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.
- dhp_protocol-1.1.0/LICENSE +21 -0
- dhp_protocol-1.1.0/PKG-INFO +105 -0
- dhp_protocol-1.1.0/README.md +86 -0
- dhp_protocol-1.1.0/dhp/__init__.py +120 -0
- dhp_protocol-1.1.0/dhp/a2a_bridge.py +189 -0
- dhp_protocol-1.1.0/dhp/backend.py +88 -0
- dhp_protocol-1.1.0/dhp/bridge_worker.py +71 -0
- dhp_protocol-1.1.0/dhp/chaos.py +205 -0
- dhp_protocol-1.1.0/dhp/config.py +73 -0
- dhp_protocol-1.1.0/dhp/conform.py +244 -0
- dhp_protocol-1.1.0/dhp/demo.py +153 -0
- dhp_protocol-1.1.0/dhp/envelope.py +239 -0
- dhp_protocol-1.1.0/dhp/errors.py +55 -0
- dhp_protocol-1.1.0/dhp/log.py +34 -0
- dhp_protocol-1.1.0/dhp/mcp_server.py +206 -0
- dhp_protocol-1.1.0/dhp/net.py +312 -0
- dhp_protocol-1.1.0/dhp/prime_chunk.py +19 -0
- dhp_protocol-1.1.0/dhp/runner.py +308 -0
- dhp_protocol-1.1.0/dhp/runner3.py +351 -0
- dhp_protocol-1.1.0/dhp/scan.py +29 -0
- dhp_protocol-1.1.0/dhp/setup_chaos.py +48 -0
- dhp_protocol-1.1.0/dhp/setup_wave2.py +45 -0
- dhp_protocol-1.1.0/dhp/setup_wave3.py +45 -0
- dhp_protocol-1.1.0/dhp/store.py +367 -0
- dhp_protocol-1.1.0/dhp/supervisor.py +113 -0
- dhp_protocol-1.1.0/dhp/supervisor3.py +204 -0
- dhp_protocol-1.1.0/dhp/supervisor3_watch.py +34 -0
- dhp_protocol-1.1.0/dhp/supervisor_watch.py +30 -0
- dhp_protocol-1.1.0/dhp/transport.py +164 -0
- dhp_protocol-1.1.0/dhp/validate.py +66 -0
- dhp_protocol-1.1.0/dhp/verify_wave.py +67 -0
- dhp_protocol-1.1.0/dhp/work_driver.py +94 -0
- dhp_protocol-1.1.0/dhp/work_driver3.py +82 -0
- dhp_protocol-1.1.0/dhp/work_driver4.py +89 -0
- dhp_protocol-1.1.0/dhp/worker_cli.py +84 -0
- dhp_protocol-1.1.0/dhp_protocol.egg-info/PKG-INFO +105 -0
- dhp_protocol-1.1.0/dhp_protocol.egg-info/SOURCES.txt +47 -0
- dhp_protocol-1.1.0/dhp_protocol.egg-info/dependency_links.txt +1 -0
- dhp_protocol-1.1.0/dhp_protocol.egg-info/entry_points.txt +5 -0
- dhp_protocol-1.1.0/dhp_protocol.egg-info/requires.txt +10 -0
- dhp_protocol-1.1.0/dhp_protocol.egg-info/top_level.txt +1 -0
- dhp_protocol-1.1.0/pyproject.toml +32 -0
- dhp_protocol-1.1.0/setup.cfg +4 -0
- dhp_protocol-1.1.0/tests/test_adversarial.py +174 -0
- dhp_protocol-1.1.0/tests/test_runner.py +129 -0
- dhp_protocol-1.1.0/tests/test_store.py +103 -0
- dhp_protocol-1.1.0/tests/test_supervisor.py +110 -0
- dhp_protocol-1.1.0/tests/test_transport.py +105 -0
- 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()
|