dhp-protocol 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dhp/chaos.py ADDED
@@ -0,0 +1,205 @@
1
+ """DHP fault-injection harness — randomized, not scripted.
2
+
3
+ Spawns workers and supervisors as real OS processes, then a fault
4
+ injector kills random victims at random times (SIGKILL — no warning):
5
+ workers, the leader supervisor, sometimes both. A provisioner loop
6
+ detects orphans and spawns recovery workers automatically.
7
+
8
+ This is the "difficult, surprising" test: nobody decides who dies when.
9
+ Invariants asserted at the end:
10
+ - every handoff completed exactly once, result correct
11
+ - zero checkpoint rework (sequence continuous per handoff)
12
+ - exactly one owner at every point (no split-brain)
13
+
14
+ Usage: python3 -m dhp.chaos [--rounds N] [--seed S]
15
+ """
16
+ import argparse
17
+ import json
18
+ import os
19
+ import random
20
+ import shutil
21
+ import signal
22
+ import subprocess
23
+ import sys
24
+ import time
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
+
28
+ from dhp import envelope as E
29
+ from dhp.runner3 import Runner3
30
+ from dhp.prime_chunk import chunk
31
+
32
+ HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
33
+ DRIVER = os.path.join(HERE, "dhp", "work_driver4.py")
34
+ SUPWATCH = os.path.join(HERE, "dhp", "supervisor3_watch.py")
35
+ SCHEMA = {"type": "object",
36
+ "properties": {"lo": {"type": "integer"}, "hi": {"type": "integer"},
37
+ "prime_count": {"type": "integer"},
38
+ "prime_sum": {"type": "integer"},
39
+ "chunks_done": {"type": "integer"}},
40
+ "required": ["lo", "hi", "prime_count", "prime_sum", "chunks_done"]}
41
+
42
+
43
+ def setup(root, n_tasks=3, chunks=400, chunk=100, span=40000):
44
+ shutil.rmtree(root, ignore_errors=True)
45
+ os.makedirs(root, exist_ok=True)
46
+ r = Runner3(root, runner_id="chaos")
47
+ hids = []
48
+ base = 5000000
49
+ for i in range(n_tasks):
50
+ lo = base + i * span
51
+ env = E.build(
52
+ task_kind="prime.census",
53
+ inputs={"inline": {"lo": lo, "hi": lo + chunks * chunk,
54
+ "chunk": chunk}},
55
+ output_schema=SCHEMA,
56
+ budget={"max_steps": chunks + 100, "max_spend_usd": 500.0,
57
+ "max_wallclock_s": 5400.0, "max_attempts": 25},
58
+ created_by="chaos-harness")
59
+ hids.append(r.dispatch(env))
60
+ r.store.close()
61
+ return hids
62
+
63
+
64
+ def main():
65
+ ap = argparse.ArgumentParser()
66
+ ap.add_argument("--rounds", type=int, default=1)
67
+ ap.add_argument("--seed", type=int, default=None)
68
+ a = ap.parse_args()
69
+ rng = random.Random(a.seed)
70
+
71
+ for rnd in range(a.rounds):
72
+ root = f"/tmp/dhp-chaos-{os.getpid()}-{rnd}"
73
+ tdir = os.path.join(root, "transport")
74
+ os.makedirs(tdir, exist_ok=True)
75
+ hids = setup(root, n_tasks=3, chunks=300, chunk=100, span=30000)
76
+ print(f"[round {rnd}] {len(hids)} handoffs dispatched")
77
+
78
+ # supervisors (2, leader election decides)
79
+ sups = {}
80
+ for sid in ("supA", "supB"):
81
+ p = subprocess.Popen(
82
+ [sys.executable, SUPWATCH, root, sid, "300"],
83
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
84
+ sups[sid] = p
85
+
86
+ # workers
87
+ workers = {} # hid -> Popen
88
+ for i, hid in enumerate(hids):
89
+ log = os.path.join(tdir, hid + ".log")
90
+ p = subprocess.Popen(
91
+ [sys.executable, DRIVER, "--root", root,
92
+ "--runner", f"w{i}", "--chunk", "100", "--pace", "0.05",
93
+ "--ship", log, "claim", hid],
94
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
95
+ workers[hid] = p
96
+
97
+ kills = {"workers": 0, "supervisors": 0}
98
+ deadline = time.time() + 240
99
+ r = Runner3(root, runner_id="chaos-mon")
100
+
101
+ def live_workers():
102
+ return {h: p for h, p in workers.items() if p.poll() is None}
103
+
104
+ def recover_orphans():
105
+ for hid in r.store.all_hids():
106
+ env = r._load(hid)
107
+ if env["execution"]["status"] == "orphaned" and hid not in live_workers():
108
+ import uuid
109
+ rid = f"rw{uuid.uuid4().hex[:8]}"
110
+ log = os.path.join(tdir, hid + ".log")
111
+ p = subprocess.Popen(
112
+ [sys.executable, DRIVER, "--root", root,
113
+ "--runner", rid,
114
+ "--chunk", "100", "--pace", "0.05",
115
+ "--ship", log, "recover", hid],
116
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
117
+ workers[hid] = p
118
+ print(f"[round {rnd}] provisioned recovery for {hid[:12]}")
119
+
120
+ while time.time() < deadline:
121
+ # random fault injection
122
+ time.sleep(rng.uniform(1.0, 3.0))
123
+ roll = rng.random()
124
+ lw = live_workers()
125
+ if roll < 0.55 and lw:
126
+ hid = rng.choice(list(lw))
127
+ lw[hid].send_signal(signal.SIGKILL)
128
+ kills["workers"] += 1
129
+ print(f"[round {rnd}] FAULT: SIGKILL worker on {hid[:12]}")
130
+ elif roll < 0.75:
131
+ # kill a random supervisor (maybe the leader, maybe not —
132
+ # the injector doesn't know, that's the point)
133
+ sid = rng.choice(list(sups))
134
+ if sups[sid].poll() is None:
135
+ sups[sid].send_signal(signal.SIGKILL)
136
+ kills["supervisors"] += 1
137
+ print(f"[round {rnd}] FAULT: SIGKILL supervisor {sid}")
138
+ # start a replacement supervisor (standby pool)
139
+ p = subprocess.Popen(
140
+ [sys.executable, SUPWATCH, root, sid + "2", "300"],
141
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
142
+ sups[sid + "2"] = p
143
+ recover_orphans()
144
+ # done?
145
+ done = all(r._load(h)["execution"]["status"] == "completed"
146
+ for h in hids)
147
+ if done and not live_workers():
148
+ break
149
+
150
+ # stop everything
151
+ for p in list(workers.values()) + list(sups.values()):
152
+ try:
153
+ p.kill()
154
+ except Exception:
155
+ pass
156
+
157
+ # verify invariants
158
+ # completed -> must be correct. failed (DLQ) -> must have intact
159
+ # checkpoints (parked for humans, not lost). Nothing may be stuck
160
+ # in a non-terminal state with no live owner.
161
+ ok = True
162
+ live = live_workers()
163
+ for hid in hids:
164
+ env = r._load(hid)
165
+ ex = env["execution"]
166
+ rows = r.store.cx.execute(
167
+ "SELECT seq FROM checkpoints WHERE hid=? ORDER BY seq",
168
+ (hid,)).fetchall()
169
+ seqs = [x[0] for x in rows]
170
+ if seqs != list(range(len(seqs))):
171
+ print(f"[round {rnd}] INVARIANT FAIL: {hid[:12]} rework/gap")
172
+ ok = False
173
+ if ex["status"] == "completed":
174
+ res = env["delivery"]["result"]
175
+ c = s = 0
176
+ n = res["lo"]
177
+ while n < res["hi"]:
178
+ rr = chunk(n, min(n + 5000, res["hi"]))
179
+ c += rr["count"]
180
+ s += rr["sum"]
181
+ n += 5000
182
+ if c != res["prime_count"] or s != res["prime_sum"]:
183
+ print(f"[round {rnd}] INVARIANT FAIL: {hid[:12]} wrong result")
184
+ ok = False
185
+ r.ack(hid)
186
+ elif ex["status"] == "failed":
187
+ # DLQ: parked, intact, awaiting human replay — not lost.
188
+ print(f"[round {rnd}] note: {hid[:12]} in DLQ "
189
+ f"({len(seqs)} checkpoints intact)")
190
+ else:
191
+ own = r.store.get_ownership(hid)
192
+ if hid not in live and (not own or own["holder"] == "__orphaned__"
193
+ or own["lease_expires_at"] < time.time()):
194
+ print(f"[round {rnd}] INVARIANT FAIL: {hid[:12]} "
195
+ f"stuck in {ex['status']} with no live owner")
196
+ ok = False
197
+ r.store.close()
198
+ print(f"[round {rnd}] kills={kills} invariants={'OK' if ok else 'FAILED'}")
199
+ if not ok:
200
+ sys.exit(1)
201
+ print("chaos harness: ALL ROUNDS GREEN")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()
dhp/config.py ADDED
@@ -0,0 +1,78 @@
1
+ """DHP configuration. Every operational knob via env vars (DHP_* prefix).
2
+
3
+ Production rule: no magic numbers in the hot path. All defaults match
4
+ the chaos-proven v0.4 values.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ def _env_float(name: str, default: float) -> float:
13
+ try:
14
+ return float(os.environ.get(name, default))
15
+ except ValueError:
16
+ return default
17
+
18
+
19
+ def _env_int(name: str, default: int) -> int:
20
+ try:
21
+ return int(os.environ.get(name, default))
22
+ except ValueError:
23
+ return default
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DHPConfig:
28
+ # leases / supervision
29
+ lease_interval_s: float = field(
30
+ default_factory=lambda: _env_float("DHP_LEASE_INTERVAL_S", 2.0))
31
+ lease_max_missed: int = field(
32
+ default_factory=lambda: _env_int("DHP_LEASE_MAX_MISSED", 3))
33
+ supervisor_scan_s: float = field(
34
+ default_factory=lambda: _env_float("DHP_SUPERVISOR_SCAN_S", 1.0))
35
+ leader_ttl_s: float = field(
36
+ default_factory=lambda: _env_float("DHP_LEADER_TTL_S", 5.0))
37
+ # stall detection: alive but making no progress
38
+ stall_timeout_s: float = field(
39
+ default_factory=lambda: _env_float("DHP_STALL_TIMEOUT_S", 120.0))
40
+ stall_max_repeats: int = field(
41
+ default_factory=lambda: _env_int("DHP_STALL_MAX_REPEATS", 10))
42
+ # safety bounds
43
+ max_checkpoint_bytes: int = field(
44
+ default_factory=lambda: _env_int("DHP_MAX_CHECKPOINT_BYTES",
45
+ 1_000_000)) # 1 MB
46
+ max_envelope_bytes: int = field(
47
+ default_factory=lambda: _env_int("DHP_MAX_ENVELOPE_BYTES",
48
+ 10_000_000)) # 10 MB
49
+ max_result_bytes: int = field(
50
+ default_factory=lambda: _env_int("DHP_MAX_RESULT_BYTES",
51
+ 10_000_000)) # 10 MB
52
+ max_checkpoints_per_handoff: int = field(
53
+ default_factory=lambda: _env_int("DHP_MAX_CHECKPOINTS", 1_000_000))
54
+ # transport
55
+ transport_max_line_bytes: int = field(
56
+ default_factory=lambda: _env_int("DHP_TRANSPORT_MAX_LINE_BYTES",
57
+ 2_000_000)) # 2 MB
58
+ # observability
59
+ log_level: str = field(
60
+ default_factory=lambda: os.environ.get("DHP_LOG_LEVEL", "INFO"))
61
+
62
+ @property
63
+ def lease_ttl_s(self) -> float:
64
+ return self.lease_interval_s * self.lease_max_missed
65
+
66
+ def validate(self) -> None:
67
+ assert self.lease_interval_s > 0, "lease_interval_s must be > 0"
68
+ assert self.lease_max_missed >= 2, "lease_max_missed must be >= 2"
69
+ assert self.leader_ttl_s > self.supervisor_scan_s, \
70
+ "leader_ttl_s must exceed supervisor_scan_s"
71
+ for f in ("max_checkpoint_bytes", "max_envelope_bytes",
72
+ "max_result_bytes", "transport_max_line_bytes",
73
+ "max_checkpoints_per_handoff"):
74
+ assert getattr(self, f) > 0, f"{f} must be > 0"
75
+
76
+
77
+ DEFAULT = DHPConfig()
78
+ DEFAULT.validate()
dhp/conform.py ADDED
@@ -0,0 +1,244 @@
1
+ """DHP conformance suite — the protocol as runnable assertions.
2
+
3
+ Any implementation claiming DHP conformance must pass all of these.
4
+ They encode the guarantees v0.1–v0.4, each tied to a real failure mode
5
+ found in chaos testing.
6
+
7
+ Run: python3 -m dhp.conform
8
+ """
9
+ import shutil
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ import time
14
+
15
+ from . import envelope as E
16
+ from .runner3 import Runner3, RunnerError, BudgetExceeded, FencingError
17
+ from .supervisor3 import Supervisor3
18
+ from .store import OwnershipLost
19
+ from . import transport as T
20
+ from . import errors as DHE
21
+
22
+ SCHEMA = {"type": "object",
23
+ "properties": {"x": {"type": "integer"}},
24
+ "required": ["x"]}
25
+ PASS = []
26
+ FAIL = []
27
+
28
+
29
+ def check(name, fn):
30
+ root = tempfile.mkdtemp(prefix="dhp-conform-")
31
+ try:
32
+ fn(root)
33
+ PASS.append(name)
34
+ print(f" PASS {name}")
35
+ except Exception as e:
36
+ FAIL.append((name, e))
37
+ print(f" FAIL {name}: {type(e).__name__}: {e}")
38
+ finally:
39
+ shutil.rmtree(root, ignore_errors=True)
40
+
41
+
42
+ def env(n=1, **kw):
43
+ return E.build(task_kind="t", inputs={"inline": {"n": n}},
44
+ output_schema=SCHEMA, **kw)
45
+
46
+
47
+ # 1. No lost results: kill mid-run -> recover -> result present & correct.
48
+ def t_no_lost_results(root):
49
+ r = Runner3(root, runner_id="w")
50
+ hid = r.dispatch(env())
51
+ ctx = r.claim(hid)
52
+ ctx.checkpoint({"s": 1})
53
+ # simulate death: expire lease, supervisor orphans
54
+ r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
55
+ (time.time() - 1, hid))
56
+ sup = Supervisor3(root, supervisor_id="s")
57
+ sup.ensure_leadership()
58
+ assert sup.scan()[0]["action"] == "orphaned"
59
+ r2 = Runner3(root, runner_id="w2")
60
+ ctx2 = r2.recover(hid)
61
+ assert r2.last_checkpoint(hid) == {"s": 1}
62
+ ctx2.complete({"x": 7})
63
+ assert r2._load(hid)["execution"]["status"] == "completed"
64
+
65
+
66
+ # 2. No crossed inputs: tampered inputs rejected at dispatch.
67
+ def t_no_crossed_inputs(root):
68
+ r = Runner3(root, runner_id="w")
69
+ e = env()
70
+ e["task"]["inputs"]["inline"]["n"] = 999 # tamper after id computed
71
+ try:
72
+ r.dispatch(e)
73
+ except (RunnerError, DHE.TamperError):
74
+ return
75
+ raise AssertionError("tampered inputs accepted")
76
+
77
+
78
+ # 3. No untyped results: schema violation rejected at completion.
79
+ def t_no_untyped_results(root):
80
+ r = Runner3(root, runner_id="w")
81
+ hid = r.dispatch(env())
82
+ ctx = r.claim(hid)
83
+ try:
84
+ ctx.complete({"wrong": "shape"})
85
+ except Exception:
86
+ return
87
+ raise AssertionError("schema-violating result accepted")
88
+
89
+
90
+ # 4. No silent budget overrun: runtime raises, worker cannot hide it.
91
+ def t_no_silent_overrun(root):
92
+ r = Runner3(root, runner_id="w")
93
+ e = E.build(task_kind="t", inputs={"inline": {"n": 1}},
94
+ output_schema=SCHEMA,
95
+ budget={"max_steps": 2, "max_spend_usd": 1.0,
96
+ "max_wallclock_s": 60.0, "max_attempts": 3})
97
+ hid = r.dispatch(e)
98
+ ctx = r.claim(hid)
99
+ ctx.note_step(2)
100
+ try:
101
+ ctx.note_step(1)
102
+ except BudgetExceeded:
103
+ return
104
+ raise AssertionError("budget overrun not raised")
105
+
106
+
107
+ # 5. No runner lock-in: suspend here, resume there, complete there.
108
+ def t_no_runner_lockin(root):
109
+ r1 = Runner3(root + "/a", runner_id="w1")
110
+ hid = r1.dispatch(env())
111
+ ctx = r1.claim(hid)
112
+ ctx.checkpoint({"s": 1})
113
+ bundle = r1.suspend(hid)
114
+ r2 = Runner3(root + "/b", runner_id="w2")
115
+ hid2 = r2.resume(bundle)
116
+ assert hid2 == hid and r2.last_checkpoint(hid2) == {"s": 1}
117
+ r2.claim(hid2).complete({"x": 1})
118
+ assert r2._load(hid2)["execution"]["status"] == "completed"
119
+
120
+
121
+ # 6. Exactly-one-wins: N simultaneous recoverers, one winner.
122
+ def t_exactly_one_wins(root):
123
+ r = Runner3(root, runner_id="w")
124
+ hid = r.dispatch(env())
125
+ r.claim(hid)
126
+ r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
127
+ (time.time() - 1, hid))
128
+ sup = Supervisor3(root, supervisor_id="s")
129
+ sup.ensure_leadership()
130
+ sup.scan()
131
+ winners = []
132
+
133
+ def race(i):
134
+ try:
135
+ Runner3(root, runner_id=f"r{i}").recover(hid)
136
+ winners.append(i)
137
+ except Exception:
138
+ pass
139
+
140
+ ts = [threading.Thread(target=race, args=(i,)) for i in range(8)]
141
+ [t.start() for t in ts]
142
+ [t.join() for t in ts]
143
+ assert len(winners) == 1, f"{len(winners)} winners"
144
+
145
+
146
+ # 7. Fencing: stale token cannot commit after orphan+recover.
147
+ def t_fencing(root):
148
+ r = Runner3(root, runner_id="w")
149
+ hid = r.dispatch(env())
150
+ ctx = r.claim(hid)
151
+ ctx.checkpoint({"s": 1})
152
+ r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
153
+ (time.time() - 1, hid))
154
+ sup = Supervisor3(root, supervisor_id="s")
155
+ sup.ensure_leadership()
156
+ sup.scan()
157
+ Runner3(root, runner_id="w2").recover(hid) # new owner, fence bumped
158
+ try:
159
+ ctx.checkpoint({"s": 2}) # stale token
160
+ except FencingError:
161
+ return
162
+ raise AssertionError("stale fence accepted")
163
+
164
+
165
+ # 8. Leader election: exactly one leader; death -> promotion.
166
+ def t_leader_election(root):
167
+ Runner3(root, runner_id="x") # init db
168
+ s1 = Supervisor3(root, supervisor_id="s1")
169
+ s2 = Supervisor3(root, supervisor_id="s2")
170
+ s1.ensure_leadership()
171
+ s2.ensure_leadership()
172
+ assert sum(s.is_leader for s in (s1, s2)) == 1
173
+ dead = "s1" if s1.is_leader else "s2"
174
+ cx = s1.store.cx
175
+ cx.execute("UPDATE leadership SET expires_at=? WHERE holder=?",
176
+ (time.time() - 1, dead))
177
+ (s2 if dead == "s1" else s1).ensure_leadership()
178
+ info = s1.store.get_leadership()
179
+ assert info["holder"] != dead and info["expires_at"] > time.time()
180
+
181
+
182
+ # 9. Transport: ingest reconstructs exact state; idempotent.
183
+ def t_transport(root):
184
+ r = Runner3(root + "/a", runner_id="w")
185
+ hid = r.dispatch(env())
186
+ ctx = r.claim(hid)
187
+ log = root + "/t.log"
188
+ T.ship_envelope(log, r._load(hid))
189
+ for i in range(3):
190
+ seq = ctx.checkpoint({"s": i})
191
+ T.ship_checkpoint(log, seq, {"s": i})
192
+ r2 = Runner3(root + "/b", runner_id="w2")
193
+ out = T.ingest_log(r2, log)
194
+ assert out["handoff_id"] == hid, out
195
+ assert r2.last_checkpoint(hid) == {"s": 2}
196
+ T.ingest_log(r2, log) # re-ingest: no dupes
197
+ n = r2.store.cx.execute(
198
+ "SELECT COUNT(*) FROM checkpoints WHERE hid=?", (hid,)).fetchone()[0]
199
+ assert n == 3, n
200
+
201
+
202
+ # 10. DLQ: max_attempts exhausted -> failed, not silently dropped.
203
+ def t_dlq(root):
204
+ r = Runner3(root, runner_id="w")
205
+ e = E.build(task_kind="t", inputs={"inline": {"n": 1}},
206
+ output_schema=SCHEMA,
207
+ budget={"max_steps": 10, "max_spend_usd": 1.0,
208
+ "max_wallclock_s": 60.0, "max_attempts": 1})
209
+ hid = r.dispatch(e)
210
+ r.claim(hid)
211
+ r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
212
+ (time.time() - 1, hid))
213
+ sup = Supervisor3(root, supervisor_id="s")
214
+ sup.ensure_leadership()
215
+ assert sup.scan()[0]["action"] == "orphaned"
216
+ Runner3(root, runner_id="w2").recover(hid) # attempt -> 1
217
+ r.store.cx.execute("UPDATE ownership SET lease_expires_at=? WHERE hid=?",
218
+ (time.time() - 1, hid))
219
+ acts = sup.scan()
220
+ assert acts and acts[0]["action"] == "dlq", acts
221
+ assert r._load(hid)["execution"]["status"] == "failed"
222
+
223
+
224
+ def main():
225
+ print("DHP conformance suite (v0.4):")
226
+ for name, fn in [
227
+ ("no-lost-results", t_no_lost_results),
228
+ ("no-crossed-inputs", t_no_crossed_inputs),
229
+ ("no-untyped-results", t_no_untyped_results),
230
+ ("no-silent-budget-overrun", t_no_silent_overrun),
231
+ ("no-runner-lock-in", t_no_runner_lockin),
232
+ ("exactly-one-wins", t_exactly_one_wins),
233
+ ("fencing", t_fencing),
234
+ ("leader-election", t_leader_election),
235
+ ("transport", t_transport),
236
+ ("dead-letter-queue", t_dlq),
237
+ ]:
238
+ check(name, fn)
239
+ print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
240
+ sys.exit(1 if FAIL else 0)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()
dhp/demo.py ADDED
@@ -0,0 +1,153 @@
1
+ """DHP v0.1 dogfood: fan out, kill a worker mid-run, resume elsewhere.
2
+
3
+ Proves the five guarantees against a real (simulated) worker death:
4
+ 1. no lost results 4. no silent budget overrun
5
+ 2. no crossed inputs 5. no runner lock-in (portable suspend/resume)
6
+ 3. no untyped results
7
+ """
8
+ import os
9
+ import shutil
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from dhp import envelope as E
15
+ from dhp.runner import LocalRunner, RunnerError, BudgetExceeded
16
+
17
+ ROOT = "/tmp/dhp-demo"
18
+ ROOT2 = "/tmp/dhp-demo-2"
19
+ SCHEMA = {"type": "object",
20
+ "properties": {"total": {"type": "integer"},
21
+ "chunks_done": {"type": "integer"}},
22
+ "required": ["total", "chunks_done"]}
23
+ BUDGET = {"max_steps": 1000, "max_spend_usd": 50.0, "max_wallclock_s": 600.0}
24
+
25
+
26
+ def sum_job(lo, hi):
27
+ return E.build(task_kind="sum.range",
28
+ inputs={"inline": {"lo": lo, "hi": hi}},
29
+ output_schema=SCHEMA, budget=BUDGET)
30
+
31
+
32
+ def work_range(ctx, lo, hi, die_after_checkpoints=None):
33
+ """Checkpoint every 25 numbers. Optionally die (simulated kill)."""
34
+ state = ctx.last_checkpoint() or {"next_i": lo, "acc": 0}
35
+ i, acc, done_chunks = state["next_i"], state["acc"], 0
36
+ cps = 0
37
+ while i < hi:
38
+ for _ in range(25):
39
+ if i >= hi:
40
+ break
41
+ acc += i
42
+ i += 1
43
+ ctx.note_step()
44
+ ctx.checkpoint({"next_i": i, "acc": acc})
45
+ done_chunks += 1
46
+ cps += 1
47
+ if die_after_checkpoints and cps >= die_after_checkpoints:
48
+ raise RuntimeError("worker killed mid-run (simulated)")
49
+ return {"total": acc, "chunks_done": done_chunks}
50
+
51
+
52
+ def main():
53
+ for d in (ROOT, ROOT2):
54
+ shutil.rmtree(d, ignore_errors=True)
55
+
56
+ r1 = LocalRunner(ROOT, runner_id="runner-A")
57
+ h1 = r1.dispatch(sum_job(0, 100))
58
+ h2 = r1.dispatch(sum_job(100, 200))
59
+ h3 = r1.dispatch(sum_job(200, 300))
60
+
61
+ # worker A: h1 fully
62
+ ctx = r1.claim(h1)
63
+ r1.complete(h1, work_range(ctx, 0, 100))
64
+ r1.ack(h1)
65
+
66
+ # worker B: h2, DIES after 2 checkpoints (50 of 100 numbers done)
67
+ ctx = r1.claim(h2)
68
+ try:
69
+ work_range(ctx, 100, 200, die_after_checkpoints=2)
70
+ raise AssertionError("worker should have died")
71
+ except RuntimeError as e:
72
+ print(f" [supervisor] {e} — handoff {h2[:12]} orphaned mid-run")
73
+
74
+ # supervisor: fresh runner picks up the same handoff, resumes
75
+ r2 = LocalRunner(ROOT, runner_id="runner-B") # same root, new runner
76
+ ctx2 = r2.recover(h2)
77
+ resumed_from = ctx2.last_checkpoint()
78
+ assert resumed_from["next_i"] == 150, resumed_from
79
+ assert resumed_from["acc"] == sum(range(100, 150)), resumed_from
80
+ r2.complete(h2, work_range(ctx2, 100, 200))
81
+ r2.ack(h2)
82
+ print(f" [supervisor] resumed from checkpoint i={resumed_from['next_i']}, "
83
+ f"zero numbers recomputed before it")
84
+
85
+ # worker C: h3 fully
86
+ ctx = r1.claim(h3)
87
+ r1.complete(h3, work_range(ctx, 200, 300))
88
+ r1.ack(h3)
89
+
90
+ # cross-root portability: suspend a live handoff, resume on another root
91
+ h4 = r1.dispatch(sum_job(300, 400))
92
+ ctx = r1.claim(h4)
93
+ # do one checkpoint manually, then suspend mid-run
94
+ st = {"next_i": 325, "acc": sum(range(300, 325))}
95
+ ctx.checkpoint(st)
96
+ bundle = r1.suspend(h4)
97
+ r3 = LocalRunner(ROOT2, runner_id="runner-C")
98
+ h4b = r3.resume(bundle)
99
+ assert h4b == h4
100
+ # resume() leaves status=running, so hand a context directly to continue
101
+ from dhp.runner import TaskContext
102
+ c4 = TaskContext(r3, h4b)
103
+ assert c4.last_checkpoint() == st, "checkpoint blobs must travel in the bundle"
104
+ r3.complete(h4b, work_range(c4, 300, 400))
105
+ r3.ack(h4b)
106
+ print(" [portability] suspend→bytes→resume on a different root: checkpoints intact")
107
+
108
+ # ---- results ----
109
+ assert r1.result(h1)["total"] == sum(range(0, 100)) == 4950
110
+ assert r2.result(h2)["total"] == sum(range(100, 200)) == 14950
111
+ assert r1.result(h3)["total"] == sum(range(200, 300)) == 24950
112
+ assert r3.result(h4)["total"] == sum(range(300, 400)) == 34950
113
+ for r, h in ((r1, h1), (r2, h2), (r1, h3), (r3, h4)):
114
+ env = r._load(h)
115
+ assert env["delivery"]["outbox"]["ack"] is True
116
+ print(" [results] all four totals correct, all outboxes acked")
117
+
118
+ # ---- negative tests ----
119
+ bad = sum_job(0, 10)
120
+ bad["task"]["inputs"]["inline"]["lo"] = 999 # tamper after build
121
+ try:
122
+ r1.dispatch(bad)
123
+ raise AssertionError("tampered dispatch should be rejected")
124
+ except RunnerError:
125
+ print(" [negative] tampered inputs rejected at dispatch (no crossed wires)")
126
+
127
+ h5 = r1.dispatch(sum_job(0, 10))
128
+ ctx = r1.claim(h5)
129
+ try:
130
+ r1.complete(h5, {"wrong_shape": True})
131
+ raise AssertionError("invalid result should be rejected")
132
+ except E.EnvelopeError:
133
+ print(" [negative] schema-violating result rejected at completion")
134
+
135
+ tiny = E.build(task_kind="sum.range", inputs={"inline": {"lo": 0, "hi": 5}},
136
+ output_schema=SCHEMA,
137
+ budget={"max_steps": 3, "max_spend_usd": 50.0,
138
+ "max_wallclock_s": 600.0})
139
+ h6 = r1.dispatch(tiny)
140
+ ctx = r1.claim(h6)
141
+ try:
142
+ for _ in range(5):
143
+ ctx.note_step()
144
+ raise AssertionError("budget should have blown")
145
+ except BudgetExceeded:
146
+ print(" [negative] budget overrun raised by the runtime, not the prompt")
147
+
148
+ print("\nDHP v0.1 dogfood: ALL GREEN — worker death lost zero work, "
149
+ "every result typed, every handoff acked.")
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()