dhp-protocol 1.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.
Files changed (49) hide show
  1. dhp_protocol-1.1.0/LICENSE +21 -0
  2. dhp_protocol-1.1.0/PKG-INFO +105 -0
  3. dhp_protocol-1.1.0/README.md +86 -0
  4. dhp_protocol-1.1.0/dhp/__init__.py +120 -0
  5. dhp_protocol-1.1.0/dhp/a2a_bridge.py +189 -0
  6. dhp_protocol-1.1.0/dhp/backend.py +88 -0
  7. dhp_protocol-1.1.0/dhp/bridge_worker.py +71 -0
  8. dhp_protocol-1.1.0/dhp/chaos.py +205 -0
  9. dhp_protocol-1.1.0/dhp/config.py +73 -0
  10. dhp_protocol-1.1.0/dhp/conform.py +244 -0
  11. dhp_protocol-1.1.0/dhp/demo.py +153 -0
  12. dhp_protocol-1.1.0/dhp/envelope.py +239 -0
  13. dhp_protocol-1.1.0/dhp/errors.py +55 -0
  14. dhp_protocol-1.1.0/dhp/log.py +34 -0
  15. dhp_protocol-1.1.0/dhp/mcp_server.py +206 -0
  16. dhp_protocol-1.1.0/dhp/net.py +312 -0
  17. dhp_protocol-1.1.0/dhp/prime_chunk.py +19 -0
  18. dhp_protocol-1.1.0/dhp/runner.py +308 -0
  19. dhp_protocol-1.1.0/dhp/runner3.py +351 -0
  20. dhp_protocol-1.1.0/dhp/scan.py +29 -0
  21. dhp_protocol-1.1.0/dhp/setup_chaos.py +48 -0
  22. dhp_protocol-1.1.0/dhp/setup_wave2.py +45 -0
  23. dhp_protocol-1.1.0/dhp/setup_wave3.py +45 -0
  24. dhp_protocol-1.1.0/dhp/store.py +367 -0
  25. dhp_protocol-1.1.0/dhp/supervisor.py +113 -0
  26. dhp_protocol-1.1.0/dhp/supervisor3.py +204 -0
  27. dhp_protocol-1.1.0/dhp/supervisor3_watch.py +34 -0
  28. dhp_protocol-1.1.0/dhp/supervisor_watch.py +30 -0
  29. dhp_protocol-1.1.0/dhp/transport.py +164 -0
  30. dhp_protocol-1.1.0/dhp/validate.py +66 -0
  31. dhp_protocol-1.1.0/dhp/verify_wave.py +67 -0
  32. dhp_protocol-1.1.0/dhp/work_driver.py +94 -0
  33. dhp_protocol-1.1.0/dhp/work_driver3.py +82 -0
  34. dhp_protocol-1.1.0/dhp/work_driver4.py +89 -0
  35. dhp_protocol-1.1.0/dhp/worker_cli.py +84 -0
  36. dhp_protocol-1.1.0/dhp_protocol.egg-info/PKG-INFO +105 -0
  37. dhp_protocol-1.1.0/dhp_protocol.egg-info/SOURCES.txt +47 -0
  38. dhp_protocol-1.1.0/dhp_protocol.egg-info/dependency_links.txt +1 -0
  39. dhp_protocol-1.1.0/dhp_protocol.egg-info/entry_points.txt +5 -0
  40. dhp_protocol-1.1.0/dhp_protocol.egg-info/requires.txt +10 -0
  41. dhp_protocol-1.1.0/dhp_protocol.egg-info/top_level.txt +1 -0
  42. dhp_protocol-1.1.0/pyproject.toml +32 -0
  43. dhp_protocol-1.1.0/setup.cfg +4 -0
  44. dhp_protocol-1.1.0/tests/test_adversarial.py +174 -0
  45. dhp_protocol-1.1.0/tests/test_runner.py +129 -0
  46. dhp_protocol-1.1.0/tests/test_store.py +103 -0
  47. dhp_protocol-1.1.0/tests/test_supervisor.py +110 -0
  48. dhp_protocol-1.1.0/tests/test_transport.py +105 -0
  49. dhp_protocol-1.1.0/tests/test_v11.py +145 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DHP contributors
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,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: dhp-protocol
3
+ Version: 1.1.0
4
+ Summary: Durable Handoff Protocol — agent work that survives worker death
5
+ Author: DHP contributors
6
+ License: MIT
7
+ Keywords: agents,durable-execution,failover,mcp,a2a
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: a2a
12
+ Requires-Dist: a2a-sdk>=1.0; extra == "a2a"
13
+ Provides-Extra: mcp
14
+ Requires-Dist: mcp>=1.0; extra == "mcp"
15
+ Provides-Extra: dev
16
+ Requires-Dist: a2a-sdk>=1.0; extra == "dev"
17
+ Requires-Dist: mcp>=1.0; extra == "dev"
18
+ Dynamic: license-file
19
+
20
+ # DHP — Durable Handoff Protocol
21
+
22
+ **Your AI agent dies mid-run. Another agent picks up exactly where it left off. Zero rework.**
23
+
24
+ `kill -9`, OOM, spot instance reclaimed, laptop lid closed — the work survives. DHP is the durability layer for agent systems:
25
+
26
+ - **MCP** = agent ↔ tool
27
+ - **A2A** = agent ↔ agent
28
+ - **DHP** = agent ↔ time (work outlives the worker)
29
+
30
+ ## The 30-second demo
31
+
32
+ ```bash
33
+ pip install dhp
34
+ ```
35
+
36
+ ```python
37
+ import dhp
38
+ store = dhp.Store("./demo")
39
+
40
+ hid = dhp.dispatch(store, task_kind="demo.count",
41
+ inputs={"target": 50},
42
+ output_schema={"type": "object"})
43
+
44
+ # worker (any process, any host):
45
+ with dhp.claim(store, hid, worker_id="w1") as ctx: # auto-heartbeats
46
+ state = dhp.last_checkpoint(store, hid) or {"n": 0}
47
+ while state["n"] < 50:
48
+ state["n"] += 1
49
+ ctx.checkpoint(state) # every step is a resume point
50
+ ctx.complete({"counted": state["n"]})
51
+ ```
52
+
53
+ Now `kill -9` that worker mid-run. Start a supervisor (`dhp-supervisor ./demo sup1`):
54
+ it orphans the dead handoff in ~7s. Any standby recovers from the last
55
+ checkpoint — steps 1–20 are never recomputed.
56
+
57
+ **Full kill demo** (`demo/run_mad_demo.py`): 60 real web pages fetched,
58
+ `kill -9` at page 20, checkpoints shipped over TCP to a peer host as
59
+ they're made, standby resumes at page 20 → 60/60 complete, zero
60
+ refetched.
61
+
62
+ ## The five guarantees
63
+
64
+ 1. **No lost results** — every completed handoff's result is durable.
65
+ 2. **No crossed inputs** — content-addressed envelopes; inputs can't be swapped.
66
+ 3. **No untyped results** — JSON Schema validated on completion.
67
+ 4. **No silent budget overrun** — hard caps on steps, spend, wallclock, attempts; DLQ, never silent drop.
68
+ 5. **No runner lock-in** — suspend → ship → resume on any host, any runner.
69
+
70
+ ## How it works
71
+
72
+ - **Leases + fencing.** Workers hold time-bounded leases and heartbeat (automatically, in a background thread). A silent worker is declared dead; the supervisor orphans its handoff and bumps a monotonic fence token — the dead worker's late writes are rejected, so two workers can never both believe they own the work.
73
+ - **Content-addressed envelopes.** The handoff ID covers the *intent* (task, inputs, schema), not the state — so a resumed handoff is verifiably the same work.
74
+ - **Append-only transport.** Checkpoints ship as a JSON-lines log — over TCP (`dhp.net`) or files — verified hash-by-hash on ingest. A destroyed host's work is already on the peer.
75
+ - **Pluggable storage.** `StoreBackend` interface; SQLite/WAL is the default, Postgres is a clean extension.
76
+
77
+ ## Measured, not claimed
78
+
79
+ | Event | Time |
80
+ |---|---|
81
+ | kill → orphan → standby resumes | ~7s |
82
+ | Double kill (worker + supervisor leader) → recovery | 6.6s |
83
+ | Host destroyed → peer ingests log → resumes | ~6s |
84
+ | Rework after any kill | 0 checkpoints |
85
+
86
+ Verified by `dhp-conform` (10 protocol assertions), `dhp-chaos`
87
+ (randomized SIGKILL fault injection), and a 20-agent MCP soak with 75%
88
+ worker death rate → 20/20 completed.
89
+
90
+ ## For agents
91
+
92
+ - **MCP server**: `dhp-mcp --root <dir>` — eight tools (`dhp_dispatch`, `dhp_claim`, `dhp_checkpoint`, `dhp_heartbeat`, `dhp_complete`, `dhp_recover`, `dhp_status`, `dhp_last_checkpoint`).
93
+ - **Skill**: `~/workspace/skills/dhp-durable-work/SKILL.md` teaches agents the pattern.
94
+ - **A2A bridge**: `dhp.a2a_bridge` — the A2A Task ID stays stable while DHP swaps dead workers underneath (see `BRIDGE_DEMO.md`).
95
+
96
+ ## Docs
97
+
98
+ - `QUICKSTART.md` — zero to kill-demo in 5 minutes
99
+ - `SPEC.md` — the protocol
100
+ - `PRODUCT.md` — positioning and architecture
101
+ - `demo/` — the mad demo + network chaos harness
102
+
103
+ ## License
104
+
105
+ MIT — see LICENSE.
@@ -0,0 +1,86 @@
1
+ # DHP — Durable Handoff Protocol
2
+
3
+ **Your AI agent dies mid-run. Another agent picks up exactly where it left off. Zero rework.**
4
+
5
+ `kill -9`, OOM, spot instance reclaimed, laptop lid closed — the work survives. DHP is the durability layer for agent systems:
6
+
7
+ - **MCP** = agent ↔ tool
8
+ - **A2A** = agent ↔ agent
9
+ - **DHP** = agent ↔ time (work outlives the worker)
10
+
11
+ ## The 30-second demo
12
+
13
+ ```bash
14
+ pip install dhp
15
+ ```
16
+
17
+ ```python
18
+ import dhp
19
+ store = dhp.Store("./demo")
20
+
21
+ hid = dhp.dispatch(store, task_kind="demo.count",
22
+ inputs={"target": 50},
23
+ output_schema={"type": "object"})
24
+
25
+ # worker (any process, any host):
26
+ with dhp.claim(store, hid, worker_id="w1") as ctx: # auto-heartbeats
27
+ state = dhp.last_checkpoint(store, hid) or {"n": 0}
28
+ while state["n"] < 50:
29
+ state["n"] += 1
30
+ ctx.checkpoint(state) # every step is a resume point
31
+ ctx.complete({"counted": state["n"]})
32
+ ```
33
+
34
+ Now `kill -9` that worker mid-run. Start a supervisor (`dhp-supervisor ./demo sup1`):
35
+ it orphans the dead handoff in ~7s. Any standby recovers from the last
36
+ checkpoint — steps 1–20 are never recomputed.
37
+
38
+ **Full kill demo** (`demo/run_mad_demo.py`): 60 real web pages fetched,
39
+ `kill -9` at page 20, checkpoints shipped over TCP to a peer host as
40
+ they're made, standby resumes at page 20 → 60/60 complete, zero
41
+ refetched.
42
+
43
+ ## The five guarantees
44
+
45
+ 1. **No lost results** — every completed handoff's result is durable.
46
+ 2. **No crossed inputs** — content-addressed envelopes; inputs can't be swapped.
47
+ 3. **No untyped results** — JSON Schema validated on completion.
48
+ 4. **No silent budget overrun** — hard caps on steps, spend, wallclock, attempts; DLQ, never silent drop.
49
+ 5. **No runner lock-in** — suspend → ship → resume on any host, any runner.
50
+
51
+ ## How it works
52
+
53
+ - **Leases + fencing.** Workers hold time-bounded leases and heartbeat (automatically, in a background thread). A silent worker is declared dead; the supervisor orphans its handoff and bumps a monotonic fence token — the dead worker's late writes are rejected, so two workers can never both believe they own the work.
54
+ - **Content-addressed envelopes.** The handoff ID covers the *intent* (task, inputs, schema), not the state — so a resumed handoff is verifiably the same work.
55
+ - **Append-only transport.** Checkpoints ship as a JSON-lines log — over TCP (`dhp.net`) or files — verified hash-by-hash on ingest. A destroyed host's work is already on the peer.
56
+ - **Pluggable storage.** `StoreBackend` interface; SQLite/WAL is the default, Postgres is a clean extension.
57
+
58
+ ## Measured, not claimed
59
+
60
+ | Event | Time |
61
+ |---|---|
62
+ | kill → orphan → standby resumes | ~7s |
63
+ | Double kill (worker + supervisor leader) → recovery | 6.6s |
64
+ | Host destroyed → peer ingests log → resumes | ~6s |
65
+ | Rework after any kill | 0 checkpoints |
66
+
67
+ Verified by `dhp-conform` (10 protocol assertions), `dhp-chaos`
68
+ (randomized SIGKILL fault injection), and a 20-agent MCP soak with 75%
69
+ worker death rate → 20/20 completed.
70
+
71
+ ## For agents
72
+
73
+ - **MCP server**: `dhp-mcp --root <dir>` — eight tools (`dhp_dispatch`, `dhp_claim`, `dhp_checkpoint`, `dhp_heartbeat`, `dhp_complete`, `dhp_recover`, `dhp_status`, `dhp_last_checkpoint`).
74
+ - **Skill**: `~/workspace/skills/dhp-durable-work/SKILL.md` teaches agents the pattern.
75
+ - **A2A bridge**: `dhp.a2a_bridge` — the A2A Task ID stays stable while DHP swaps dead workers underneath (see `BRIDGE_DEMO.md`).
76
+
77
+ ## Docs
78
+
79
+ - `QUICKSTART.md` — zero to kill-demo in 5 minutes
80
+ - `SPEC.md` — the protocol
81
+ - `PRODUCT.md` — positioning and architecture
82
+ - `demo/` — the mad demo + network chaos harness
83
+
84
+ ## License
85
+
86
+ MIT — see LICENSE.
@@ -0,0 +1,120 @@
1
+ """DHP — Durable Handoff Protocol.
2
+
3
+ Agent work that survives worker death. Dispatch a handoff, claim it
4
+ from any worker, checkpoint as you go; if the worker dies, a
5
+ supervisor orphans the handoff and a standby recovers from the last
6
+ checkpoint. No lost results, no crossed inputs, no runner lock-in.
7
+
8
+ Quick start::
9
+
10
+ import dhp
11
+
12
+ # one-time: create a store + supervisor for your app
13
+ store = dhp.Store("/var/lib/myapp/dhp")
14
+
15
+ # dispatch durable work
16
+ hid = dhp.dispatch(
17
+ store,
18
+ task_kind="prime.census",
19
+ inputs={"lo": 0, "hi": 16000, "chunk": 100},
20
+ output_schema={...},
21
+ )
22
+
23
+ # in the worker (any process, any host with the transport log):
24
+ ctx = dhp.claim(store, hid, worker_id="w1")
25
+ state = dhp.last_checkpoint(store, hid) or {"next_n": 0}
26
+ for ...:
27
+ ... do a chunk of work ...
28
+ ctx.checkpoint(state); ctx.heartbeat()
29
+ ctx.complete(result)
30
+
31
+ # if the worker dies: supervisor orphans, standby recovers
32
+ ctx2 = dhp.recover(store, hid, worker_id="w2") # resumes at last cp
33
+ """
34
+ from .envelope import build as _build_envelope
35
+ from .store import Store
36
+ from .backend import StoreBackend
37
+ from .runner3 import Runner3, TaskContext
38
+ from .supervisor3 import Supervisor3
39
+ from .config import DHPConfig, DEFAULT
40
+ from .transport import (
41
+ ship_envelope as transport_ship_envelope,
42
+ ship_checkpoint as transport_ship_checkpoint,
43
+ ingest_log as transport_ingest,
44
+ )
45
+ from .net import TransportServer, NetShipper
46
+ from . import validate as _validate
47
+
48
+ __version__ = "1.1.0"
49
+ __all__ = [
50
+ "Store", "StoreBackend", "Runner3", "TaskContext", "Supervisor3",
51
+ "TransportServer", "NetShipper",
52
+ "dispatch", "claim", "recover", "last_checkpoint", "status",
53
+ "transport_ship_envelope", "transport_ship_checkpoint",
54
+ "transport_ingest",
55
+ "__version__",
56
+ ]
57
+
58
+ _DEFAULT_BUDGET = {
59
+ "max_steps": 10000,
60
+ "max_spend_usd": 500.0,
61
+ "max_wallclock_s": 5400.0,
62
+ "max_attempts": 5,
63
+ }
64
+
65
+
66
+ def _runner(store: StoreBackend, worker_id: str = "api",
67
+ cfg: DHPConfig = DEFAULT) -> Runner3:
68
+ return Runner3(store.root, runner_id=worker_id, store=store, cfg=cfg)
69
+
70
+
71
+ def dispatch(store: StoreBackend, task_kind: str, inputs: dict,
72
+ output_schema: dict, budget: dict | None = None,
73
+ created_by: str = "dhp-api",
74
+ cfg: DHPConfig = DEFAULT) -> str:
75
+ """Create a durable handoff. Returns the handoff id."""
76
+ _validate.check_inputs(inputs, cfg)
77
+ env = _build_envelope(
78
+ task_kind=task_kind,
79
+ inputs={"inline": inputs},
80
+ output_schema=output_schema,
81
+ budget={**_DEFAULT_BUDGET, **(budget or {})},
82
+ created_by=created_by)
83
+ return _runner(store, cfg=cfg).dispatch(env)
84
+
85
+
86
+ def claim(store: StoreBackend, hid: str, worker_id: str,
87
+ cfg: DHPConfig = DEFAULT) -> TaskContext:
88
+ """Claim a pending handoff for a worker. Returns a TaskContext
89
+ with .checkpoint(state), .heartbeat(), .complete(result)."""
90
+ return _runner(store, worker_id, cfg).claim(_validate.check_hid(hid))
91
+
92
+
93
+ def recover(store: StoreBackend, hid: str, worker_id: str,
94
+ cfg: DHPConfig = DEFAULT) -> TaskContext:
95
+ """Recover an orphaned handoff. Resumes from the last checkpoint."""
96
+ return _runner(store, worker_id, cfg).recover(_validate.check_hid(hid))
97
+
98
+
99
+ def last_checkpoint(store: Store, hid: str):
100
+ """Return the last checkpointed state, or None."""
101
+ return _runner(store).last_checkpoint(_validate.check_hid(hid))
102
+
103
+
104
+ def status(store: StoreBackend, hid: str) -> dict:
105
+ """Return a summary: status, checkpoints, owner, result if done."""
106
+ hid = _validate.check_hid(hid)
107
+ r = _runner(store)
108
+ env = r._load(hid)
109
+ ex = env["execution"]
110
+ own = store.get_ownership(hid)
111
+ out = {
112
+ "handoff_id": hid,
113
+ "status": ex["status"],
114
+ "checkpoints": len(ex["checkpoints"]),
115
+ "attempt": ex.get("attempt", 0),
116
+ "owner": own["holder"] if own else None,
117
+ }
118
+ if ex["status"] == "completed":
119
+ out["result"] = env["delivery"]["result"]
120
+ return out
@@ -0,0 +1,189 @@
1
+ """DHP ↔ A2A bridge: durable execution under the A2A task lifecycle.
2
+
3
+ The A2A gap (from protocol_gaps.md): "A planner may mark a subtask
4
+ failed because its deadline expired while the remote agent later posts
5
+ a completed artifact… A2A exposes state. It does not provide consensus
6
+ or global transaction semantics." When the remote worker dies mid-task,
7
+ stock A2A leaves the task hanging in WORKING — the work is lost.
8
+
9
+ The bridge: the A2A Task ID is the stable identity; DHP handoffs are
10
+ the execution attempts underneath. Worker death becomes a transient
11
+ DHP-internal event (orphan → recover) invisible at the A2A layer.
12
+
13
+ Lifecycle mapping:
14
+ DHP pending/running/orphaned → A2A WORKING (orphaned is transient:
15
+ a standby is coming; stock A2A would hang here forever)
16
+ DHP completed → A2A COMPLETED (+ Artifact from result)
17
+ DHP failed (DLQ) → A2A FAILED
18
+
19
+ Uses the real a2a-sdk types (Task/TaskStatus/Artifact). Transport is
20
+ in-process for the prototype; the A2A JSON-RPC/HTTP layer is orthogonal
21
+ to the durability question.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import time
27
+ import uuid
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ from a2a.types import (
31
+ Artifact,
32
+ Message,
33
+ Part,
34
+ Role,
35
+ Task,
36
+ TaskState,
37
+ TaskStatus,
38
+ )
39
+
40
+ from . import envelope as E
41
+ from .runner3 import Runner3
42
+ from .supervisor3 import Supervisor3
43
+
44
+
45
+ def _text_part(text: str) -> Part:
46
+ return Part(text=text)
47
+
48
+
49
+ def _now_ts():
50
+ from google.protobuf.timestamp_pb2 import Timestamp
51
+ ts = Timestamp()
52
+ ts.GetCurrentTime()
53
+ return ts
54
+
55
+
56
+ class DHPBridge:
57
+ """Binds A2A tasks to DHP handoffs. One bridge per host/store."""
58
+
59
+ def __init__(self, root: str, bridge_id: Optional[str] = None):
60
+ self.runner = Runner3(root, runner_id=bridge_id or "bridge")
61
+ self.supervisor = Supervisor3(
62
+ root, supervisor_id=(bridge_id or "bridge") + "-sup")
63
+ self.tasks: Dict[str, Task] = {} # a2a_task_id -> Task
64
+ self.task_handoff: Dict[str, str] = {} # a2a_task_id -> dhp hid
65
+ self.handoff_task: Dict[str, str] = {} # dhp hid -> a2a_task_id
66
+ self._rebuild_mapping()
67
+
68
+ def _rebuild_mapping(self) -> None:
69
+ """Recover the A2A<->DHP binding from durable envelope provenance.
70
+ A fresh bridge instance (new process, after a crash) rebuilds
71
+ everything from the store — no in-memory state is load-bearing."""
72
+ for hid in self.runner.store.all_hids():
73
+ try:
74
+ env = self.runner._load(hid)
75
+ except Exception:
76
+ continue
77
+ tid = env.get("provenance", {}).get("a2a_task_id")
78
+ if tid:
79
+ self.task_handoff[tid] = hid
80
+ self.handoff_task[hid] = tid
81
+ if tid not in self.tasks:
82
+ # rehydrate a Task shell; _refresh_task fills status
83
+ task = Task(id=tid,
84
+ context_id=env["provenance"].get("trace_id", ""),
85
+ status=TaskStatus(
86
+ state=TaskState.TASK_STATE_SUBMITTED,
87
+ timestamp=_now_ts()))
88
+ self.tasks[tid] = task
89
+
90
+ # ---- A2A-facing ----
91
+ def submit_task(self, message_text: str,
92
+ task_kind: str = "prime.census",
93
+ inputs: Optional[Dict[str, Any]] = None,
94
+ output_schema: Optional[Dict[str, Any]] = None,
95
+ context_id: Optional[str] = None) -> Task:
96
+ """Client submits work. Returns the A2A Task (SUBMITTED)."""
97
+ task_id = f"task-{uuid.uuid4().hex[:12]}"
98
+ task = Task(
99
+ id=task_id,
100
+ context_id=context_id or f"ctx-{uuid.uuid4().hex[:8]}",
101
+ status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED,
102
+ timestamp=_now_ts()),
103
+ )
104
+ # Wrap in a DHP handoff: the durable execution underneath.
105
+ schema = output_schema or {
106
+ "type": "object",
107
+ "properties": {
108
+ "lo": {"type": "integer"}, "hi": {"type": "integer"},
109
+ "prime_count": {"type": "integer"},
110
+ "prime_sum": {"type": "integer"},
111
+ "chunks_done": {"type": "integer"}},
112
+ "required": ["lo", "hi", "prime_count", "prime_sum",
113
+ "chunks_done"]}
114
+ env = E.build(
115
+ task_kind=task_kind,
116
+ inputs={"inline": inputs or {}},
117
+ output_schema=schema,
118
+ budget={"max_steps": 2000, "max_spend_usd": 500.0,
119
+ "max_wallclock_s": 5400.0, "max_attempts": 5},
120
+ created_by=f"a2a-bridge:{task_id}")
121
+ # record the A2A binding in the envelope metadata
122
+ hid = self.runner.dispatch(env)
123
+ self.tasks[task_id] = task
124
+ self.task_handoff[task_id] = hid
125
+ self.handoff_task[hid] = task_id
126
+ # store the binding durably in the envelope
127
+ e2 = self.runner._load(hid)
128
+ e2["provenance"]["a2a_task_id"] = task_id
129
+ self.runner._save(e2)
130
+ self._refresh_task(task_id)
131
+ return task
132
+
133
+ def get_task(self, task_id: str) -> Task:
134
+ """Client polls. Always reflects current DHP state."""
135
+ self._refresh_task(task_id)
136
+ return self.tasks[task_id]
137
+
138
+ def _refresh_task(self, task_id: str) -> None:
139
+ task = self.tasks[task_id]
140
+ hid = self.task_handoff[task_id]
141
+ env = self.runner._load(hid)
142
+ st = env["execution"]["status"]
143
+ if st == "completed":
144
+ result = env["delivery"]["result"]
145
+ artifact = Artifact(
146
+ artifact_id=f"artifact-{hid[:12]}",
147
+ name="result",
148
+ description="DHP handoff result",
149
+ parts=[_text_part(json.dumps(result))],
150
+ )
151
+ task.status.state = TaskState.TASK_STATE_COMPLETED
152
+ task.status.timestamp.CopyFrom(_now_ts())
153
+ # avoid duplicate artifacts on repeated refresh
154
+ if not any(a.artifact_id == artifact.artifact_id
155
+ for a in task.artifacts):
156
+ task.artifacts.append(artifact)
157
+ elif st == "failed":
158
+ task.status.state = TaskState.TASK_STATE_FAILED
159
+ task.status.timestamp.CopyFrom(_now_ts())
160
+ task.status.message.CopyFrom(Message(
161
+ message_id=f"msg-{uuid.uuid4().hex[:8]}",
162
+ role=Role.ROLE_AGENT,
163
+ parts=[_text_part(
164
+ env["execution"].get("error", "handoff failed"))]))
165
+ else:
166
+ # pending / running / orphaned / suspended -> WORKING.
167
+ # Orphaned is the key: stock A2A would hang here; DHP is
168
+ # already reassigning underneath.
169
+ task.status.state = TaskState.TASK_STATE_WORKING
170
+ task.status.timestamp.CopyFrom(_now_ts())
171
+
172
+ # ---- worker-facing (what agents B, C, ... call) ----
173
+ def claim_for_task(self, task_id: str, worker_id: str):
174
+ hid = self.task_handoff[task_id]
175
+ r = Runner3(self.runner.store.root, runner_id=worker_id,
176
+ store=self.runner.store)
177
+ return r.claim(hid), hid
178
+
179
+ def recover_for_task(self, task_id: str, worker_id: str):
180
+ hid = self.task_handoff[task_id]
181
+ r = Runner3(self.runner.store.root, runner_id=worker_id,
182
+ store=self.runner.store)
183
+ return r.recover(hid), hid
184
+
185
+ def supervisor_scan(self):
186
+ return self.supervisor.scan()
187
+
188
+ def task_state(self, task_id: str) -> str:
189
+ return TaskState.Name(self.get_task(task_id).status.state)
@@ -0,0 +1,88 @@
1
+ """DHP storage backend interface.
2
+
3
+ The durability substrate is pluggable. ``SQLiteStore`` (dhp.store.Store)
4
+ is the default: zero-dependency, crash-safe, single-writer. For
5
+ multi-writer / multi-host deployments, implement ``StoreBackend``
6
+ against Postgres (advisory locks + FOR UPDATE SKIP LOCKED give you the
7
+ same CAS semantics) or any store that can provide:
8
+
9
+ - atomic compare-and-set on (holder, fence)
10
+ - monotonic fence increments
11
+ - ordered, deduplicated checkpoint log
12
+
13
+ The protocol logic (runner, supervisor, transport) only talks to this
14
+ interface — never to SQL directly.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import abc
19
+ from typing import Any, Dict, List, Optional
20
+
21
+
22
+ class StoreBackend(abc.ABC):
23
+ """Durability substrate for DHP."""
24
+
25
+ # -- handoffs --
26
+ @abc.abstractmethod
27
+ def create_handoff(self, hid: str, envelope: Dict[str, Any]) -> None: ...
28
+
29
+ @abc.abstractmethod
30
+ def get_envelope(self, hid: str) -> Dict[str, Any]: ...
31
+
32
+ @abc.abstractmethod
33
+ def save_envelope(self, hid: str, envelope: Dict[str, Any]) -> None: ...
34
+
35
+ @abc.abstractmethod
36
+ def all_hids(self) -> List[str]: ...
37
+
38
+ # -- checkpoints --
39
+ @abc.abstractmethod
40
+ def add_checkpoint(self, hid: str, state: Dict[str, Any]) -> int: ...
41
+
42
+ @abc.abstractmethod
43
+ def last_checkpoint_state(self, hid: str) -> Optional[Dict[str, Any]]: ...
44
+
45
+ @abc.abstractmethod
46
+ def checkpoint_count(self, hid: str) -> int: ...
47
+
48
+ @abc.abstractmethod
49
+ def get_checkpoints(self, hid: str) -> List[Dict[str, Any]]: ...
50
+
51
+ @abc.abstractmethod
52
+ def import_checkpoints(self, hid: str,
53
+ items: List[Dict[str, Any]]) -> int: ...
54
+
55
+ # -- ownership / leases / fencing --
56
+ @abc.abstractmethod
57
+ def acquire_ownership(self, hid: str, holder: str,
58
+ lease_s: float) -> int: ...
59
+
60
+ @abc.abstractmethod
61
+ def get_ownership(self, hid: str) -> Optional[Dict[str, Any]]: ...
62
+
63
+ @abc.abstractmethod
64
+ def refresh_lease(self, hid: str, holder: str, fence: int) -> float: ...
65
+
66
+ @abc.abstractmethod
67
+ def release_ownership(self, hid: str, holder: str,
68
+ fence: int) -> None: ...
69
+
70
+ @abc.abstractmethod
71
+ def force_orphan(self, hid: str, orphan_holder: str) -> bool: ...
72
+
73
+ @abc.abstractmethod
74
+ def expired_leases(self,
75
+ now: Optional[float] = None) -> List[str]: ...
76
+
77
+ # -- supervisor leadership --
78
+ @abc.abstractmethod
79
+ def acquire_leadership(self, holder: str, ttl_s: float) -> bool: ...
80
+
81
+ @abc.abstractmethod
82
+ def renew_leadership(self, holder: str, ttl_s: float) -> bool: ...
83
+
84
+ @abc.abstractmethod
85
+ def get_leadership(self) -> Optional[Dict[str, Any]]: ...
86
+
87
+ @abc.abstractmethod
88
+ def close(self) -> None: ...
@@ -0,0 +1,71 @@
1
+ """bridge_worker.py — worker driver for the A2A+DHP demo.
2
+
3
+ Works an A2A task through the DHP bridge: claims/recovers the task's
4
+ DHP handoff, runs the prime-census chunk loop with per-chunk
5
+ heartbeats, completes. The A2A task ID is stable; DHP handles worker
6
+ swaps underneath.
7
+
8
+ python3 bridge_worker.py --root R --task <a2a-task-id> --worker <id>
9
+ [--chunk N] [--pace S] (claim|recover)
10
+ """
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+
19
+ from dhp.a2a_bridge import DHPBridge # noqa: E402
20
+ from dhp.prime_chunk import chunk # noqa: E402
21
+
22
+
23
+ def main():
24
+ ap = argparse.ArgumentParser()
25
+ ap.add_argument("--root", required=True)
26
+ ap.add_argument("--task", required=True)
27
+ ap.add_argument("--worker", required=True)
28
+ ap.add_argument("--chunk", type=int, default=100)
29
+ ap.add_argument("--pace", type=float, default=0.4)
30
+ ap.add_argument("mode", choices=["claim", "recover"])
31
+ a = ap.parse_args()
32
+
33
+ b = DHPBridge(a.root, bridge_id="demo-bridge")
34
+ if a.mode == "claim":
35
+ ctx, hid = b.claim_for_task(a.task, a.worker)
36
+ else:
37
+ ctx, hid = b.recover_for_task(a.task, a.worker)
38
+ env = b.runner._load(hid)
39
+ lo = env["task"]["inputs"]["inline"]["lo"]
40
+ hi = env["task"]["inputs"]["inline"]["hi"]
41
+
42
+ st = b.runner.last_checkpoint(hid)
43
+ if st is None:
44
+ nxt, count, total, done = lo, 0, 0, 0
45
+ else:
46
+ nxt, count, total = st["next_n"], st["count"], st["total"]
47
+ done = 0
48
+ t0 = time.time()
49
+ while nxt < hi:
50
+ chi = min(nxt + a.chunk, hi)
51
+ c = chunk(nxt, chi)
52
+ count += c["count"]
53
+ total += c["sum"]
54
+ nxt = chi
55
+ done += 1
56
+ ctx.checkpoint({"next_n": nxt, "count": count, "total": total})
57
+ ctx.note_step()
58
+ ctx.heartbeat()
59
+ if a.pace:
60
+ time.sleep(a.pace)
61
+ ctx.complete({"lo": lo, "hi": hi, "prime_count": count,
62
+ "prime_sum": total, "chunks_done": done})
63
+ print(json.dumps({"completed": True, "task_id": a.task,
64
+ "worker": a.worker, "prime_count": count,
65
+ "prime_sum": total, "chunks_done": done,
66
+ "elapsed_s": round(time.time() - t0, 1),
67
+ "a2a_state": b.task_state(a.task)}))
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()