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/__init__.py +143 -0
- dhp/a2a_bridge.py +189 -0
- dhp/backend.py +88 -0
- dhp/bridge_worker.py +71 -0
- dhp/chaos.py +205 -0
- dhp/config.py +78 -0
- dhp/conform.py +244 -0
- dhp/demo.py +153 -0
- dhp/envelope.py +245 -0
- dhp/errors.py +74 -0
- dhp/log.py +34 -0
- dhp/mcp_server.py +325 -0
- dhp/net.py +312 -0
- dhp/prime_chunk.py +19 -0
- dhp/runner.py +308 -0
- dhp/runner3.py +446 -0
- dhp/scan.py +29 -0
- dhp/setup_chaos.py +48 -0
- dhp/setup_wave2.py +45 -0
- dhp/setup_wave3.py +45 -0
- dhp/store.py +656 -0
- dhp/supervisor.py +113 -0
- dhp/supervisor3.py +264 -0
- dhp/supervisor3_watch.py +34 -0
- dhp/supervisor_watch.py +30 -0
- dhp/transport.py +164 -0
- dhp/transport_server.py +49 -0
- dhp/validate.py +66 -0
- dhp/verify_wave.py +67 -0
- dhp/work_driver.py +94 -0
- dhp/work_driver3.py +82 -0
- dhp/work_driver4.py +89 -0
- dhp/worker_cli.py +84 -0
- dhp_protocol-1.3.0.dist-info/METADATA +449 -0
- dhp_protocol-1.3.0.dist-info/RECORD +39 -0
- dhp_protocol-1.3.0.dist-info/WHEEL +5 -0
- dhp_protocol-1.3.0.dist-info/entry_points.txt +6 -0
- dhp_protocol-1.3.0.dist-info/licenses/LICENSE +21 -0
- dhp_protocol-1.3.0.dist-info/top_level.txt +1 -0
dhp/__init__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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.3.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
|
+
_DEFAULT_MAX_STALL_RETRIES = 2
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _runner(store: StoreBackend, worker_id: str = "api",
|
|
69
|
+
cfg: DHPConfig = DEFAULT) -> Runner3:
|
|
70
|
+
return Runner3(store.root, runner_id=worker_id, store=store, cfg=cfg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def dispatch(store: StoreBackend, task_kind: str, inputs: dict,
|
|
74
|
+
output_schema: dict, budget: dict | None = None,
|
|
75
|
+
capabilities: dict | None = None,
|
|
76
|
+
max_stall_retries: int = _DEFAULT_MAX_STALL_RETRIES,
|
|
77
|
+
created_by: str = "dhp-api",
|
|
78
|
+
cfg: DHPConfig = DEFAULT) -> str:
|
|
79
|
+
"""Create a durable handoff. Returns the handoff id."""
|
|
80
|
+
_validate.check_inputs(inputs, cfg)
|
|
81
|
+
env = _build_envelope(
|
|
82
|
+
task_kind=task_kind,
|
|
83
|
+
inputs={"inline": inputs},
|
|
84
|
+
output_schema=output_schema,
|
|
85
|
+
budget={**_DEFAULT_BUDGET, **(budget or {})},
|
|
86
|
+
max_stall_retries=max_stall_retries,
|
|
87
|
+
capabilities=capabilities,
|
|
88
|
+
created_by=created_by)
|
|
89
|
+
return _runner(store, cfg=cfg).dispatch(env)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def claim(store: StoreBackend, hid: str, worker_id: str,
|
|
93
|
+
cfg: DHPConfig = DEFAULT) -> TaskContext:
|
|
94
|
+
"""Claim a pending handoff for a worker. Returns a TaskContext
|
|
95
|
+
with .checkpoint(state), .heartbeat(), .complete(result)."""
|
|
96
|
+
return _runner(store, worker_id, cfg).claim(_validate.check_hid(hid))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def recover(store: StoreBackend, hid: str, worker_id: str,
|
|
100
|
+
cfg: DHPConfig = DEFAULT) -> TaskContext:
|
|
101
|
+
"""Recover an orphaned handoff. Resumes from the last checkpoint."""
|
|
102
|
+
return _runner(store, worker_id, cfg).recover(_validate.check_hid(hid))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def last_checkpoint(store: Store, hid: str):
|
|
106
|
+
"""Return the last checkpointed state, or None."""
|
|
107
|
+
return _runner(store).last_checkpoint(_validate.check_hid(hid))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def status(store: StoreBackend, hid: str) -> dict:
|
|
111
|
+
"""Return a summary: status, checkpoints, owner, result if done."""
|
|
112
|
+
hid = _validate.check_hid(hid)
|
|
113
|
+
r = _runner(store)
|
|
114
|
+
env = r._load(hid)
|
|
115
|
+
ex = env["execution"]
|
|
116
|
+
own = store.get_ownership(hid)
|
|
117
|
+
out = {
|
|
118
|
+
"handoff_id": hid,
|
|
119
|
+
"status": ex["status"],
|
|
120
|
+
"checkpoints": len(ex["checkpoints"]),
|
|
121
|
+
"attempt": ex.get("attempt", 0),
|
|
122
|
+
"owner": own["holder"] if own else None,
|
|
123
|
+
"effects": store.effect_count(hid),
|
|
124
|
+
"approvals": store.approvals_for(hid),
|
|
125
|
+
"steps": ex.get("steps", 0),
|
|
126
|
+
"spend_usd": ex.get("spend_usd", 0.0),
|
|
127
|
+
}
|
|
128
|
+
if ex["status"] == "completed":
|
|
129
|
+
out["result"] = env["delivery"]["result"]
|
|
130
|
+
if ex.get("error"):
|
|
131
|
+
out["error"] = ex["error"]
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def approve(store: StoreBackend, approval_id: str) -> bool:
|
|
136
|
+
"""Approve a pending approval request (human / policy engine).
|
|
137
|
+
Returns True if the decision was recorded."""
|
|
138
|
+
return store.decide_approval(approval_id, True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def deny(store: StoreBackend, approval_id: str) -> bool:
|
|
142
|
+
"""Deny a pending approval request. Returns True if recorded."""
|
|
143
|
+
return store.decide_approval(approval_id, False)
|
dhp/a2a_bridge.py
ADDED
|
@@ -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)
|
dhp/backend.py
ADDED
|
@@ -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: ...
|
dhp/bridge_worker.py
ADDED
|
@@ -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()
|