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/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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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()