dispatchbox 0.1.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.
- dispatchbox/__init__.py +3 -0
- dispatchbox/__main__.py +127 -0
- dispatchbox/chat.py +209 -0
- dispatchbox/cron.py +56 -0
- dispatchbox/feishu.py +304 -0
- dispatchbox/hub.py +550 -0
- dispatchbox/net.py +33 -0
- dispatchbox/rules.py +236 -0
- dispatchbox/server.py +480 -0
- dispatchbox/slack.py +251 -0
- dispatchbox/store.py +99 -0
- dispatchbox/worker.py +314 -0
- dispatchbox-0.1.0.dist-info/METADATA +293 -0
- dispatchbox-0.1.0.dist-info/RECORD +18 -0
- dispatchbox-0.1.0.dist-info/WHEEL +5 -0
- dispatchbox-0.1.0.dist-info/entry_points.txt +2 -0
- dispatchbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- dispatchbox-0.1.0.dist-info/top_level.txt +1 -0
dispatchbox/slack.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Slack adapter over plain HTTP (Events API + Interactivity), standard library only.
|
|
2
|
+
|
|
3
|
+
Formats follow docs.slack.dev: v0 request signing, chat.postMessage / chat.update, Block Kit
|
|
4
|
+
buttons, block_actions payloads, app_mention and message.im events.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qs
|
|
17
|
+
|
|
18
|
+
from .chat import (ChatAdapter, ChatError, Response, answer_text, decided_line, decision_text, empty_response,
|
|
19
|
+
http_json, in_background, json_response, status_line)
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("dispatchbox.slack")
|
|
22
|
+
|
|
23
|
+
REPLAY_WINDOW = 300
|
|
24
|
+
_MENTION = re.compile(r"<@[A-Z0-9]+>")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def esc(text: str) -> str:
|
|
28
|
+
"""Escape the three characters Slack mrkdwn treats as control characters."""
|
|
29
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def link_md(link: str, label: str) -> str:
|
|
33
|
+
return f"<{link}|{label}>" if link.startswith(("http://", "https://")) else ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sign(secret: str, timestamp: str, body: bytes) -> str:
|
|
37
|
+
base = b"v0:" + timestamp.encode() + b":" + body
|
|
38
|
+
return "v0=" + hmac.new(secret.encode(), base, hashlib.sha256).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SlackAdapter(ChatAdapter):
|
|
42
|
+
name = "slack"
|
|
43
|
+
|
|
44
|
+
def __init__(self, hub, bot_token: str, signing_secret: str, api_base: str = "https://slack.com/api",
|
|
45
|
+
clock=time.time):
|
|
46
|
+
super().__init__(hub)
|
|
47
|
+
self.bot_token = bot_token
|
|
48
|
+
self.signing_secret = signing_secret
|
|
49
|
+
self.api_base = api_base.rstrip("/")
|
|
50
|
+
self.clock = clock
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------ Slack Web API
|
|
53
|
+
|
|
54
|
+
def api(self, method: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
55
|
+
resp = http_json("POST", f"{self.api_base}/{method}", body,
|
|
56
|
+
headers={"Authorization": f"Bearer {self.bot_token}"})
|
|
57
|
+
if not resp.get("ok"):
|
|
58
|
+
raise ChatError(f"slack {method} failed: {resp.get('error', resp)}")
|
|
59
|
+
return resp
|
|
60
|
+
|
|
61
|
+
def post(self, channel: str, text: str, blocks: list[dict[str, Any]] | None = None,
|
|
62
|
+
thread_ts: str | None = None) -> dict[str, Any]:
|
|
63
|
+
body: dict[str, Any] = {"channel": channel, "text": text}
|
|
64
|
+
if blocks:
|
|
65
|
+
body["blocks"] = blocks
|
|
66
|
+
if thread_ts:
|
|
67
|
+
body["thread_ts"] = thread_ts
|
|
68
|
+
return self.api("chat.postMessage", body)
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------ incoming requests
|
|
71
|
+
|
|
72
|
+
def verify(self, headers: dict[str, str], body: bytes) -> bool:
|
|
73
|
+
ts = headers.get("x-slack-request-timestamp", "")
|
|
74
|
+
sig = headers.get("x-slack-signature", "")
|
|
75
|
+
if not ts.isdigit() or abs(self.clock() - int(ts)) > REPLAY_WINDOW:
|
|
76
|
+
return False
|
|
77
|
+
return hmac.compare_digest(sig, sign(self.signing_secret, ts, body))
|
|
78
|
+
|
|
79
|
+
def handle(self, headers: dict[str, str], body: bytes) -> Response:
|
|
80
|
+
if not self.verify(headers, body):
|
|
81
|
+
return json_response({"error": "bad slack signature"}, 401)
|
|
82
|
+
if headers.get("content-type", "").startswith("application/x-www-form-urlencoded"):
|
|
83
|
+
form = parse_qs(body.decode())
|
|
84
|
+
try:
|
|
85
|
+
payload = json.loads(form.get("payload", ["{}"])[-1])
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
return json_response({"error": "bad payload"}, 400)
|
|
88
|
+
return self._interaction(payload)
|
|
89
|
+
try:
|
|
90
|
+
data = json.loads(body or b"{}")
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
return json_response({"error": "bad json"}, 400)
|
|
93
|
+
if data.get("type") == "url_verification":
|
|
94
|
+
return json_response({"challenge": data.get("challenge", "")})
|
|
95
|
+
if data.get("type") == "event_callback":
|
|
96
|
+
self._event(data)
|
|
97
|
+
return empty_response()
|
|
98
|
+
|
|
99
|
+
def _event(self, data: dict[str, Any]) -> None:
|
|
100
|
+
ev = data.get("event") or {}
|
|
101
|
+
kind = ev.get("type")
|
|
102
|
+
if ev.get("bot_id") or ev.get("subtype") or not ev.get("user"):
|
|
103
|
+
return # bots (including ourselves), edits, deletes, joins
|
|
104
|
+
if kind == "app_mention":
|
|
105
|
+
etype = "message.mention"
|
|
106
|
+
elif kind == "message" and ev.get("channel_type") == "im":
|
|
107
|
+
etype = "message.dm"
|
|
108
|
+
else:
|
|
109
|
+
return
|
|
110
|
+
user, channel = ev["user"], ev.get("channel", "")
|
|
111
|
+
thread = ev.get("thread_ts") or ev.get("ts")
|
|
112
|
+
if not self.user_allowed(user):
|
|
113
|
+
log.warning("slack user %s is not in chat.slack.allowed_users", user)
|
|
114
|
+
in_background(self._reply_safe, channel, thread,
|
|
115
|
+
f"You are not allowed to start dispatchbox runs. Ask the owner to add `{user}` to "
|
|
116
|
+
"chat.slack.allowed_users.")
|
|
117
|
+
return
|
|
118
|
+
text = _MENTION.sub("", ev.get("text", "")).strip()
|
|
119
|
+
result = self.hub.ingest({
|
|
120
|
+
"source": "slack", "type": etype, "subject": channel, "summary": text[:200],
|
|
121
|
+
"payload": {"chat": channel, "thread": thread, "ts": ev.get("ts"), "user": user, "text": text},
|
|
122
|
+
"dedupe_key": f"slack:{data.get('event_id') or ev.get('ts')}",
|
|
123
|
+
"dedupe_window": 86400,
|
|
124
|
+
})
|
|
125
|
+
if self.settings.get("ack", True):
|
|
126
|
+
msg = ack_text(result)
|
|
127
|
+
if msg:
|
|
128
|
+
in_background(self._reply_safe, channel, thread, msg)
|
|
129
|
+
|
|
130
|
+
def _interaction(self, p: dict[str, Any]) -> Response:
|
|
131
|
+
if p.get("type") != "block_actions":
|
|
132
|
+
return empty_response()
|
|
133
|
+
user = (p.get("user") or {}).get("id", "")
|
|
134
|
+
channel = (p.get("container") or {}).get("channel_id") or (p.get("channel") or {}).get("id")
|
|
135
|
+
ts = (p.get("container") or {}).get("message_ts") or (p.get("message") or {}).get("ts")
|
|
136
|
+
for action in p.get("actions") or []:
|
|
137
|
+
if not str(action.get("action_id", "")).startswith("dispatchbox_choice_"):
|
|
138
|
+
continue
|
|
139
|
+
try:
|
|
140
|
+
value = json.loads(action.get("value") or "{}")
|
|
141
|
+
run_id, choice = int(value["run"]), str(value["choice"])
|
|
142
|
+
except (ValueError, KeyError, TypeError):
|
|
143
|
+
continue
|
|
144
|
+
ok, message, run = self.decide_from_chat(run_id, choice, user)
|
|
145
|
+
if run and channel and ts:
|
|
146
|
+
in_background(self._refresh_card, channel, ts, run)
|
|
147
|
+
if not ok and p.get("response_url"):
|
|
148
|
+
in_background(http_json, "POST", p["response_url"],
|
|
149
|
+
{"response_type": "ephemeral", "replace_original": False, "text": message})
|
|
150
|
+
return empty_response()
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------ hub hooks
|
|
153
|
+
|
|
154
|
+
def notify(self, run: dict[str, Any], link: str) -> None:
|
|
155
|
+
if self.from_chat(run):
|
|
156
|
+
return # the originating conversation hears about it in run_finished
|
|
157
|
+
channel = self.settings.get("notify_channel")
|
|
158
|
+
if not channel:
|
|
159
|
+
log.warning("slack: chat.slack.notify_channel is not set, skipping notification for run %s", run["id"])
|
|
160
|
+
return
|
|
161
|
+
if run["status"] == "awaiting_decision":
|
|
162
|
+
self.post(channel, decision_fallback(run), decision_blocks(run, link))
|
|
163
|
+
else:
|
|
164
|
+
self.post(channel, result_text(run, link))
|
|
165
|
+
|
|
166
|
+
def run_finished(self, run: dict[str, Any], link: str) -> None:
|
|
167
|
+
origin = self.origin(run)
|
|
168
|
+
if not origin or not self.settings.get("reply_on_finish", True):
|
|
169
|
+
return
|
|
170
|
+
if run["status"] == "awaiting_decision":
|
|
171
|
+
self.post(origin["chat"], decision_fallback(run), decision_blocks(run, link), thread_ts=origin.get("thread"))
|
|
172
|
+
elif run["status"] in {"succeeded", "failed", "lost"}:
|
|
173
|
+
self.post(origin["chat"], result_text(run, link), thread_ts=origin.get("thread"))
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------ helpers
|
|
176
|
+
|
|
177
|
+
def _reply_safe(self, channel: str, thread: str | None, text: str) -> None:
|
|
178
|
+
try:
|
|
179
|
+
self.post(channel, text, thread_ts=thread)
|
|
180
|
+
except ChatError as e:
|
|
181
|
+
log.error("slack reply failed: %s", e)
|
|
182
|
+
|
|
183
|
+
def _refresh_card(self, channel: str, ts: str, run: dict[str, Any]) -> None:
|
|
184
|
+
link = self.hub.run_link(run["id"])
|
|
185
|
+
if run["status"] == "awaiting_decision":
|
|
186
|
+
blocks, text = decision_blocks(run, link), decision_fallback(run)
|
|
187
|
+
else:
|
|
188
|
+
blocks, text = decided_blocks(run, link), decided_line(run)
|
|
189
|
+
self.api("chat.update", {"channel": channel, "ts": ts, "text": text, "blocks": blocks})
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def ack_text(result: dict[str, Any]) -> str:
|
|
193
|
+
status = result.get("status")
|
|
194
|
+
if status == "routed" and result.get("runs"):
|
|
195
|
+
return "Got it. Queued as run " + ", ".join(f"#{r}" for r in result["runs"]) + "."
|
|
196
|
+
if status == "coalesced":
|
|
197
|
+
return "Added to queued run " + ", ".join(f"#{r}" for r in result.get("coalesced_into", [])) + "."
|
|
198
|
+
if status == "unmatched":
|
|
199
|
+
return "No dispatchbox rule matches this message, so nothing was started."
|
|
200
|
+
return ""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def decision_fallback(run: dict[str, Any]) -> str:
|
|
204
|
+
question, _, _ = decision_text(run)
|
|
205
|
+
return f"Run #{run['id']} ({run['rule']}) needs your decision: {question}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def decision_blocks(run: dict[str, Any], link: str) -> list[dict[str, Any]]:
|
|
209
|
+
question, context, options = decision_text(run)
|
|
210
|
+
blocks: list[dict[str, Any]] = [
|
|
211
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": f"*{esc(question)}*"}},
|
|
212
|
+
{"type": "context", "elements": [{"type": "mrkdwn", "text": esc(status_line(run))}]},
|
|
213
|
+
]
|
|
214
|
+
if context:
|
|
215
|
+
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": esc(context)[:2900]}})
|
|
216
|
+
if options:
|
|
217
|
+
blocks.append({"type": "actions", "block_id": f"dispatchbox_run_{run['id']}", "elements": [
|
|
218
|
+
{"type": "button", "action_id": f"dispatchbox_choice_{i}", "text": {"type": "plain_text", "text": opt[:75]},
|
|
219
|
+
"value": json.dumps({"run": run["id"], "choice": opt}, ensure_ascii=False)[:2000],
|
|
220
|
+
**({"style": "primary"} if i == 0 else {})}
|
|
221
|
+
for i, opt in enumerate(options)
|
|
222
|
+
]})
|
|
223
|
+
else:
|
|
224
|
+
blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": "No options were given. Answer on the dashboard."}]})
|
|
225
|
+
md = link_md(link, "Open in dispatchbox")
|
|
226
|
+
if md:
|
|
227
|
+
blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": md}]})
|
|
228
|
+
return blocks
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def decided_blocks(run: dict[str, Any], link: str) -> list[dict[str, Any]]:
|
|
232
|
+
question, _, _ = decision_text(run)
|
|
233
|
+
blocks: list[dict[str, Any]] = [
|
|
234
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": f"~{esc(question)}~\n*{esc(decided_line(run))}*"}},
|
|
235
|
+
{"type": "context", "elements": [{"type": "mrkdwn", "text": esc(status_line(run))}]},
|
|
236
|
+
]
|
|
237
|
+
md = link_md(link, "Open in dispatchbox")
|
|
238
|
+
if md:
|
|
239
|
+
blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": md}]})
|
|
240
|
+
return blocks
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def result_text(run: dict[str, Any], link: str) -> str:
|
|
244
|
+
lines = [esc(status_line(run))]
|
|
245
|
+
answer = answer_text(run, 3000)
|
|
246
|
+
if answer:
|
|
247
|
+
lines.append(esc(answer))
|
|
248
|
+
md = link_md(link, "details")
|
|
249
|
+
if md:
|
|
250
|
+
lines.append(md)
|
|
251
|
+
return "\n".join(lines)
|
dispatchbox/store.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""SQLite persistence. All access goes through one connection guarded by the hub's lock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
SCHEMA = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
11
|
+
id INTEGER PRIMARY KEY,
|
|
12
|
+
received_at REAL NOT NULL,
|
|
13
|
+
source TEXT NOT NULL,
|
|
14
|
+
type TEXT NOT NULL,
|
|
15
|
+
subject TEXT NOT NULL,
|
|
16
|
+
summary TEXT NOT NULL,
|
|
17
|
+
link TEXT NOT NULL,
|
|
18
|
+
payload TEXT NOT NULL,
|
|
19
|
+
dedupe_key TEXT NOT NULL,
|
|
20
|
+
status TEXT NOT NULL, -- routed | coalesced | duplicate | unmatched
|
|
21
|
+
note TEXT NOT NULL DEFAULT ''
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS events_dedupe ON events(dedupe_key, received_at);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
26
|
+
id INTEGER PRIMARY KEY,
|
|
27
|
+
rule TEXT NOT NULL,
|
|
28
|
+
worker_pattern TEXT NOT NULL,
|
|
29
|
+
group_key TEXT NOT NULL,
|
|
30
|
+
concurrency INTEGER NOT NULL,
|
|
31
|
+
spec TEXT NOT NULL,
|
|
32
|
+
status TEXT NOT NULL, -- queued | running | succeeded | failed | lost | awaiting_decision | decided
|
|
33
|
+
created_at REAL NOT NULL,
|
|
34
|
+
claimed_by TEXT,
|
|
35
|
+
claimed_at REAL,
|
|
36
|
+
heartbeat_at REAL,
|
|
37
|
+
finished_at REAL,
|
|
38
|
+
exit_code INTEGER,
|
|
39
|
+
summary TEXT,
|
|
40
|
+
session_id TEXT,
|
|
41
|
+
prompt TEXT,
|
|
42
|
+
output_tail TEXT,
|
|
43
|
+
decision_request TEXT,
|
|
44
|
+
decision TEXT,
|
|
45
|
+
parent_run INTEGER,
|
|
46
|
+
cost_usd REAL,
|
|
47
|
+
input_tokens INTEGER,
|
|
48
|
+
output_tokens INTEGER,
|
|
49
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
50
|
+
retry_of INTEGER
|
|
51
|
+
);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS runs_status ON runs(status, id);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS run_events (
|
|
55
|
+
run_id INTEGER NOT NULL,
|
|
56
|
+
event_id INTEGER NOT NULL,
|
|
57
|
+
PRIMARY KEY (run_id, event_id)
|
|
58
|
+
);
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def connect(path: str) -> sqlite3.Connection:
|
|
63
|
+
conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
|
64
|
+
conn.row_factory = sqlite3.Row
|
|
65
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
66
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
67
|
+
conn.executescript(SCHEMA)
|
|
68
|
+
_migrate(conn)
|
|
69
|
+
return conn
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Columns added after the first release. Older databases get them on startup.
|
|
73
|
+
_ADDED_RUN_COLUMNS = {
|
|
74
|
+
"cost_usd": "REAL",
|
|
75
|
+
"input_tokens": "INTEGER",
|
|
76
|
+
"output_tokens": "INTEGER",
|
|
77
|
+
"attempt": "INTEGER NOT NULL DEFAULT 1",
|
|
78
|
+
"retry_of": "INTEGER",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _migrate(conn: sqlite3.Connection) -> None:
|
|
83
|
+
have = {row["name"] for row in conn.execute("PRAGMA table_info(runs)")}
|
|
84
|
+
for name, decl in _ADDED_RUN_COLUMNS.items():
|
|
85
|
+
if name not in have:
|
|
86
|
+
conn.execute(f"ALTER TABLE runs ADD COLUMN {name} {decl}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
|
90
|
+
if row is None:
|
|
91
|
+
return None
|
|
92
|
+
d = dict(row)
|
|
93
|
+
for key in ("payload", "spec", "decision_request", "decision"):
|
|
94
|
+
if key in d and isinstance(d[key], str) and d[key]:
|
|
95
|
+
try:
|
|
96
|
+
d[key] = json.loads(d[key])
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
pass
|
|
99
|
+
return d
|
dispatchbox/worker.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Worker: runs on a machine that has your agents installed, pulls tasks from the hub and runs them.
|
|
2
|
+
|
|
3
|
+
The worker only makes outbound requests, so the machine never needs to be reachable from the internet.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .net import context_for
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("dispatchbox.worker")
|
|
26
|
+
|
|
27
|
+
HEARTBEAT_EVERY = 20
|
|
28
|
+
OUTPUT_LIMIT = 200_000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HubGone(Exception):
|
|
32
|
+
"""The hub no longer considers this run ours (it was reaped or finished elsewhere)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class WorkerConfig:
|
|
37
|
+
hub: str
|
|
38
|
+
name: str
|
|
39
|
+
token: str
|
|
40
|
+
slots: int = 2
|
|
41
|
+
wait: int = 50
|
|
42
|
+
allow_dirs: list[str] = field(default_factory=lambda: [os.getcwd()])
|
|
43
|
+
runners: list[str] = field(default_factory=lambda: ["claude", "codex"])
|
|
44
|
+
claude_bin: str = "claude"
|
|
45
|
+
codex_bin: str = "codex"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HubClient:
|
|
49
|
+
def __init__(self, base: str, token: str):
|
|
50
|
+
self.base = base.rstrip("/")
|
|
51
|
+
self.token = token
|
|
52
|
+
|
|
53
|
+
def _req(self, method: str, path: str, body: Any = None, timeout: float = 30) -> tuple[int, Any]:
|
|
54
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
55
|
+
req = urllib.request.Request(self.base + path, data=data, method=method, headers={
|
|
56
|
+
"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"})
|
|
57
|
+
try:
|
|
58
|
+
with urllib.request.urlopen(req, timeout=timeout, context=context_for(req.full_url)) as resp:
|
|
59
|
+
raw = resp.read()
|
|
60
|
+
return resp.status, (json.loads(raw) if raw else None)
|
|
61
|
+
except urllib.error.HTTPError as e:
|
|
62
|
+
raw = e.read()
|
|
63
|
+
try:
|
|
64
|
+
payload = json.loads(raw) if raw else None
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
payload = raw.decode(errors="replace")
|
|
67
|
+
return e.code, payload
|
|
68
|
+
|
|
69
|
+
def claim(self, worker: str, wait: int, runners: list[str]) -> dict[str, Any] | None:
|
|
70
|
+
q = urllib.parse.urlencode({"worker": worker, "wait": wait, "runners": ",".join(runners)})
|
|
71
|
+
status, body = self._req("GET", f"/claim?{q}", timeout=wait + 15)
|
|
72
|
+
if status == 204:
|
|
73
|
+
return None
|
|
74
|
+
if status != 200:
|
|
75
|
+
raise RuntimeError(f"claim failed: {status} {body}")
|
|
76
|
+
return body
|
|
77
|
+
|
|
78
|
+
def heartbeat(self, run_id: int, worker: str) -> None:
|
|
79
|
+
status, body = self._req("POST", f"/runs/{run_id}/heartbeat", {"worker": worker})
|
|
80
|
+
if status == 409 or status == 404:
|
|
81
|
+
raise HubGone(str(body))
|
|
82
|
+
if status != 200:
|
|
83
|
+
raise RuntimeError(f"heartbeat failed: {status} {body}")
|
|
84
|
+
|
|
85
|
+
def result(self, run_id: int, worker: str, result: dict[str, Any]) -> None:
|
|
86
|
+
delay = 2.0
|
|
87
|
+
deadline = time.monotonic() + 600
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
status, body = self._req("POST", f"/runs/{run_id}/result", {"worker": worker, **result})
|
|
91
|
+
if status == 200:
|
|
92
|
+
return
|
|
93
|
+
if status in (404, 409):
|
|
94
|
+
log.warning("hub refused result for run %s: %s", run_id, body)
|
|
95
|
+
return
|
|
96
|
+
err = f"{status} {body}"
|
|
97
|
+
except (urllib.error.URLError, OSError) as e:
|
|
98
|
+
err = str(e)
|
|
99
|
+
if time.monotonic() > deadline:
|
|
100
|
+
log.error("giving up reporting run %s: %s", run_id, err)
|
|
101
|
+
return
|
|
102
|
+
log.warning("reporting run %s failed (%s), retrying in %.0fs", run_id, err, delay)
|
|
103
|
+
time.sleep(delay)
|
|
104
|
+
delay = min(delay * 2, 60)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_cwd(raw: str, allow_dirs: list[str]) -> Path:
|
|
108
|
+
path = Path(os.path.expanduser(raw or ".")).resolve()
|
|
109
|
+
allowed = [Path(os.path.expanduser(d)).resolve() for d in allow_dirs]
|
|
110
|
+
if not any(path == a or a in path.parents for a in allowed):
|
|
111
|
+
raise PermissionError(f"{path} is outside the worker's allowed directories")
|
|
112
|
+
if not path.is_dir():
|
|
113
|
+
raise FileNotFoundError(f"{path} does not exist")
|
|
114
|
+
return path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_command(task: dict[str, Any], cfg: WorkerConfig, tmpdir: str) -> tuple[list[str], str | None]:
|
|
118
|
+
"""Return (argv, stdin text). Prompts go through stdin so they never show up in `ps`."""
|
|
119
|
+
runner = task["runner"]
|
|
120
|
+
extra = [str(a) for a in task.get("args") or []]
|
|
121
|
+
model = task.get("model")
|
|
122
|
+
resume = task.get("resume_session")
|
|
123
|
+
if runner == "claude":
|
|
124
|
+
cmd = [cfg.claude_bin, "-p", "--output-format", "json"]
|
|
125
|
+
if model:
|
|
126
|
+
cmd += ["--model", model]
|
|
127
|
+
if resume:
|
|
128
|
+
cmd += ["--resume", resume]
|
|
129
|
+
return cmd + extra, task["prompt"]
|
|
130
|
+
if runner == "codex":
|
|
131
|
+
last = os.path.join(tmpdir, "last_message.txt")
|
|
132
|
+
cmd = [cfg.codex_bin, "exec"]
|
|
133
|
+
if resume:
|
|
134
|
+
cmd += ["resume", resume]
|
|
135
|
+
cmd += ["--json", "-o", last]
|
|
136
|
+
if model:
|
|
137
|
+
cmd += ["-m", model]
|
|
138
|
+
return cmd + extra + ["-"], task["prompt"]
|
|
139
|
+
if runner == "shell":
|
|
140
|
+
return ["/bin/sh", "-c", task["command"]], None
|
|
141
|
+
raise ValueError(f"unknown runner {runner!r}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_output(runner: str, stdout: str, stderr: str, tmpdir: str) -> dict[str, Any]:
|
|
145
|
+
"""Pull the agent's final message and session id out of the raw process output."""
|
|
146
|
+
if runner == "claude":
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(stdout.strip().splitlines()[-1]) if stdout.strip() else {}
|
|
149
|
+
except (json.JSONDecodeError, IndexError):
|
|
150
|
+
data = {}
|
|
151
|
+
if isinstance(data, dict) and "result" in data:
|
|
152
|
+
out = {"output": str(data.get("result") or ""), "session_id": data.get("session_id")}
|
|
153
|
+
if data.get("is_error"):
|
|
154
|
+
out["agent_error"] = True
|
|
155
|
+
if data.get("total_cost_usd") is not None:
|
|
156
|
+
out["cost_usd"] = data["total_cost_usd"]
|
|
157
|
+
usage = data.get("usage") or {}
|
|
158
|
+
if isinstance(usage, dict) and usage:
|
|
159
|
+
# Claude reports cache reads and writes separately from fresh input; count all of it as input.
|
|
160
|
+
out["input_tokens"] = sum(int(usage.get(k) or 0) for k in
|
|
161
|
+
("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"))
|
|
162
|
+
out["output_tokens"] = int(usage.get("output_tokens") or 0)
|
|
163
|
+
return out
|
|
164
|
+
return {"output": (stdout + ("\n" + stderr if stderr else "")).strip()}
|
|
165
|
+
if runner == "codex":
|
|
166
|
+
session = None
|
|
167
|
+
tokens_in = tokens_out = 0
|
|
168
|
+
saw_usage = False
|
|
169
|
+
errors: list[str] = []
|
|
170
|
+
for line in stdout.splitlines():
|
|
171
|
+
try:
|
|
172
|
+
ev = json.loads(line)
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
continue
|
|
175
|
+
if not isinstance(ev, dict):
|
|
176
|
+
continue
|
|
177
|
+
kind = ev.get("type")
|
|
178
|
+
if kind in {"thread.started", "session.started"}:
|
|
179
|
+
session = ev.get("thread_id") or ev.get("session_id") or session
|
|
180
|
+
elif kind == "turn.completed" and isinstance(ev.get("usage"), dict):
|
|
181
|
+
# Codex's input_tokens already includes cached_input_tokens.
|
|
182
|
+
saw_usage = True
|
|
183
|
+
tokens_in += int(ev["usage"].get("input_tokens") or 0)
|
|
184
|
+
tokens_out += int(ev["usage"].get("output_tokens") or 0)
|
|
185
|
+
elif kind == "turn.failed":
|
|
186
|
+
errors.append(str((ev.get("error") or {}).get("message", "turn failed")))
|
|
187
|
+
elif kind == "error":
|
|
188
|
+
errors.append(str(ev.get("message", "error")))
|
|
189
|
+
last = Path(tmpdir, "last_message.txt")
|
|
190
|
+
text = last.read_text(errors="replace").strip() if last.exists() else ""
|
|
191
|
+
out: dict[str, Any] = {"session_id": session}
|
|
192
|
+
if errors and not text:
|
|
193
|
+
out["output"] = "codex failed: " + errors[-1][:2000]
|
|
194
|
+
out["agent_error"] = True
|
|
195
|
+
else:
|
|
196
|
+
out["output"] = text or stderr.strip()[-4000:]
|
|
197
|
+
if saw_usage:
|
|
198
|
+
out["input_tokens"], out["output_tokens"] = tokens_in, tokens_out
|
|
199
|
+
return out
|
|
200
|
+
return {"output": (stdout + ("\n" + stderr if stderr else "")).strip()}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_task(task: dict[str, Any], cfg: WorkerConfig, client: HubClient) -> dict[str, Any]:
|
|
204
|
+
run_id = task["run_id"]
|
|
205
|
+
try:
|
|
206
|
+
cwd = resolve_cwd(task.get("cwd", "."), cfg.allow_dirs)
|
|
207
|
+
except (PermissionError, FileNotFoundError) as e:
|
|
208
|
+
return {"exit_code": 126, "output": f"dispatchbox worker refused to run: {e}", "summary": str(e)}
|
|
209
|
+
|
|
210
|
+
with tempfile.TemporaryDirectory(prefix=f"dispatchbox-{run_id}-") as tmpdir:
|
|
211
|
+
events_file = Path(tmpdir, "events.json")
|
|
212
|
+
events_file.write_text(json.dumps(task.get("events", []), ensure_ascii=False, indent=1))
|
|
213
|
+
env = {**os.environ, "DISPATCHBOX_RUN_ID": str(run_id), "DISPATCHBOX_RULE": task["rule"],
|
|
214
|
+
"DISPATCHBOX_EVENTS_FILE": str(events_file), "DISPATCHBOX_HUB": cfg.hub}
|
|
215
|
+
env.pop("DISPATCHBOX_TOKEN", None) # agents never see the hub token
|
|
216
|
+
try:
|
|
217
|
+
cmd, stdin = build_command(task, cfg, tmpdir)
|
|
218
|
+
except (ValueError, KeyError) as e:
|
|
219
|
+
return {"exit_code": 2, "output": str(e), "summary": str(e)}
|
|
220
|
+
|
|
221
|
+
log.info("run #%s %s: %s in %s", run_id, task["rule"], cmd[0], cwd)
|
|
222
|
+
try:
|
|
223
|
+
proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
|
|
224
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
|
|
225
|
+
except FileNotFoundError as e:
|
|
226
|
+
return {"exit_code": 127, "output": str(e), "summary": f"{cmd[0]} is not installed on this worker"}
|
|
227
|
+
|
|
228
|
+
stop = threading.Event()
|
|
229
|
+
gone = threading.Event()
|
|
230
|
+
|
|
231
|
+
def beat() -> None:
|
|
232
|
+
while not stop.wait(HEARTBEAT_EVERY):
|
|
233
|
+
try:
|
|
234
|
+
client.heartbeat(run_id, cfg.name)
|
|
235
|
+
except HubGone:
|
|
236
|
+
log.warning("hub dropped run #%s, stopping it", run_id)
|
|
237
|
+
gone.set()
|
|
238
|
+
_kill(proc)
|
|
239
|
+
return
|
|
240
|
+
except Exception as e:
|
|
241
|
+
log.warning("heartbeat for run #%s failed: %s", run_id, e)
|
|
242
|
+
|
|
243
|
+
threading.Thread(target=beat, daemon=True).start()
|
|
244
|
+
timed_out = False
|
|
245
|
+
try:
|
|
246
|
+
stdout, stderr = proc.communicate(input=stdin, timeout=task.get("timeout", 1800))
|
|
247
|
+
except subprocess.TimeoutExpired:
|
|
248
|
+
timed_out = True
|
|
249
|
+
_kill(proc)
|
|
250
|
+
stdout, stderr = proc.communicate()
|
|
251
|
+
finally:
|
|
252
|
+
stop.set()
|
|
253
|
+
|
|
254
|
+
result = parse_output(task["runner"], stdout[-OUTPUT_LIMIT:], stderr[-OUTPUT_LIMIT:], tmpdir)
|
|
255
|
+
code = proc.returncode
|
|
256
|
+
if timed_out:
|
|
257
|
+
code = 124
|
|
258
|
+
result["summary"] = f"timed out after {task.get('timeout')}s"
|
|
259
|
+
elif result.pop("agent_error", False) and code == 0:
|
|
260
|
+
code = 1
|
|
261
|
+
result["exit_code"] = code
|
|
262
|
+
if gone.is_set():
|
|
263
|
+
result["summary"] = "stopped because the hub dropped this run"
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _kill(proc: subprocess.Popen) -> None:
|
|
268
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
269
|
+
try:
|
|
270
|
+
os.killpg(proc.pid, sig)
|
|
271
|
+
except ProcessLookupError:
|
|
272
|
+
return
|
|
273
|
+
try:
|
|
274
|
+
proc.wait(timeout=10)
|
|
275
|
+
return
|
|
276
|
+
except subprocess.TimeoutExpired:
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def serve_worker(cfg: WorkerConfig, stop: threading.Event | None = None) -> None:
|
|
281
|
+
stop = stop or threading.Event()
|
|
282
|
+
client = HubClient(cfg.hub, cfg.token)
|
|
283
|
+
slots = threading.Semaphore(cfg.slots)
|
|
284
|
+
backoff = 2.0
|
|
285
|
+
log.info("worker %s polling %s (slots=%d, runners=%s, dirs=%s)", cfg.name, cfg.hub, cfg.slots,
|
|
286
|
+
",".join(cfg.runners), ", ".join(cfg.allow_dirs))
|
|
287
|
+
|
|
288
|
+
def work(task: dict[str, Any]) -> None:
|
|
289
|
+
try:
|
|
290
|
+
result = run_task(task, cfg, client)
|
|
291
|
+
client.result(task["run_id"], cfg.name, result)
|
|
292
|
+
log.info("run #%s finished with exit %s", task["run_id"], result.get("exit_code"))
|
|
293
|
+
except Exception:
|
|
294
|
+
log.exception("run #%s crashed", task["run_id"])
|
|
295
|
+
client.result(task["run_id"], cfg.name, {"exit_code": 1, "output": "worker crashed", "summary": "worker crashed"})
|
|
296
|
+
finally:
|
|
297
|
+
slots.release()
|
|
298
|
+
|
|
299
|
+
while not stop.is_set():
|
|
300
|
+
if not slots.acquire(timeout=1):
|
|
301
|
+
continue
|
|
302
|
+
try:
|
|
303
|
+
task = client.claim(cfg.name, cfg.wait, cfg.runners)
|
|
304
|
+
backoff = 2.0
|
|
305
|
+
except Exception as e:
|
|
306
|
+
slots.release()
|
|
307
|
+
log.warning("cannot reach hub (%s), retrying in %.0fs", e, backoff)
|
|
308
|
+
stop.wait(backoff)
|
|
309
|
+
backoff = min(backoff * 2, 60)
|
|
310
|
+
continue
|
|
311
|
+
if task is None:
|
|
312
|
+
slots.release()
|
|
313
|
+
continue
|
|
314
|
+
threading.Thread(target=work, args=(task,), name=f"run-{task['run_id']}", daemon=True).start()
|