crashresume 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 calasdan-sketch
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,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: crashresume
3
+ Version: 0.1.0
4
+ Summary: Conformance harness that proves an agent survives crash/restart without losing state or replaying effects
5
+ Author: calasdan-sketch
6
+ License-Expression: MIT
7
+ Keywords: agents,conformance,crash,resume,idempotency,pytest,recovery
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Topic :: Software Development :: Testing
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # crashresume
17
+
18
+ **Prove your agent survives a crash.** A conformance harness that kills and restarts a subject at every side-effect boundary, then checks the three invariants that decide whether a resume is safe:
19
+
20
+ | Invariant | Meaning |
21
+ |---|---|
22
+ | `state_lost` | after restart, persisted state is gone while the baseline had state |
23
+ | `effect_replayed` | the same idempotency key fired twice (e.g. a double charge) |
24
+ | `resume_diverged` | the resumed run finished in a different state than the baseline |
25
+
26
+ Real agent frameworks hit exactly these failures: streamed state vanishes on cancel, a payment succeeds but the client times out, the resumed run re-issues the payment. `crashresume` makes that testable without a framework — the subject is any object with a `step(input)` method.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install crashresume
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from crashresume import conform
38
+
39
+ def make_agent(ctx): # called once per (re)start — model a fresh process
40
+ return MyAgent(ctx, store=DB) # your persistence lives in DB
41
+
42
+ report = conform(make_agent, inputs=["job-1", "job-2", "job-3"])
43
+ assert report.passed, str(report)
44
+ ```
45
+
46
+ Your agent records side effects through the harness-owned context:
47
+
48
+ ```python
49
+ class MyAgent:
50
+ def __init__(self, ctx, store):
51
+ self.ctx = ctx
52
+ self.store = store
53
+ self.done = set(store.load("done"))
54
+
55
+ def step(self, job):
56
+ if job in self.done:
57
+ return # resume-safe: skip what already committed
58
+ self.ctx.effect("charge", job, key=f"charge:{job}")
59
+ self.done.add(job)
60
+ self.store.save("done", self.done)
61
+ ```
62
+
63
+ The harness runs a baseline, then re-runs crashing right after each recorded effect (side effect committed, process dead before checkpoint — the nastiest real-world case), restarts via your factory, and compares state and effect logs.
64
+
65
+ ### Per-scenario state (important)
66
+
67
+ Each crash point is an **independent scenario** — your factory is called from scratch each time. Durable state that must survive a restart but must not leak between scenarios should be keyed by `ctx.run_id` (the baseline uses `"run"`; crash point *k* and its resume share `"run-<k>"`):
68
+
69
+ ```python
70
+ stores = {}
71
+
72
+ def make_agent(ctx):
73
+ store = stores.setdefault(ctx.run_id, Store()) # fresh per scenario
74
+ return MyAgent(ctx, store) # durable within the scenario
75
+ ```
76
+
77
+ If your durable store is a real database, reset its fixture per scenario the same way.
78
+
79
+ ## pytest plugin
80
+
81
+ A `conform` fixture is registered automatically:
82
+
83
+ ```python
84
+ def test_agent_is_crash_safe(conform):
85
+ conform(lambda ctx: MyAgent(ctx, store=DB), inputs=["a", "b", "c"])
86
+ ```
87
+
88
+ ## CLI demo
89
+
90
+ ```bash
91
+ crashresume --selftest # runs a conforming agent plus two broken ones and verifies the findings
92
+ ```
93
+
94
+ ## Notes
95
+
96
+ - Subjects that record no effects through `ctx` pass trivially — route real side effects through `ctx.effect` or the harness cannot observe them.
97
+ - `state_lost` vs `resume_diverged`: an empty state after restart is "lost"; a non-empty but different final state is "diverged". Expose a `state()` method to opt into state comparison.
98
+ - Works with any persistence: SQLite, Redis, files, LangGraph checkpoints.
99
+
100
+ ## License
101
+
102
+ MIT-style: use it, ship it, no warranty.
@@ -0,0 +1,87 @@
1
+ # crashresume
2
+
3
+ **Prove your agent survives a crash.** A conformance harness that kills and restarts a subject at every side-effect boundary, then checks the three invariants that decide whether a resume is safe:
4
+
5
+ | Invariant | Meaning |
6
+ |---|---|
7
+ | `state_lost` | after restart, persisted state is gone while the baseline had state |
8
+ | `effect_replayed` | the same idempotency key fired twice (e.g. a double charge) |
9
+ | `resume_diverged` | the resumed run finished in a different state than the baseline |
10
+
11
+ Real agent frameworks hit exactly these failures: streamed state vanishes on cancel, a payment succeeds but the client times out, the resumed run re-issues the payment. `crashresume` makes that testable without a framework — the subject is any object with a `step(input)` method.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install crashresume
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from crashresume import conform
23
+
24
+ def make_agent(ctx): # called once per (re)start — model a fresh process
25
+ return MyAgent(ctx, store=DB) # your persistence lives in DB
26
+
27
+ report = conform(make_agent, inputs=["job-1", "job-2", "job-3"])
28
+ assert report.passed, str(report)
29
+ ```
30
+
31
+ Your agent records side effects through the harness-owned context:
32
+
33
+ ```python
34
+ class MyAgent:
35
+ def __init__(self, ctx, store):
36
+ self.ctx = ctx
37
+ self.store = store
38
+ self.done = set(store.load("done"))
39
+
40
+ def step(self, job):
41
+ if job in self.done:
42
+ return # resume-safe: skip what already committed
43
+ self.ctx.effect("charge", job, key=f"charge:{job}")
44
+ self.done.add(job)
45
+ self.store.save("done", self.done)
46
+ ```
47
+
48
+ The harness runs a baseline, then re-runs crashing right after each recorded effect (side effect committed, process dead before checkpoint — the nastiest real-world case), restarts via your factory, and compares state and effect logs.
49
+
50
+ ### Per-scenario state (important)
51
+
52
+ Each crash point is an **independent scenario** — your factory is called from scratch each time. Durable state that must survive a restart but must not leak between scenarios should be keyed by `ctx.run_id` (the baseline uses `"run"`; crash point *k* and its resume share `"run-<k>"`):
53
+
54
+ ```python
55
+ stores = {}
56
+
57
+ def make_agent(ctx):
58
+ store = stores.setdefault(ctx.run_id, Store()) # fresh per scenario
59
+ return MyAgent(ctx, store) # durable within the scenario
60
+ ```
61
+
62
+ If your durable store is a real database, reset its fixture per scenario the same way.
63
+
64
+ ## pytest plugin
65
+
66
+ A `conform` fixture is registered automatically:
67
+
68
+ ```python
69
+ def test_agent_is_crash_safe(conform):
70
+ conform(lambda ctx: MyAgent(ctx, store=DB), inputs=["a", "b", "c"])
71
+ ```
72
+
73
+ ## CLI demo
74
+
75
+ ```bash
76
+ crashresume --selftest # runs a conforming agent plus two broken ones and verifies the findings
77
+ ```
78
+
79
+ ## Notes
80
+
81
+ - Subjects that record no effects through `ctx` pass trivially — route real side effects through `ctx.effect` or the harness cannot observe them.
82
+ - `state_lost` vs `resume_diverged`: an empty state after restart is "lost"; a non-empty but different final state is "diverged". Expose a `state()` method to opt into state comparison.
83
+ - Works with any persistence: SQLite, Redis, files, LangGraph checkpoints.
84
+
85
+ ## License
86
+
87
+ MIT-style: use it, ship it, no warranty.
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "crashresume"
7
+ version = "0.1.0"
8
+ description = "Conformance harness that proves an agent survives crash/restart without losing state or replaying effects"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "calasdan-sketch" }]
13
+ keywords = ["agents", "conformance", "crash", "resume", "idempotency", "pytest", "recovery"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Framework :: Pytest",
17
+ "Topic :: Software Development :: Testing",
18
+ ]
19
+
20
+ [project.scripts]
21
+ crashresume = "crashresume.cli:main"
22
+
23
+ [project.entry-points.pytest11]
24
+ crashresume = "crashresume.pytest_plugin"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,23 @@
1
+ """crashresume — prove an agent survives crash/restart without losing state or replaying effects."""
2
+
3
+ from .core import (
4
+ ConformanceReport,
5
+ Context,
6
+ Effect,
7
+ EffectLog,
8
+ Finding,
9
+ InjectedCrash,
10
+ conform,
11
+ )
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = [
15
+ "conform",
16
+ "Context",
17
+ "Effect",
18
+ "EffectLog",
19
+ "Finding",
20
+ "InjectedCrash",
21
+ "ConformanceReport",
22
+ "__version__",
23
+ ]
@@ -0,0 +1,126 @@
1
+ """crashresume CLI: a self-test demo that exercises good and bad agents."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from typing import Any, Dict
7
+
8
+ from . import __version__
9
+ from .core import Context, InjectedCrash, conform
10
+
11
+
12
+ class _Store:
13
+ """Stand-in for durable storage shared across process restarts."""
14
+
15
+ def __init__(self) -> None:
16
+ self.data: Dict[str, Any] = {}
17
+
18
+
19
+ class _Stores:
20
+ """Per-scenario durable stores, isolated by ctx.run_id (see README)."""
21
+
22
+ def __init__(self) -> None:
23
+ self.d: Dict[str, _Store] = {}
24
+
25
+ def get(self, ctx) -> "_Store":
26
+ return self.d.setdefault(ctx.run_id, _Store())
27
+
28
+
29
+ class GoodAgent:
30
+ """Persists after every input and reconciles against committed effects."""
31
+
32
+ def __init__(self, ctx: Context, store: _Store) -> None:
33
+ self.ctx = ctx
34
+ self.store = store
35
+ self.done = set(store.data.get("done", set()))
36
+
37
+ def step(self, item: str) -> None:
38
+ key = f"process:{item}"
39
+ if item in self.done or key in self.ctx.effects.keys():
40
+ self.done.add(item)
41
+ self.store.data["done"] = set(self.done)
42
+ return
43
+ self.ctx.effect("process", item, key=key)
44
+ self.done.add(item)
45
+ self.store.data["done"] = set(self.done)
46
+
47
+ def state(self):
48
+ return {"done": sorted(self.store.data.get("done", set()))}
49
+
50
+
51
+ class ReplayAgent:
52
+ """Persists state but re-issues effects for everything on resume."""
53
+
54
+ def __init__(self, ctx: Context, store: _Store) -> None:
55
+ self.ctx = ctx
56
+ self.store = store
57
+
58
+ def step(self, item: str) -> None:
59
+ self.ctx.effect("process", item, key=f"process:{item}") # always fires
60
+ self.store.data.setdefault("done", set()).add(item)
61
+
62
+ def state(self):
63
+ return {"done": sorted(self.store.data.get("done", set()))}
64
+
65
+
66
+ class LostAgent:
67
+ """Loses its checkpoint and wrongly believes the stream is exhausted."""
68
+
69
+ def __init__(self, ctx: Context, store: _Store) -> None:
70
+ self.ctx = ctx
71
+ self.store = store
72
+
73
+ def step(self, item: str) -> None:
74
+ if self.store.data.get("exhausted"):
75
+ return
76
+ self.ctx.effect("process", item, key=f"process:{item}")
77
+ self.store.data.setdefault("done", []).append(item)
78
+
79
+ def state(self):
80
+ return {"done": list(self.store.data.get("done", []))}
81
+
82
+
83
+ def main(argv: list[str] | None = None) -> int:
84
+ ap = argparse.ArgumentParser(prog="crashresume", description=__doc__.splitlines()[0])
85
+ ap.add_argument("--version", action="version", version=f"crashresume {__version__}")
86
+ ap.add_argument("--selftest", action="store_true", help="run the built-in good/bad agent demo")
87
+ args = ap.parse_args(argv)
88
+ if not args.selftest:
89
+ ap.print_help()
90
+ return 0
91
+
92
+ inputs = ["a", "b", "c"]
93
+ ok = True
94
+
95
+ good_stores = _Stores()
96
+ good = conform(lambda ctx: GoodAgent(ctx, good_stores.get(ctx)), inputs)
97
+ print("GoodAgent (persist + reconcile against committed effects):")
98
+ print(" ", good)
99
+ ok &= good.passed
100
+
101
+ replay_stores = _Stores()
102
+ replay = conform(lambda ctx: ReplayAgent(ctx, replay_stores.get(ctx)), inputs)
103
+ print("ReplayAgent (re-fires effects on resume):")
104
+ print(" ", replay)
105
+ ok &= not replay.passed and any(f.kind == "effect_replayed" for f in replay.findings)
106
+
107
+ lost_stores = _Stores()
108
+
109
+ def make_lost(ctx):
110
+ store = lost_stores.get(ctx)
111
+ if ctx.run_id != "run": # checkpoint lost in the crash
112
+ store.data["done"] = []
113
+ store.data["exhausted"] = True
114
+ return LostAgent(ctx, store)
115
+
116
+ lost = conform(make_lost, inputs)
117
+ print("LostAgent (checkpoint lost, wrongly believes stream is done):")
118
+ print(" ", lost)
119
+ ok &= not lost.passed and any(f.kind == "state_lost" for f in lost.findings)
120
+
121
+ print("\nselftest:", "PASS" if ok else "FAIL")
122
+ return 0 if ok else 1
123
+
124
+
125
+ if __name__ == "__main__": # pragma: no cover
126
+ sys.exit(main())
@@ -0,0 +1,229 @@
1
+ """Crash/restart conformance: run a subject, then re-run it crashing at every
2
+ side-effect boundary, and check the three invariants that matter:
3
+
4
+ * ``state_lost`` — after restart, the subject's persisted state is empty/gone
5
+ while the baseline run had state
6
+ * ``effect_replayed`` — the same idempotency key was issued twice, or the final
7
+ effect log contains effects the baseline never produced
8
+ * ``resume_diverged`` — the resumed run finished in a different state than the
9
+ baseline run
10
+
11
+ The subject is any object with a ``step(input)`` method. The harness owns the
12
+ side-effect channel: subjects record effects through ``ctx.effect(...)``, which
13
+ is where crashes get injected. Persistence across restart is the subject's job —
14
+ the factory is called again to model a fresh process.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import itertools
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Callable, Iterable, List, Optional, Sequence
21
+
22
+
23
+ class InjectedCrash(RuntimeError):
24
+ """Raised inside the subject to simulate a process death at a side effect."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Effect:
29
+ kind: str
30
+ payload: Any = None
31
+ key: Optional[str] = None
32
+ seq: int = 0
33
+
34
+
35
+ class EffectLog:
36
+ """Durable record of side effects, shared across restarts of one run."""
37
+
38
+ def __init__(self) -> None:
39
+ self._effects: List[Effect] = []
40
+
41
+ def record(self, kind: str, payload: Any = None, key: Optional[str] = None) -> Effect:
42
+ e = Effect(kind=kind, payload=payload, key=key, seq=len(self._effects))
43
+ self._effects.append(e)
44
+ return e
45
+
46
+ @property
47
+ def effects(self) -> List[Effect]:
48
+ return list(self._effects)
49
+
50
+ def keys(self) -> List[str]:
51
+ return [e.key for e in self._effects if e.key is not None]
52
+
53
+
54
+ class Context:
55
+ """Handed to the factory on every (re)start; subjects record effects via it.
56
+
57
+ ``run_id`` is the scenario namespace: the baseline run uses ``"run"``;
58
+ crash point *k* and its resume share ``"run-<k>"``. State that must survive
59
+ a restart but must NOT leak from one scenario into the next should be keyed
60
+ by ``ctx.run_id`` (e.g. an in-memory ``stores.setdefault(ctx.run_id, ...)``).
61
+ """
62
+
63
+ def __init__(self, run_id: str, log: EffectLog, crash_at: Optional[int] = None) -> None:
64
+ self.run_id = run_id
65
+ self.effects = log
66
+ self._log = log
67
+ self._crash_at = crash_at
68
+ self._count = 0
69
+
70
+ def effect(self, kind: str, payload: Any = None, key: Optional[str] = None) -> Effect:
71
+ """Record a side effect. The crash is injected right after this call,
72
+ modelling: side effect committed, process died before the checkpoint."""
73
+ e = self._log.record(kind, payload, key)
74
+ self._count += 1
75
+ if self._crash_at is not None and self._count >= self._crash_at:
76
+ raise InjectedCrash(f"crash injected after effect #{self._count} ({kind})")
77
+ return e
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class Finding:
82
+ kind: str # state_lost | effect_replayed | effect_lost | resume_diverged | resume_crashed
83
+ crash_after: int # which effect boundary the crash was injected at
84
+ detail: str
85
+
86
+
87
+ @dataclass
88
+ class ConformanceReport:
89
+ crash_points: int = 0
90
+ findings: List[Finding] = field(default_factory=list)
91
+
92
+ @property
93
+ def passed(self) -> bool:
94
+ return not self.findings
95
+
96
+ def __bool__(self) -> bool:
97
+ return self.passed
98
+
99
+ def __str__(self) -> str:
100
+ head = (
101
+ f"conformance: {self.crash_points} crash point(s) tested, "
102
+ f"{len(self.findings)} finding(s)"
103
+ )
104
+ if not self.findings:
105
+ return head + " — PASS"
106
+ body = "\n".join(
107
+ f" [{f.kind}] crash after effect #{f.crash_after}: {f.detail}"
108
+ for f in self.findings
109
+ )
110
+ return head + "\n" + body
111
+
112
+
113
+ def _is_empty(value: Any) -> bool:
114
+ """Deep emptiness: None, empty containers, or containers whose contents are
115
+ all empty. Numbers are never empty (0 is data, not absence)."""
116
+ if value is None or value is False:
117
+ return True
118
+ if isinstance(value, str):
119
+ return value == ""
120
+ if isinstance(value, dict):
121
+ return all(_is_empty(v) for v in value.values())
122
+ if isinstance(value, (list, tuple, set)):
123
+ return len(value) == 0 or all(_is_empty(v) for v in value)
124
+ return False
125
+
126
+
127
+ def _snapshot(agent: Any) -> Any:
128
+ """Best-effort observable state of a subject."""
129
+ state = getattr(agent, "state", None)
130
+ if callable(state):
131
+ return state()
132
+ if hasattr(agent, "__dict__"):
133
+ return {k: v for k, v in vars(agent).items() if not k.startswith("_") and k != "ctx"}
134
+ return None
135
+
136
+
137
+ def _run(factory: Callable[[Context], Any], inputs: Sequence[Any], log: EffectLog,
138
+ run_id: str, crash_at: Optional[int] = None) -> Any:
139
+ agent = factory(Context(run_id, log, crash_at=crash_at))
140
+ for item in inputs:
141
+ agent.step(item)
142
+ return agent
143
+
144
+
145
+ def conform(
146
+ factory: Callable[[Context], Any],
147
+ inputs: Iterable[Any],
148
+ run_id: str = "run",
149
+ ) -> ConformanceReport:
150
+ """Run ``factory(ctx) -> subject`` to completion, then re-run it crashing at
151
+ every effect boundary and compare against the baseline.
152
+
153
+ Each crash point is an independent scenario: the factory is called again
154
+ from scratch (baseline state included). Key per-scenario durable state by
155
+ ``ctx.run_id`` so scenarios don't contaminate each other — see Context.
156
+ """
157
+ inputs = list(inputs)
158
+
159
+ baseline_log = EffectLog()
160
+ baseline_agent = _run(factory, inputs, baseline_log, run_id)
161
+ base_state = _snapshot(baseline_agent)
162
+ base_effects = baseline_log.effects
163
+ base_keys = [e.key for e in base_effects if e.key]
164
+
165
+ report = ConformanceReport(crash_points=len(base_effects))
166
+ if not base_effects:
167
+ # Nothing to crash against: subject records no side effects through ctx.
168
+ return report
169
+
170
+ counter = itertools.count(1)
171
+ for k in range(1, len(base_effects) + 1):
172
+ run_id_k = f"{run_id}-{next(counter)}"
173
+ log = EffectLog()
174
+ try:
175
+ _run(factory, inputs, log, run_id_k, crash_at=k)
176
+ except InjectedCrash:
177
+ pass # expected
178
+
179
+ # restart: fresh process model, same durable log
180
+ try:
181
+ agent = factory(Context(run_id_k, log))
182
+ for item in inputs:
183
+ agent.step(item)
184
+ final_state = _snapshot(agent)
185
+ except InjectedCrash:
186
+ report.findings.append(Finding("resume_crashed", k, "resume run raised InjectedCrash again"))
187
+ continue
188
+
189
+ final_effects = log.effects
190
+ final_keys = [e.key for e in final_effects if e.key]
191
+
192
+ # 1. duplicate idempotency keys in the final log
193
+ seen: set = set()
194
+ for key in final_keys:
195
+ if key in seen:
196
+ report.findings.append(Finding("effect_replayed", k, f"key {key!r} issued more than once"))
197
+ seen.add(key)
198
+
199
+ # 2. keyed effects the baseline never issued (beyond one occurrence)
200
+ extra = [key for key in final_keys if final_keys.count(key) > base_keys.count(key)]
201
+ for key in sorted(set(extra)):
202
+ if not any(f.kind == "effect_replayed" and key in f.detail for f in report.findings):
203
+ report.findings.append(Finding("effect_replayed", k, f"key {key!r} appears more often than in baseline"))
204
+
205
+ # 3. keyed effects that vanished
206
+ for key in sorted(set(base_keys) - set(final_keys)):
207
+ report.findings.append(Finding("effect_lost", k, f"key {key!r} never re-issued after resume"))
208
+
209
+ # 4. state invariants (only when the subject exposes comparable state).
210
+ # A subject that reconciles against the committed effect log may start
211
+ # from an empty checkpoint and still converge — that is fine. We flag
212
+ # state only when the loss survives the whole resume: final state is
213
+ # empty while the baseline had state (state_lost), or non-empty but
214
+ # different (resume_diverged).
215
+ if base_state is not None and final_state is not None:
216
+ if _is_empty(final_state) and not _is_empty(base_state):
217
+ report.findings.append(
218
+ Finding("state_lost", k, f"final state {final_state!r} is empty but baseline state was {base_state!r}")
219
+ )
220
+ elif final_state != base_state:
221
+ report.findings.append(
222
+ Finding(
223
+ "resume_diverged",
224
+ k,
225
+ f"final state {final_state!r} != baseline {base_state!r}",
226
+ )
227
+ )
228
+
229
+ return report
@@ -0,0 +1,24 @@
1
+ """pytest integration: a ``conform`` fixture that asserts and returns a report."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from . import core
7
+
8
+
9
+ @pytest.fixture
10
+ def conform():
11
+ """``conform(factory, inputs)`` — assert crash/restart conformance.
12
+
13
+ Usage inside a test::
14
+
15
+ def test_agent_is_conformant(conform, make_agent):
16
+ conform(lambda ctx: make_agent(ctx), inputs=["a", "b", "c"])
17
+ """
18
+
19
+ def run(factory, inputs, run_id: str = "run") -> core.ConformanceReport:
20
+ report = core.conform(factory, inputs, run_id=run_id)
21
+ assert report.passed, str(report)
22
+ return report
23
+
24
+ return run
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: crashresume
3
+ Version: 0.1.0
4
+ Summary: Conformance harness that proves an agent survives crash/restart without losing state or replaying effects
5
+ Author: calasdan-sketch
6
+ License-Expression: MIT
7
+ Keywords: agents,conformance,crash,resume,idempotency,pytest,recovery
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Topic :: Software Development :: Testing
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # crashresume
17
+
18
+ **Prove your agent survives a crash.** A conformance harness that kills and restarts a subject at every side-effect boundary, then checks the three invariants that decide whether a resume is safe:
19
+
20
+ | Invariant | Meaning |
21
+ |---|---|
22
+ | `state_lost` | after restart, persisted state is gone while the baseline had state |
23
+ | `effect_replayed` | the same idempotency key fired twice (e.g. a double charge) |
24
+ | `resume_diverged` | the resumed run finished in a different state than the baseline |
25
+
26
+ Real agent frameworks hit exactly these failures: streamed state vanishes on cancel, a payment succeeds but the client times out, the resumed run re-issues the payment. `crashresume` makes that testable without a framework — the subject is any object with a `step(input)` method.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install crashresume
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ from crashresume import conform
38
+
39
+ def make_agent(ctx): # called once per (re)start — model a fresh process
40
+ return MyAgent(ctx, store=DB) # your persistence lives in DB
41
+
42
+ report = conform(make_agent, inputs=["job-1", "job-2", "job-3"])
43
+ assert report.passed, str(report)
44
+ ```
45
+
46
+ Your agent records side effects through the harness-owned context:
47
+
48
+ ```python
49
+ class MyAgent:
50
+ def __init__(self, ctx, store):
51
+ self.ctx = ctx
52
+ self.store = store
53
+ self.done = set(store.load("done"))
54
+
55
+ def step(self, job):
56
+ if job in self.done:
57
+ return # resume-safe: skip what already committed
58
+ self.ctx.effect("charge", job, key=f"charge:{job}")
59
+ self.done.add(job)
60
+ self.store.save("done", self.done)
61
+ ```
62
+
63
+ The harness runs a baseline, then re-runs crashing right after each recorded effect (side effect committed, process dead before checkpoint — the nastiest real-world case), restarts via your factory, and compares state and effect logs.
64
+
65
+ ### Per-scenario state (important)
66
+
67
+ Each crash point is an **independent scenario** — your factory is called from scratch each time. Durable state that must survive a restart but must not leak between scenarios should be keyed by `ctx.run_id` (the baseline uses `"run"`; crash point *k* and its resume share `"run-<k>"`):
68
+
69
+ ```python
70
+ stores = {}
71
+
72
+ def make_agent(ctx):
73
+ store = stores.setdefault(ctx.run_id, Store()) # fresh per scenario
74
+ return MyAgent(ctx, store) # durable within the scenario
75
+ ```
76
+
77
+ If your durable store is a real database, reset its fixture per scenario the same way.
78
+
79
+ ## pytest plugin
80
+
81
+ A `conform` fixture is registered automatically:
82
+
83
+ ```python
84
+ def test_agent_is_crash_safe(conform):
85
+ conform(lambda ctx: MyAgent(ctx, store=DB), inputs=["a", "b", "c"])
86
+ ```
87
+
88
+ ## CLI demo
89
+
90
+ ```bash
91
+ crashresume --selftest # runs a conforming agent plus two broken ones and verifies the findings
92
+ ```
93
+
94
+ ## Notes
95
+
96
+ - Subjects that record no effects through `ctx` pass trivially — route real side effects through `ctx.effect` or the harness cannot observe them.
97
+ - `state_lost` vs `resume_diverged`: an empty state after restart is "lost"; a non-empty but different final state is "diverged". Expose a `state()` method to opt into state comparison.
98
+ - Works with any persistence: SQLite, Redis, files, LangGraph checkpoints.
99
+
100
+ ## License
101
+
102
+ MIT-style: use it, ship it, no warranty.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/crashresume/__init__.py
5
+ src/crashresume/cli.py
6
+ src/crashresume/core.py
7
+ src/crashresume/pytest_plugin.py
8
+ src/crashresume.egg-info/PKG-INFO
9
+ src/crashresume.egg-info/SOURCES.txt
10
+ src/crashresume.egg-info/dependency_links.txt
11
+ src/crashresume.egg-info/entry_points.txt
12
+ src/crashresume.egg-info/top_level.txt
13
+ tests/test_core.py
14
+ tests/test_plugin.py
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ crashresume = crashresume.cli:main
3
+
4
+ [pytest11]
5
+ crashresume = crashresume.pytest_plugin
@@ -0,0 +1 @@
1
+ crashresume
@@ -0,0 +1,258 @@
1
+ """Core conformance-engine tests.
2
+
3
+ Every factory here keys its durable store by ``ctx.run_id`` so that each crash
4
+ scenario starts from the same initial state as the baseline — the documented
5
+ isolation protocol.
6
+ """
7
+ from crashresume import ConformanceReport, Context, EffectLog, InjectedCrash, conform
8
+
9
+
10
+ class Store:
11
+ def __init__(self):
12
+ self.data = {}
13
+
14
+
15
+ class Stores:
16
+ """Per-scenario stores, isolated by run_id."""
17
+
18
+ def __init__(self):
19
+ self.d = {}
20
+
21
+ def get(self, ctx):
22
+ return self.d.setdefault(ctx.run_id, Store())
23
+
24
+
25
+ class GoodAgent:
26
+ def __init__(self, ctx: Context, store: Store):
27
+ self.ctx, self.store = ctx, store
28
+ self.done = set(store.data.get("done", set()))
29
+
30
+ def step(self, item):
31
+ key = f"process:{item}"
32
+ if item in self.done or key in self.ctx.effects.keys():
33
+ self.done.add(item)
34
+ self.store.data["done"] = set(self.done)
35
+ return
36
+ self.ctx.effect("process", item, key=key)
37
+ self.done.add(item)
38
+ self.store.data["done"] = set(self.done)
39
+
40
+ def state(self):
41
+ return {"done": sorted(self.store.data.get("done", set()))}
42
+
43
+
44
+ class LazyAgent:
45
+ def __init__(self, ctx: Context, store: Store):
46
+ self.ctx = ctx
47
+ self.done = []
48
+
49
+ def step(self, item):
50
+ self.ctx.effect("process", item, key=f"process:{item}")
51
+ self.done.append(item)
52
+
53
+ def state(self):
54
+ return {"done": list(self.done)}
55
+
56
+
57
+ class ReplayAgent:
58
+ def __init__(self, ctx: Context, store: Store):
59
+ self.ctx, self.store = ctx, store
60
+
61
+ def step(self, item):
62
+ self.ctx.effect("process", item, key=f"process:{item}")
63
+ self.store.data.setdefault("done", set()).add(item)
64
+
65
+ def state(self):
66
+ return {"done": sorted(self.store.data.get("done", set()))}
67
+
68
+
69
+ class UnkeyedAgent:
70
+ """No idempotency keys; harness falls back to state comparison only."""
71
+
72
+ def __init__(self, ctx: Context, store: Store):
73
+ self.ctx, self.store = ctx, store
74
+ self.n = store.data.get("n", 0)
75
+
76
+ def step(self, item):
77
+ if self.n >= 3:
78
+ return
79
+ self.ctx.effect("process", item)
80
+ self.n += 1
81
+ self.store.data["n"] = self.n
82
+
83
+ def state(self):
84
+ return {"n": self.n}
85
+
86
+
87
+ def test_good_agent_passes():
88
+ stores = Stores()
89
+ report = conform(lambda ctx: GoodAgent(ctx, stores.get(ctx)), ["a", "b", "c"])
90
+ assert isinstance(report, ConformanceReport)
91
+ assert report.passed, str(report)
92
+ assert report.crash_points == 3
93
+
94
+
95
+ def test_lazy_agent_flagged_as_replay():
96
+ stores = Stores()
97
+ report = conform(lambda ctx: LazyAgent(ctx, stores.get(ctx)), ["a", "b"])
98
+ assert not report.passed
99
+ assert any(f.kind == "effect_replayed" for f in report.findings)
100
+
101
+
102
+ def test_replay_agent_flagged():
103
+ stores = Stores()
104
+ report = conform(lambda ctx: ReplayAgent(ctx, stores.get(ctx)), ["a", "b"])
105
+ assert not report.passed
106
+ assert any(f.kind == "effect_replayed" for f in report.findings)
107
+
108
+
109
+ def test_unkeyed_agent_still_conforms_when_count_stable():
110
+ stores = Stores()
111
+ report = conform(lambda ctx: UnkeyedAgent(ctx, stores.get(ctx)), ["a", "b", "c", "d"])
112
+ assert report.passed, str(report)
113
+
114
+
115
+ def test_no_effects_trivially_passes():
116
+ class Silent:
117
+ def step(self, item):
118
+ pass
119
+
120
+ report = conform(lambda ctx: Silent(), ["x"])
121
+ assert report.passed and report.crash_points == 0
122
+
123
+
124
+ def test_state_lost_when_subject_stops_reading_after_lost_checkpoint():
125
+ # classic offset-commit failure: checkpoint lost, subject wrongly believes
126
+ # the stream is exhausted -> final state empty though baseline had state
127
+ stores = Stores()
128
+
129
+ class Agent:
130
+ def __init__(self, ctx, store):
131
+ self.ctx, self.store = ctx, store
132
+
133
+ def step(self, item):
134
+ if self.store.data.get("exhausted"):
135
+ return
136
+ self.ctx.effect("e", item, key=item)
137
+ self.store.data.setdefault("done", []).append(item)
138
+
139
+ def state(self):
140
+ return {"done": list(self.store.data.get("done", []))}
141
+
142
+ def factory(ctx):
143
+ store = stores.get(ctx)
144
+ if ctx.run_id != "run": # simulate: checkpoint lost in the crash
145
+ store.data["done"] = []
146
+ store.data["exhausted"] = True
147
+ return Agent(ctx, store)
148
+
149
+ report = conform(factory, ["a", "b"])
150
+ assert any(f.kind == "state_lost" for f in report.findings), str(report)
151
+
152
+
153
+ def test_resume_diverged_when_final_state_differs():
154
+ stores = Stores()
155
+
156
+ class Agent:
157
+ def __init__(self, ctx, store):
158
+ self.ctx, self.store = ctx, store
159
+
160
+ def step(self, item):
161
+ key = f"e:{item}"
162
+ if key in self.ctx.effects.keys():
163
+ return
164
+ self.ctx.effect("e", item, key=key)
165
+ self.store.data.setdefault("done", []).append(item)
166
+
167
+ def state(self):
168
+ return {"done": sorted(self.store.data.get("done", []))}
169
+
170
+ def factory(ctx):
171
+ store = stores.get(ctx)
172
+ if ctx.run_id != "run":
173
+ store.data["done"] = ["ghost"] # corrupted but non-empty checkpoint
174
+ return Agent(ctx, store)
175
+
176
+ report = conform(factory, ["a", "b"])
177
+ assert any(f.kind == "resume_diverged" for f in report.findings), str(report)
178
+
179
+
180
+ def test_crash_injected_at_every_boundary():
181
+ stores = Stores()
182
+ seen = []
183
+
184
+ class Recorder:
185
+ def __init__(self, ctx, store):
186
+ self.ctx, self.store = ctx, store
187
+ self.done = set(store.data.get("done", set()))
188
+
189
+ def step(self, item):
190
+ if item in self.done or item in self.ctx.effects.keys():
191
+ self.done.add(item)
192
+ self.store.data["done"] = set(self.done)
193
+ return
194
+ seen.append((self.ctx.run_id, tuple(self.ctx.effects.keys())))
195
+ self.ctx.effect("e", item, key=item)
196
+ self.done.add(item)
197
+ self.store.data["done"] = set(self.done)
198
+
199
+ conform(lambda ctx: Recorder(ctx, stores.get(ctx)), ["1", "2"])
200
+ run_ids = [r[0] for r in seen]
201
+ assert len(set(run_ids)) >= 3
202
+
203
+
204
+ def test_report_str_lists_findings():
205
+ stores = Stores()
206
+ report = conform(lambda ctx: ReplayAgent(ctx, stores.get(ctx)), ["a"])
207
+ text = str(report)
208
+ assert "effect_replayed" in text and "crash after effect #1" in text
209
+
210
+
211
+ def test_effect_log_shared_across_restart():
212
+ log = EffectLog()
213
+ ctx = Context("r", log)
214
+ ctx.effect("a", key="k1")
215
+ assert log.keys() == ["k1"]
216
+
217
+
218
+ def test_injected_crash_raised_after_recording():
219
+ log = EffectLog()
220
+ ctx = Context("r", log, crash_at=1)
221
+ try:
222
+ ctx.effect("charge", key="pay:1")
223
+ raised = False
224
+ except InjectedCrash:
225
+ raised = True
226
+ assert raised and log.keys() == ["pay:1"]
227
+
228
+
229
+ def test_scenarios_are_isolated():
230
+ # the baseline must not poison crash scenarios via shared durable state
231
+ stores = Stores()
232
+ starts = []
233
+
234
+ class Agent:
235
+ def __init__(self, ctx, store):
236
+ self.ctx, self.store = ctx, store
237
+ self.done = set(store.data.get("done", set()))
238
+ starts.append((ctx.run_id, sorted(self.done)))
239
+
240
+ def step(self, item):
241
+ key = f"e:{item}"
242
+ if key in self.ctx.effects.keys():
243
+ self.store.data.setdefault("done", set()).add(item) # heal from log
244
+ return
245
+ self.ctx.effect("e", item, key=key)
246
+ self.store.data.setdefault("done", set()).add(item)
247
+
248
+ def state(self):
249
+ return {"done": sorted(self.store.data.get("done", set()))}
250
+
251
+ report = conform(lambda ctx: Agent(ctx, stores.get(ctx)), ["a", "b"])
252
+ assert report.passed, str(report)
253
+ # the FIRST construction of every scenario starts from an empty checkpoint
254
+ # (resumes within a scenario may see that scenario's crash-run state)
255
+ first_seen = {}
256
+ for rid, done in starts:
257
+ first_seen.setdefault(rid, done)
258
+ assert all(done == [] for done in first_seen.values())
@@ -0,0 +1,54 @@
1
+ """The pytest plugin ships a working fixture — exercise it from a real test run."""
2
+ PLUGIN = """
3
+ STORES = {}
4
+
5
+
6
+ def store_for(ctx):
7
+ return STORES.setdefault(ctx.run_id, {"done": set()})
8
+
9
+
10
+ def test_conform_fixture_passes(conform):
11
+ class Agent:
12
+ def __init__(self, ctx):
13
+ self.ctx = ctx
14
+ self.store = store_for(ctx)
15
+ self.done = set(self.store["done"])
16
+
17
+ def step(self, item):
18
+ if item in self.done or item in self.ctx.effects.keys():
19
+ self.done.add(item)
20
+ self.store["done"] = set(self.done)
21
+ return
22
+ self.ctx.effect("e", item, key=item)
23
+ self.done.add(item)
24
+ self.store["done"] = set(self.done)
25
+
26
+ def state(self):
27
+ return {"done": sorted(self.store["done"])}
28
+
29
+ conform(lambda ctx: Agent(ctx), ["a", "b"])
30
+
31
+
32
+ def test_conform_fixture_fails_on_replay(conform):
33
+ class Agent:
34
+ def __init__(self, ctx):
35
+ self.ctx = ctx
36
+
37
+ def step(self, item):
38
+ self.ctx.effect("e", item, key=item)
39
+
40
+ import pytest
41
+ with pytest.raises(AssertionError):
42
+ conform(lambda ctx: Agent(ctx), ["a"])
43
+ """
44
+
45
+
46
+ def test_plugin_fixture_registered_and_asserting(tmp_path, pytester):
47
+ pytester.makepyfile(PLUGIN)
48
+ # Expose the shipped fixture via conftest instead of -p: when the package is
49
+ # pip-installed (CI), its pytest11 entry point already registers the plugin,
50
+ # and an explicit -p load makes pluggy reject the double registration.
51
+ # The conftest import works in both installed and PYTHONPATH-only runs.
52
+ pytester.makeconftest("from crashresume.pytest_plugin import conform # noqa: F401")
53
+ result = pytester.runpytest()
54
+ assert result.ret == 0