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.
- crashresume-0.1.0/LICENSE +21 -0
- crashresume-0.1.0/PKG-INFO +102 -0
- crashresume-0.1.0/README.md +87 -0
- crashresume-0.1.0/pyproject.toml +27 -0
- crashresume-0.1.0/setup.cfg +4 -0
- crashresume-0.1.0/src/crashresume/__init__.py +23 -0
- crashresume-0.1.0/src/crashresume/cli.py +126 -0
- crashresume-0.1.0/src/crashresume/core.py +229 -0
- crashresume-0.1.0/src/crashresume/pytest_plugin.py +24 -0
- crashresume-0.1.0/src/crashresume.egg-info/PKG-INFO +102 -0
- crashresume-0.1.0/src/crashresume.egg-info/SOURCES.txt +14 -0
- crashresume-0.1.0/src/crashresume.egg-info/dependency_links.txt +1 -0
- crashresume-0.1.0/src/crashresume.egg-info/entry_points.txt +5 -0
- crashresume-0.1.0/src/crashresume.egg-info/top_level.txt +1 -0
- crashresume-0.1.0/tests/test_core.py +258 -0
- crashresume-0.1.0/tests/test_plugin.py +54 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|