opal-optimizer 0.3.5__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.
- opal/__init__.py +7 -0
- opal/_version.py +3 -0
- opal/agent.py +394 -0
- opal/backends/__init__.py +31 -0
- opal/backends/_codex_client.py +450 -0
- opal/backends/_privacy.py +116 -0
- opal/backends/base.py +248 -0
- opal/backends/claude.py +665 -0
- opal/backends/codex.py +356 -0
- opal/backends/mailbox.py +1034 -0
- opal/backends/unavailable.py +161 -0
- opal/cli.py +396 -0
- opal/context.py +111 -0
- opal/defaults.json +11 -0
- opal/defaults.py +54 -0
- opal/estimate.py +120 -0
- opal/hf.py +109 -0
- opal/misc/__init__.py +2 -0
- opal/misc/aggressive.py +157 -0
- opal/misc/audit.py +84 -0
- opal/misc/boundary.py +226 -0
- opal/misc/checkpoint.py +399 -0
- opal/misc/ddp.py +147 -0
- opal/misc/integrity.py +263 -0
- opal/misc/nested.py +67 -0
- opal/misc/neutral_analysis.py +166 -0
- opal/misc/precision.py +210 -0
- opal/misc/recovery.py +231 -0
- opal/misc/schedulers.py +214 -0
- opal/misc/setup.py +253 -0
- opal/misc/skip.py +165 -0
- opal/misc/taskfacts.py +293 -0
- opal/misc/waiting.py +58 -0
- opal/programs.py +482 -0
- opal/prompts/example_updater.py +252 -0
- opal/prompts/neutral.md +206 -0
- opal/report.py +1001 -0
- opal/report_page.py +175 -0
- opal/report_privacy.py +159 -0
- opal/results.py +80 -0
- opal/schedule.py +134 -0
- opal/summaries.py +224 -0
- opal/wrapper.py +1674 -0
- opal_optimizer-0.3.5.dist-info/METADATA +232 -0
- opal_optimizer-0.3.5.dist-info/RECORD +49 -0
- opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
- opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
- opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
- opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
opal/backends/mailbox.py
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
"""Mailbox: for GPU machines where no coding agent is installed or logged in (cluster nodes, shared servers).
|
|
2
|
+
|
|
3
|
+
Training side: opal.wrap(..., backend="mailbox:/path/on/the/gpu/machine")
|
|
4
|
+
Agent side: opal broker /path/on/the/gpu/machine --ssh user@gpu-host --backend claude
|
|
5
|
+
|
|
6
|
+
Each run gets its own subfolder <box>/<run id>/ (created 0700). The training side writes request-0001.json, ... and a
|
|
7
|
+
small latest.json naming the newest request, then waits (in its background thread; training goes on) for the matching
|
|
8
|
+
response. Every request carries the run id and an attempt id (new for each process start), and a response counts only
|
|
9
|
+
if it echoes both, so answers left over from another run or from before a crash are never applied. When the run stops
|
|
10
|
+
waiting (no broker, or the timeout), an answer that still comes before its next request is applied; a later one is not,
|
|
11
|
+
and the next message says so. The broker, on a laptop or login node, polls latest.json over ssh (or a shared
|
|
12
|
+
filesystem), runs a local backend on a local copy of the workspace, and writes the response back. All writes are atomic.
|
|
13
|
+
|
|
14
|
+
A request carries the programs and the files that are new since the previous request, plus a manifest of every other
|
|
15
|
+
workspace file (inbox/, reference/, SYSTEM.md, the agent's own notes); each such file is also kept in <run>/files/, so
|
|
16
|
+
a broker that lacks some (a new --workdir, another machine) fetches them from there, and drops inbox/ and reference/
|
|
17
|
+
files the run no longer has. A response carries the programs and the agent's own files (NOTES.md, scratch/), which the
|
|
18
|
+
training side keeps, so notes survive a change of broker. A broker claims a request (claim-NNNN.json, created
|
|
19
|
+
exclusively) before it starts the agent, so two brokers never both answer it, and runs the agent for at most the time
|
|
20
|
+
the request says the agent was told it has; when it stops the agent sooner (a shorter --timeout, or a request that
|
|
21
|
+
waited long), the message it gives the agent starts with the limit that holds.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import inspect
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shlex
|
|
32
|
+
import socket
|
|
33
|
+
import subprocess
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
import traceback
|
|
37
|
+
import uuid
|
|
38
|
+
from pathlib import Path, PurePosixPath
|
|
39
|
+
|
|
40
|
+
from ..misc.taskfacts import duration
|
|
41
|
+
from . import unavailable
|
|
42
|
+
from .base import Backend, error_tail
|
|
43
|
+
from ._privacy import redact_secrets
|
|
44
|
+
|
|
45
|
+
_PROGRAM = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\.py") # program files, as in programs.py
|
|
46
|
+
AGENT_FILE_MAX = 256 * 1024 # bytes per agent file sent back with a response
|
|
47
|
+
AGENT_FILES_MAX = 2 * 1024 * 1024 # bytes of agent files per response
|
|
48
|
+
CLAIM_STALE = 120 # seconds after which a claim its broker no longer refreshes may be taken over
|
|
49
|
+
ANSWER_MARGIN = 60 # seconds the broker keeps in hand to write an answer before the run stops waiting
|
|
50
|
+
IDLE_SWITCH = 10 # seconds a broker not pinned to a run (--run) stays on an idle run while another one is waiting
|
|
51
|
+
BEAT_SECONDS = 30 # between a broker's heartbeats
|
|
52
|
+
HEARTBEAT_STALE = 90 # seconds after which a heartbeat no longer counts
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
56
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp")
|
|
57
|
+
tmp.write_bytes(data)
|
|
58
|
+
os.replace(tmp, path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sha(text: str) -> str:
|
|
62
|
+
return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _safe_rel(rel: str) -> bool:
|
|
66
|
+
"""Workspace paths a request may carry: top-level program files, SYSTEM.md, files under inbox/ or reference/, and
|
|
67
|
+
the agent's own files."""
|
|
68
|
+
p = PurePosixPath(rel)
|
|
69
|
+
if p.is_absolute() or ".." in p.parts or not p.parts:
|
|
70
|
+
return False
|
|
71
|
+
if len(p.parts) == 1 and (_PROGRAM.fullmatch(p.name) or p.name == "SYSTEM.md"):
|
|
72
|
+
return True
|
|
73
|
+
if p.parts[0] in ("inbox", "reference"):
|
|
74
|
+
return not any(x.startswith(".") for x in p.parts)
|
|
75
|
+
return _agent_rel(rel)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _agent_rel(rel: str) -> bool:
|
|
79
|
+
"""The agent's own files, which a response may carry back: top-level files that are not programs (NOTES.md) and
|
|
80
|
+
files under scratch/. No hidden files, no __pycache__."""
|
|
81
|
+
p = PurePosixPath(rel)
|
|
82
|
+
if p.is_absolute() or ".." in p.parts or not p.parts or any(x.startswith(".") for x in p.parts):
|
|
83
|
+
return False
|
|
84
|
+
if "__pycache__" in p.parts or p.parts[0] in ("inbox", "reference"):
|
|
85
|
+
return False
|
|
86
|
+
if len(p.parts) == 1:
|
|
87
|
+
return not _PROGRAM.fullmatch(p.name) and p.name != "SYSTEM.md"
|
|
88
|
+
return p.parts[0] == "scratch"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _inside(ws: Path, rel: str):
|
|
92
|
+
"""ws/rel if no part of the way below ws is a symbolic link (an agent's program can make links in its folder), else
|
|
93
|
+
None."""
|
|
94
|
+
ws = Path(ws)
|
|
95
|
+
cur = ws
|
|
96
|
+
for part in PurePosixPath(rel).parts:
|
|
97
|
+
cur = cur / part
|
|
98
|
+
if cur.is_symlink():
|
|
99
|
+
return None
|
|
100
|
+
real_ws = os.path.realpath(ws)
|
|
101
|
+
if not os.path.realpath(cur).startswith(real_ws.rstrip(os.sep) + os.sep):
|
|
102
|
+
return None
|
|
103
|
+
return cur
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_in(ws: Path, rel: str, text: str) -> bool:
|
|
107
|
+
f = _inside(ws, rel)
|
|
108
|
+
if f is None:
|
|
109
|
+
return False
|
|
110
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
if _inside(ws, rel) is None: # a link made between the check and the mkdir
|
|
112
|
+
return False
|
|
113
|
+
f.write_text(text)
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _files(ws: Path, keep):
|
|
118
|
+
"""{rel: path} of the regular files below ws for which keep(rel) holds, never through a symbolic link."""
|
|
119
|
+
out = {}
|
|
120
|
+
ws = Path(ws)
|
|
121
|
+
for root, dirs, names in os.walk(ws): # os.walk does not follow links into folders
|
|
122
|
+
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(root, d)) and d != "__pycache__")
|
|
123
|
+
for n in sorted(names):
|
|
124
|
+
p = Path(root) / n
|
|
125
|
+
if p.is_symlink() or not p.is_file():
|
|
126
|
+
continue
|
|
127
|
+
rel = p.relative_to(ws).as_posix()
|
|
128
|
+
if keep(rel):
|
|
129
|
+
out[rel] = p
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _agent_files(ws: Path, known: dict, log=None) -> dict:
|
|
134
|
+
"""The agent's own files that differ from `known` ({rel: sha}), capped in size, as {rel: text}."""
|
|
135
|
+
out, total = {}, 0
|
|
136
|
+
for rel, p in _files(ws, _agent_rel).items():
|
|
137
|
+
try:
|
|
138
|
+
if p.stat().st_size > AGENT_FILE_MAX:
|
|
139
|
+
raise ValueError("too large")
|
|
140
|
+
text = p.read_bytes().decode("utf-8")
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
if log:
|
|
143
|
+
log(f"broker: not sent back (binary, unreadable or over {AGENT_FILE_MAX // 1024} KB): {rel}")
|
|
144
|
+
continue
|
|
145
|
+
if known.get(rel) == _sha(text):
|
|
146
|
+
continue
|
|
147
|
+
if total + len(text) > AGENT_FILES_MAX:
|
|
148
|
+
if log:
|
|
149
|
+
log(f"broker: not sent back (over {AGENT_FILES_MAX // 1024**2} MB of agent files): {rel}")
|
|
150
|
+
continue
|
|
151
|
+
out[rel], total = text, total + len(text)
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class MailboxBackend(Backend):
|
|
156
|
+
name = "mailbox"
|
|
157
|
+
|
|
158
|
+
def __init__(self, spec, poll=2.0, timeout=6 * 3600, broker_wait=120, unavailable_timeout=None):
|
|
159
|
+
self.box = Path(spec.split(":", 1)[1]).expanduser()
|
|
160
|
+
self.poll, self.timeout, self.broker_wait = poll, timeout, broker_wait
|
|
161
|
+
# no broker for broker_wait seconds, or a broker whose agent is unavailable: the request is kept and waited for
|
|
162
|
+
# (backends/unavailable.py), for at most unavailable_timeout seconds when given
|
|
163
|
+
self.unavailable_timeout = unavailable_timeout
|
|
164
|
+
self.training_waits = None # set by the run before each decision (the wait line)
|
|
165
|
+
self.run_id = None
|
|
166
|
+
self.attempt = uuid.uuid4().hex[:12]
|
|
167
|
+
self.k = 0
|
|
168
|
+
self.answered = 0 # answers received, so a new broker knows the agent had an earlier conversation
|
|
169
|
+
self.sent = {} # {rel: sha} of the non-program files already in the box (request or files/)
|
|
170
|
+
self.late = None # (k, workspace, t0) of the request the run stopped waiting for
|
|
171
|
+
self._wait = time.sleep
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def folder(self) -> Path:
|
|
175
|
+
return self.box / self.run_id
|
|
176
|
+
|
|
177
|
+
def setup(self, run_dir, workspace):
|
|
178
|
+
# the run's subfolder is created at the first request, so a resumed run (whose id is restored from the
|
|
179
|
+
# checkpoint after setup) never leaves an empty run behind for a broker to latch onto
|
|
180
|
+
self.box.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
181
|
+
self.run_id = self.run_id or uuid.uuid4().hex[:12]
|
|
182
|
+
|
|
183
|
+
def _ensure_folder(self):
|
|
184
|
+
if not (self.folder / "RUN").exists():
|
|
185
|
+
self.folder.mkdir(mode=0o700, exist_ok=True)
|
|
186
|
+
_atomic_write(self.folder / "RUN", json.dumps(dict(run_id=self.run_id, started=time.time())).encode())
|
|
187
|
+
(self.folder / "done").unlink(missing_ok=True)
|
|
188
|
+
(self.folder / "interrupted").unlink(missing_ok=True)
|
|
189
|
+
|
|
190
|
+
def broker_alive(self) -> bool:
|
|
191
|
+
"""A broker serving this run, or one that serves any run of the box (waiting, or --keep-serving), refreshes its
|
|
192
|
+
heartbeat every 30 s, also while the agent thinks."""
|
|
193
|
+
for f in (self.folder / "broker-alive", self.box / "broker-alive"):
|
|
194
|
+
try:
|
|
195
|
+
if f.exists() and time.time() - f.stat().st_mtime < HEARTBEAT_STALE:
|
|
196
|
+
if f.parent == self.folder or json.loads(f.read_text() or "{}").get("run") is None:
|
|
197
|
+
return True
|
|
198
|
+
except (OSError, ValueError):
|
|
199
|
+
continue
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def describe(self) -> str:
|
|
203
|
+
return (
|
|
204
|
+
f"mailbox {self.box}, run id {self.run_id}; answer it with: "
|
|
205
|
+
f"opal broker {self.box} --run {self.run_id} [--ssh user@this-host] --backend claude"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def _pack(self, workspace):
|
|
209
|
+
"""(files, manifest): the programs and every new or changed other file, and {rel: sha} of all other files.
|
|
210
|
+
New or changed other files are also written to <run>/files/ for a broker that lacks them."""
|
|
211
|
+
out, manifest = {}, {}
|
|
212
|
+
for rel, p in _files(Path(workspace), _safe_rel).items():
|
|
213
|
+
if _agent_rel(rel): # the agent's own files (or what its programs wrote): small text files only
|
|
214
|
+
try:
|
|
215
|
+
if p.stat().st_size > AGENT_FILE_MAX:
|
|
216
|
+
continue
|
|
217
|
+
text = p.read_bytes().decode("utf-8")
|
|
218
|
+
except (OSError, ValueError):
|
|
219
|
+
continue
|
|
220
|
+
else:
|
|
221
|
+
text = p.read_text(errors="replace")
|
|
222
|
+
if len(PurePosixPath(rel).parts) == 1 and _PROGRAM.fullmatch(rel):
|
|
223
|
+
out[rel] = text
|
|
224
|
+
continue
|
|
225
|
+
sha = _sha(text)
|
|
226
|
+
manifest[rel] = sha
|
|
227
|
+
if self.sent.get(rel) != sha:
|
|
228
|
+
out[rel] = text
|
|
229
|
+
mirror = self.folder / "files" / rel
|
|
230
|
+
mirror.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
231
|
+
_atomic_write(mirror, text.encode("utf-8", "replace"))
|
|
232
|
+
self.sent[rel] = sha
|
|
233
|
+
return out, manifest
|
|
234
|
+
|
|
235
|
+
def decide(self, workspace, message, system, record_dir):
|
|
236
|
+
t0 = time.time()
|
|
237
|
+
if getattr(self, "cancelled", False) or getattr(self, "finished", False):
|
|
238
|
+
return dict(text="", cost_usd=None, returncode=-1, error="the run has ended", seconds=0.0)
|
|
239
|
+
self._ensure_folder()
|
|
240
|
+
self.late = None # a newer request: an answer to an older one is no longer applied
|
|
241
|
+
self.k += 1
|
|
242
|
+
k = self.k
|
|
243
|
+
files, manifest = self._pack(workspace)
|
|
244
|
+
req = dict(
|
|
245
|
+
run_id=self.run_id,
|
|
246
|
+
attempt=self.attempt,
|
|
247
|
+
k=k,
|
|
248
|
+
system=system,
|
|
249
|
+
message=message,
|
|
250
|
+
files=files,
|
|
251
|
+
manifest=manifest,
|
|
252
|
+
limit=self.timeout, # what the agent was told it may take: the broker stops it in time for this run
|
|
253
|
+
answered=self.answered,
|
|
254
|
+
utc=time.time(),
|
|
255
|
+
**({"budget_left": self.budget_left} if getattr(self, "budget_left", None) is not None else {}),
|
|
256
|
+
)
|
|
257
|
+
_atomic_write(self.folder / f"request-{k:04d}.json", json.dumps(req).encode())
|
|
258
|
+
latest = dict(k=k, attempt=self.attempt, utc=req["utc"])
|
|
259
|
+
_atomic_write(self.folder / "latest.json", json.dumps(latest).encode())
|
|
260
|
+
# While no broker answers (none seen for broker_wait seconds) or the broker's agent is unavailable, the request
|
|
261
|
+
# is kept and waited for: that time does not count against `timeout`, and the broker is told how long the run
|
|
262
|
+
# had no broker (absent-NNNN.json), so that it does not shorten the agent's time for it either.
|
|
263
|
+
waiting, waited, absent, last = None, 0.0, 0.0, time.time()
|
|
264
|
+
while time.time() - t0 - waited < self.timeout:
|
|
265
|
+
if getattr(self, "cancelled", False) or getattr(self, "finished", False):
|
|
266
|
+
return dict(text="", cost_usd=None, returncode=-1, error="mailbox request cancelled: the run ended",
|
|
267
|
+
seconds=time.time() - t0 - waited)
|
|
268
|
+
now = time.time()
|
|
269
|
+
down = None
|
|
270
|
+
if now - t0 > self.broker_wait and not self.broker_alive():
|
|
271
|
+
down = ("broker", f"no broker is answering ({self.describe()})")
|
|
272
|
+
absent += now - last
|
|
273
|
+
_atomic_write(self.folder / f"absent-{k:04d}.json", json.dumps(dict(seconds=absent)).encode())
|
|
274
|
+
else:
|
|
275
|
+
down = self._broker_agent_down(k)
|
|
276
|
+
if down is not None:
|
|
277
|
+
if waiting is None:
|
|
278
|
+
what = "the mailbox's broker" if down[0] == "broker" else "Claude Code (on the broker)"
|
|
279
|
+
waiting = unavailable.Wait(
|
|
280
|
+
self._say, what, self.training_waits, self.unavailable_timeout, every=self.poll
|
|
281
|
+
)
|
|
282
|
+
waited += now - last
|
|
283
|
+
if waiting.note(*down) is None: # unavailable_timeout is over
|
|
284
|
+
return self._give_up(k, workspace, t0, down[1], waited)
|
|
285
|
+
elif waiting is not None:
|
|
286
|
+
waiting.end()
|
|
287
|
+
waiting = None
|
|
288
|
+
last = now
|
|
289
|
+
out = self._answer(k, workspace, t0)
|
|
290
|
+
if out is not None:
|
|
291
|
+
if waiting is not None:
|
|
292
|
+
waiting.end()
|
|
293
|
+
return dict(out, seconds=out["seconds"] - waited, **({"unavailable_seconds": round(waited, 1)} if waited else {}))
|
|
294
|
+
self._wait(self.poll)
|
|
295
|
+
return self._give_up(k, workspace, t0, "mailbox timeout", waited)
|
|
296
|
+
|
|
297
|
+
def _say(self, text):
|
|
298
|
+
log = getattr(self, "log", None)
|
|
299
|
+
if log is not None:
|
|
300
|
+
log(text)
|
|
301
|
+
else:
|
|
302
|
+
print(f"[opal] {text}", flush=True)
|
|
303
|
+
|
|
304
|
+
def _broker_agent_down(self, k):
|
|
305
|
+
"""("limit" | "login" | ..., the CLI's words) while the broker serving request k waits for its agent to be
|
|
306
|
+
available (it writes unavailable-NNNN.json for as long as that lasts), else None."""
|
|
307
|
+
try:
|
|
308
|
+
f = self.folder / f"unavailable-{k:04d}.json"
|
|
309
|
+
if not f.exists():
|
|
310
|
+
return None
|
|
311
|
+
body = json.loads(f.read_text() or "{}")
|
|
312
|
+
except (OSError, ValueError):
|
|
313
|
+
return None
|
|
314
|
+
if body.get("attempt") != self.attempt or not self.broker_alive():
|
|
315
|
+
return None
|
|
316
|
+
return body.get("kind") or "down", str(body.get("reason") or "the broker's agent is unavailable")
|
|
317
|
+
|
|
318
|
+
def _answer(self, k, workspace, t0):
|
|
319
|
+
"""Response k if it has come and is ours: its programs and the agent's own files are written into the
|
|
320
|
+
workspace. Else None."""
|
|
321
|
+
resp_f = self.folder / f"response-{k:04d}.json"
|
|
322
|
+
if not resp_f.exists():
|
|
323
|
+
return None
|
|
324
|
+
try:
|
|
325
|
+
resp = json.loads(resp_f.read_text())
|
|
326
|
+
except (OSError, ValueError):
|
|
327
|
+
resp = {}
|
|
328
|
+
if resp.get("run_id") != self.run_id or resp.get("attempt") != self.attempt:
|
|
329
|
+
resp_f.unlink(missing_ok=True) # an answer to an earlier attempt of this request: not ours
|
|
330
|
+
return None
|
|
331
|
+
ws = Path(workspace)
|
|
332
|
+
files = {n: t for n, t in (resp.get("files") or {}).items() if _PROGRAM.fullmatch(n)}
|
|
333
|
+
for p in ws.glob("*"):
|
|
334
|
+
if _PROGRAM.fullmatch(p.name) and p.name not in files and not p.is_symlink():
|
|
335
|
+
p.unlink()
|
|
336
|
+
for name, text in files.items():
|
|
337
|
+
_write_in(ws, name, text)
|
|
338
|
+
for rel, text in (resp.get("agent_files") or {}).items():
|
|
339
|
+
if isinstance(text, str) and _agent_rel(rel) and _write_in(ws, rel, text):
|
|
340
|
+
mirror = self.folder / "files" / rel # kept for the next broker, and not sent again
|
|
341
|
+
mirror.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
342
|
+
_atomic_write(mirror, text.encode("utf-8", "replace"))
|
|
343
|
+
self.sent[rel] = _sha(text)
|
|
344
|
+
self.answered += 1
|
|
345
|
+
return dict(
|
|
346
|
+
text=resp.get("text", ""),
|
|
347
|
+
cost_usd=resp.get("cost_usd"),
|
|
348
|
+
returncode=resp.get("returncode", 0),
|
|
349
|
+
error=resp.get("error"),
|
|
350
|
+
seconds=time.time() - t0,
|
|
351
|
+
**{x: resp[x] for x in ("usage", "session", "rollout") if x in resp},
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _give_up(self, k, workspace, t0, error, waited=0.0):
|
|
355
|
+
"""Stop waiting for request k (unavailable_timeout, or the mailbox timeout). It stays the newest request, so a
|
|
356
|
+
broker that comes back still answers it; late_answer() hands that answer over until the next request replaces
|
|
357
|
+
it."""
|
|
358
|
+
self.late = (k, workspace, t0)
|
|
359
|
+
out = dict(text="", cost_usd=None, returncode=-1, error=error, seconds=time.time() - t0 - waited, gave_up=True)
|
|
360
|
+
return dict(out, unavailable_seconds=round(waited, 1)) if waited else out
|
|
361
|
+
|
|
362
|
+
def late_answer(self):
|
|
363
|
+
"""The answer to the request this backend stopped waiting for, if it has come since and no newer request was
|
|
364
|
+
sent (its programs are then in the workspace), else None."""
|
|
365
|
+
if self.late is None:
|
|
366
|
+
return None
|
|
367
|
+
out = self._answer(*self.late)
|
|
368
|
+
if out is not None:
|
|
369
|
+
self.late = None
|
|
370
|
+
return out
|
|
371
|
+
|
|
372
|
+
def finish(self, interrupted=False):
|
|
373
|
+
"""`done` releases the broker; an interrupted run (stopped before its last update without close(), e.g. a
|
|
374
|
+
crash) leaves the broker waiting for the resumed attempt."""
|
|
375
|
+
self.finished = True
|
|
376
|
+
if self.run_id:
|
|
377
|
+
self._ensure_folder()
|
|
378
|
+
name = "interrupted" if interrupted else "done"
|
|
379
|
+
_atomic_write(self.folder / name, json.dumps(dict(attempt=self.attempt)).encode())
|
|
380
|
+
|
|
381
|
+
def cancel(self):
|
|
382
|
+
"""The run ended with this decision in flight: tell the broker to stop the agent."""
|
|
383
|
+
self.cancelled = True
|
|
384
|
+
if self.run_id and self.k and (self.folder / "RUN").exists():
|
|
385
|
+
_atomic_write(self.folder / f"cancel-{self.k:04d}.json", json.dumps(dict(attempt=self.attempt)).encode())
|
|
386
|
+
|
|
387
|
+
def state(self):
|
|
388
|
+
return dict(run_id=self.run_id, k=self.k, answered=self.answered)
|
|
389
|
+
|
|
390
|
+
def restore(self, state):
|
|
391
|
+
self.run_id = state.get("run_id") or self.run_id
|
|
392
|
+
self.k = state.get("k", 0)
|
|
393
|
+
self.answered = state.get("answered", self.k)
|
|
394
|
+
self.sent = {}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ----- agent side ---------------------------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _lease_file(path, action, body, expected, stale):
|
|
401
|
+
"""Serialize lease transitions on a persistent inode; the OS releases the lock after a crash.
|
|
402
|
+
|
|
403
|
+
Kept self-contained so the identical operation runs on the mailbox host over SSH.
|
|
404
|
+
A generation fences a resumed old broker's renewals, releases and response writes.
|
|
405
|
+
"""
|
|
406
|
+
import fcntl
|
|
407
|
+
import json
|
|
408
|
+
import os
|
|
409
|
+
import time
|
|
410
|
+
import uuid
|
|
411
|
+
from pathlib import Path
|
|
412
|
+
|
|
413
|
+
path = Path(path).expanduser()
|
|
414
|
+
fd = os.open(str(path) + ".lock", os.O_WRONLY | os.O_CREAT, 0o600)
|
|
415
|
+
with os.fdopen(fd, "w") as lock:
|
|
416
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
417
|
+
try:
|
|
418
|
+
old = json.loads(path.read_bytes())
|
|
419
|
+
except (FileNotFoundError, ValueError):
|
|
420
|
+
old = {}
|
|
421
|
+
|
|
422
|
+
def write(target, value):
|
|
423
|
+
tmp = target.with_name(target.name + "." + uuid.uuid4().hex + ".tmp")
|
|
424
|
+
try:
|
|
425
|
+
out = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
426
|
+
with os.fdopen(out, "wb") as stream:
|
|
427
|
+
stream.write(json.dumps(value).encode())
|
|
428
|
+
os.replace(tmp, target)
|
|
429
|
+
finally:
|
|
430
|
+
try:
|
|
431
|
+
tmp.unlink()
|
|
432
|
+
except FileNotFoundError:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
if action == "claim":
|
|
436
|
+
if (old and old.get("attempt") == body["attempt"] and old.get("broker") != body["broker"]
|
|
437
|
+
and time.time() - float(old.get("t", 0)) < stale):
|
|
438
|
+
return False
|
|
439
|
+
write(path, body)
|
|
440
|
+
return dict(previous=old)
|
|
441
|
+
if not old or old.get("generation") != expected.get("generation") or old.get("attempt") != expected.get("attempt"):
|
|
442
|
+
return False
|
|
443
|
+
if action == "renew":
|
|
444
|
+
write(path, dict(old, t=time.time()))
|
|
445
|
+
elif action == "release":
|
|
446
|
+
try:
|
|
447
|
+
path.unlink()
|
|
448
|
+
except FileNotFoundError:
|
|
449
|
+
pass
|
|
450
|
+
elif action == "answer":
|
|
451
|
+
write(path.with_name(path.name.replace("claim-", "response-", 1)), body)
|
|
452
|
+
try:
|
|
453
|
+
path.unlink()
|
|
454
|
+
except FileNotFoundError:
|
|
455
|
+
pass
|
|
456
|
+
else:
|
|
457
|
+
raise ValueError("unknown lease action")
|
|
458
|
+
return True
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class _Folder:
|
|
462
|
+
"""A folder on this machine, or on another one over ssh (one shared connection)."""
|
|
463
|
+
|
|
464
|
+
def __init__(self, path, ssh=None):
|
|
465
|
+
self.path, self.ssh = str(path).rstrip("/"), ssh
|
|
466
|
+
if ssh:
|
|
467
|
+
ctl = Path("~/.ssh").expanduser() / "opal-%r@%h-%p"
|
|
468
|
+
self.base = [
|
|
469
|
+
"ssh",
|
|
470
|
+
"-o",
|
|
471
|
+
"BatchMode=yes",
|
|
472
|
+
"-o",
|
|
473
|
+
"ControlMaster=auto",
|
|
474
|
+
"-o",
|
|
475
|
+
f"ControlPath={ctl}",
|
|
476
|
+
"-o",
|
|
477
|
+
"ControlPersist=600",
|
|
478
|
+
ssh,
|
|
479
|
+
]
|
|
480
|
+
|
|
481
|
+
def _q(self, name=""):
|
|
482
|
+
full = f"{self.path}/{name}" if name else self.path
|
|
483
|
+
if full.startswith("~/"):
|
|
484
|
+
return '"$HOME"/' + shlex.quote(full[2:])
|
|
485
|
+
return shlex.quote(full)
|
|
486
|
+
|
|
487
|
+
def _local(self, name=""):
|
|
488
|
+
return Path(self.path).expanduser() / name if name else Path(self.path).expanduser()
|
|
489
|
+
|
|
490
|
+
def _sh(self, cmd, data=None):
|
|
491
|
+
# without data, give ssh an empty stdin: it would otherwise read (and forward) the broker's own stdin
|
|
492
|
+
io = dict(input=data) if data is not None else dict(stdin=subprocess.DEVNULL)
|
|
493
|
+
return subprocess.run(self.base + [cmd], capture_output=True, timeout=120, **io)
|
|
494
|
+
|
|
495
|
+
def read(self, name):
|
|
496
|
+
if not self.ssh:
|
|
497
|
+
try:
|
|
498
|
+
return self._local(name).read_bytes()
|
|
499
|
+
except (FileNotFoundError, NotADirectoryError, IsADirectoryError):
|
|
500
|
+
return None # missing, or removed between a listing and this read
|
|
501
|
+
q = self._q(name)
|
|
502
|
+
p = self._sh(f"if test -f {q}; then cat {q}; else exit 3; fi") # 3: missing, whatever ssh prints on stderr
|
|
503
|
+
if p.returncode == 0:
|
|
504
|
+
return p.stdout
|
|
505
|
+
if p.returncode == 3:
|
|
506
|
+
return None
|
|
507
|
+
raise RuntimeError(f"ssh read failed ({p.returncode}): {p.stderr.decode()[-300:]}")
|
|
508
|
+
|
|
509
|
+
def write(self, name, data: bytes):
|
|
510
|
+
if not self.ssh:
|
|
511
|
+
_atomic_write(self._local(name), data)
|
|
512
|
+
return
|
|
513
|
+
q, t = self._q(name), self._q(f"{name}.{uuid.uuid4().hex[:12]}.tmp") # a temp name of this write only
|
|
514
|
+
p = self._sh(f"umask 077 && cat > {t} && mv {t} {q}", data)
|
|
515
|
+
if p.returncode != 0:
|
|
516
|
+
raise RuntimeError(f"ssh write failed ({p.returncode}): {p.stderr.decode()[-300:]}")
|
|
517
|
+
|
|
518
|
+
def create(self, name, data: bytes) -> bool:
|
|
519
|
+
"""Publish complete contents only if `name` does not exist (an exclusive hard link). False if it does."""
|
|
520
|
+
if not self.ssh:
|
|
521
|
+
path = self._local(name)
|
|
522
|
+
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
523
|
+
try:
|
|
524
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
525
|
+
with os.fdopen(fd, "wb") as f:
|
|
526
|
+
f.write(data)
|
|
527
|
+
try:
|
|
528
|
+
os.link(tmp, path)
|
|
529
|
+
except FileExistsError:
|
|
530
|
+
return False
|
|
531
|
+
return True
|
|
532
|
+
finally:
|
|
533
|
+
tmp.unlink(missing_ok=True)
|
|
534
|
+
temp_name = str(PurePosixPath(name).with_name(f".{PurePosixPath(name).name}.{uuid.uuid4().hex}.tmp"))
|
|
535
|
+
q, tmp = self._q(name), self._q(temp_name)
|
|
536
|
+
cleanup = shlex.quote(f"rm -f {tmp}")
|
|
537
|
+
p = self._sh(f"trap {cleanup} EXIT; umask 077; cat > {tmp} && test ! -e {q} && test ! -L {q} && ln {tmp} {q}", data)
|
|
538
|
+
if p.returncode == 0:
|
|
539
|
+
return True
|
|
540
|
+
probe = self._sh(f"test -e {self._q(name)}")
|
|
541
|
+
if probe.returncode == 0:
|
|
542
|
+
return False
|
|
543
|
+
raise RuntimeError(f"ssh create failed ({p.returncode}): {p.stderr.decode()[-300:]}")
|
|
544
|
+
|
|
545
|
+
def lease(self, name, action, body=None, expected=None):
|
|
546
|
+
if not self.ssh:
|
|
547
|
+
return _lease_file(self._local(name), action, body, expected, CLAIM_STALE)
|
|
548
|
+
# Python is already required on the training host. No remote OPAL install is needed.
|
|
549
|
+
script = inspect.getsource(_lease_file) + "\nimport json, sys\nprint(json.dumps(_lease_file(*json.load(sys.stdin))))"
|
|
550
|
+
args = json.dumps([f"{self.path}/{name}", action, body, expected, CLAIM_STALE])
|
|
551
|
+
p = self._sh("python3 -c " + shlex.quote(script), args.encode())
|
|
552
|
+
if p.returncode != 0:
|
|
553
|
+
raise RuntimeError(f"ssh lease failed ({p.returncode}): {p.stderr.decode()[-300:]}")
|
|
554
|
+
return json.loads(p.stdout)
|
|
555
|
+
|
|
556
|
+
def remove(self, name):
|
|
557
|
+
if not self.ssh:
|
|
558
|
+
self._local(name).unlink(missing_ok=True)
|
|
559
|
+
return
|
|
560
|
+
self._sh(f"rm -f {self._q(name)}")
|
|
561
|
+
|
|
562
|
+
def make(self):
|
|
563
|
+
"""Create the box (0700) if it does not exist yet: a broker may start before its job."""
|
|
564
|
+
if not self.ssh:
|
|
565
|
+
self._local().mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
566
|
+
else:
|
|
567
|
+
self._sh(f"mkdir -p -m 700 {self._q()}")
|
|
568
|
+
|
|
569
|
+
def runs(self):
|
|
570
|
+
"""Run subfolders, newest first."""
|
|
571
|
+
found = []
|
|
572
|
+
if not self.ssh:
|
|
573
|
+
root = self._local()
|
|
574
|
+
try:
|
|
575
|
+
entries = list(root.iterdir()) if root.is_dir() else []
|
|
576
|
+
except OSError:
|
|
577
|
+
entries = []
|
|
578
|
+
for d in entries:
|
|
579
|
+
try:
|
|
580
|
+
found.append(((d / "RUN").stat().st_mtime, d.name))
|
|
581
|
+
except OSError:
|
|
582
|
+
continue # not a run, or removed meanwhile
|
|
583
|
+
else:
|
|
584
|
+
p = self._sh(
|
|
585
|
+
f'cd {self._q()} 2>/dev/null || exit 3; for d in */; do test -f "$d/RUN" && '
|
|
586
|
+
f'echo "$(stat -c %Y "$d/RUN" 2>/dev/null || stat -f %m "$d/RUN") ${{d%/}}"; done; exit 0'
|
|
587
|
+
)
|
|
588
|
+
if p.returncode != 0:
|
|
589
|
+
raise RuntimeError(f"ssh listing failed ({p.returncode}): {p.stderr.decode()[-300:]}")
|
|
590
|
+
for line in p.stdout.decode().splitlines():
|
|
591
|
+
parts = line.split()
|
|
592
|
+
if len(parts) == 2:
|
|
593
|
+
found.append((float(parts[0]), parts[1]))
|
|
594
|
+
return [name for _, name in sorted(found, reverse=True)]
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
RETRY_SECONDS = 30 # between failed reads of the mailbox
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _log(msg):
|
|
601
|
+
print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True) # flushed: a broker's log is usually a file
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def broker_id(workdir) -> str:
|
|
605
|
+
"""This broker's name in claims: the host and its work folder, so a restarted broker knows its own claims."""
|
|
606
|
+
real = os.path.realpath(os.path.expanduser(str(workdir)))
|
|
607
|
+
return f"{socket.gethostname()}:{hashlib.sha1(real.encode()).hexdigest()[:8]}"
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def run_broker(box, backend, workdir, ssh=None, poll=2.0, run=None, log=_log, keep_serving=False, give_up_after=None):
|
|
611
|
+
"""Serve the runs of a mailbox. Without keep_serving: one run (the newest with a pending request, else the newest
|
|
612
|
+
unfinished one, or the one `run` names) until it ends with `done`; an interrupted run is waited for, but a broker
|
|
613
|
+
not pinned with `run` moves to another run that is waiting while its own one is idle. With keep_serving: forever,
|
|
614
|
+
taking the runs with pending requests in turn (the longest waiting first). Safe to restart at any point. If reading
|
|
615
|
+
the mailbox keeps failing for `give_up_after` seconds, exit with an error instead of retrying forever."""
|
|
616
|
+
backend.preflight()
|
|
617
|
+
note = backend.reproduction_note() if hasattr(backend, "reproduction_note") else None
|
|
618
|
+
if note: # another model or effort than the blog's (backends/claude.py)
|
|
619
|
+
log(note)
|
|
620
|
+
last = None
|
|
621
|
+
while True:
|
|
622
|
+
out = _serve_one(box, backend, workdir, ssh, poll, run, log, keep_serving, give_up_after, avoid=last)
|
|
623
|
+
run = None
|
|
624
|
+
if isinstance(out, tuple): # ("switch", the run it left): serve a run that waits
|
|
625
|
+
last = out[1]
|
|
626
|
+
continue
|
|
627
|
+
if not keep_serving:
|
|
628
|
+
return
|
|
629
|
+
last = None
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _json(raw):
|
|
633
|
+
try:
|
|
634
|
+
out = json.loads(raw) if raw else {}
|
|
635
|
+
except ValueError:
|
|
636
|
+
return {}
|
|
637
|
+
return out if isinstance(out, dict) else {}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _pending(folder):
|
|
641
|
+
"""(k, attempt, utc) of a run's newest request if it has no matching answer yet, else None."""
|
|
642
|
+
latest = folder.read("latest.json")
|
|
643
|
+
if latest is None:
|
|
644
|
+
return None
|
|
645
|
+
cur = json.loads(latest)
|
|
646
|
+
old = folder.read(f"response-{cur['k']:04d}.json")
|
|
647
|
+
if old is not None and json.loads(old).get("attempt") == cur["attempt"]:
|
|
648
|
+
return None
|
|
649
|
+
return cur["k"], cur["attempt"], cur.get("utc", 0.0)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _pick_run(root, ssh, avoid=None):
|
|
653
|
+
"""The run to serve: of the unfinished runs with a pending request the one waiting longest (another than `avoid`
|
|
654
|
+
if there is one), else the newest unfinished run."""
|
|
655
|
+
unfinished = [r for r in root.runs() if _Folder(f"{root.path}/{r}", ssh).read("done") is None]
|
|
656
|
+
waiting = []
|
|
657
|
+
for r in unfinished:
|
|
658
|
+
try:
|
|
659
|
+
p = _pending(_Folder(f"{root.path}/{r}", ssh))
|
|
660
|
+
except (ValueError, KeyError):
|
|
661
|
+
p = None
|
|
662
|
+
if p is not None:
|
|
663
|
+
waiting.append((r == avoid, p[2], r))
|
|
664
|
+
if waiting:
|
|
665
|
+
return min(waiting)[2]
|
|
666
|
+
return unfinished[0] if unfinished else None
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _serve_one(box, backend, workdir, ssh, poll, run, log, keep_serving=False, give_up_after=None, avoid=None):
|
|
670
|
+
root = _Folder(box, ssh)
|
|
671
|
+
root.make()
|
|
672
|
+
state = dict(run=run, stop=False, claim=None, me=broker_id(workdir))
|
|
673
|
+
|
|
674
|
+
def beat():
|
|
675
|
+
"""Heartbeat every 30 s from its own thread, so that it keeps coming while the agent thinks. A broker that
|
|
676
|
+
serves any run (waiting for one, or --keep-serving) also keeps the box-level one fresh, which every run of the
|
|
677
|
+
box accepts; a claimed request's claim is refreshed too."""
|
|
678
|
+
while not state["stop"]:
|
|
679
|
+
try:
|
|
680
|
+
cur = state["run"]
|
|
681
|
+
if cur is None or keep_serving:
|
|
682
|
+
body = dict(t=time.time(), run=None, serving=cur, keep_serving=keep_serving, broker=state["me"])
|
|
683
|
+
root.write("broker-alive", json.dumps(body).encode())
|
|
684
|
+
if cur is not None:
|
|
685
|
+
body = dict(t=time.time(), run=cur, broker=state["me"])
|
|
686
|
+
root.write(f"{cur}/broker-alive", json.dumps(body).encode())
|
|
687
|
+
claim = state["claim"]
|
|
688
|
+
if claim is not None:
|
|
689
|
+
if not root.lease(claim[0], "renew", expected=claim[1]) and state["claim"] is claim:
|
|
690
|
+
log("broker: lost request lease to another generation; stopping the agent")
|
|
691
|
+
backend.cancel()
|
|
692
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
693
|
+
log(f"broker: heartbeat failed: {exc}")
|
|
694
|
+
until = time.time() + BEAT_SECONDS
|
|
695
|
+
while time.time() < until:
|
|
696
|
+
if state["stop"]:
|
|
697
|
+
return
|
|
698
|
+
time.sleep(min(1.0, BEAT_SECONDS / 10))
|
|
699
|
+
|
|
700
|
+
threading.Thread(target=beat, name="opal-broker-heartbeat", daemon=True).start()
|
|
701
|
+
try:
|
|
702
|
+
return _serve(root, backend, workdir, ssh, poll, run, log, state, keep_serving, give_up_after, avoid)
|
|
703
|
+
finally:
|
|
704
|
+
state["stop"] = True
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _claim(folder, k, attempt, me, log):
|
|
708
|
+
"""Atomically acquire a fresh generation, including stale and previous-attempt takeovers."""
|
|
709
|
+
name = f"claim-{k:04d}.json"
|
|
710
|
+
body = dict(broker=me, attempt=attempt, t=time.time(), generation=uuid.uuid4().hex)
|
|
711
|
+
won = folder.lease(name, "claim", body=body)
|
|
712
|
+
if won:
|
|
713
|
+
folder._held = {name: body}
|
|
714
|
+
old = won["previous"]
|
|
715
|
+
if old:
|
|
716
|
+
log(f"broker: taking over request {k} from broker {old.get('broker')}, which stopped answering")
|
|
717
|
+
elif (k, attempt) != getattr(folder, "_backed_off", None):
|
|
718
|
+
folder._backed_off = (k, attempt)
|
|
719
|
+
log(f"broker: request {k} lease belongs to another broker; backing off")
|
|
720
|
+
return bool(won)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _timeout_for(backend, req, log):
|
|
724
|
+
"""Run the agent for at most what it was told (the request's limit), less the time the request waited and a
|
|
725
|
+
margin to write the answer. A broker --timeout shorter than that is kept, and said once."""
|
|
726
|
+
limit = req.get("limit")
|
|
727
|
+
if not limit or not hasattr(backend, "timeout"):
|
|
728
|
+
return None
|
|
729
|
+
if not hasattr(backend, "_own_timeout"):
|
|
730
|
+
backend._own_timeout = backend.timeout if getattr(backend, "timeout_given", True) else None
|
|
731
|
+
# the time the request waited, less the time the run had no broker (which it does not count either)
|
|
732
|
+
absent = float(req.get("absent") or 0.0)
|
|
733
|
+
waited = min(max(0.0, time.time() - float(req.get("utc") or time.time()) - absent), float(limit))
|
|
734
|
+
room = max(60.0, float(limit) - waited - ANSWER_MARGIN)
|
|
735
|
+
own = backend._own_timeout
|
|
736
|
+
if own is None:
|
|
737
|
+
return room
|
|
738
|
+
if own < room and not getattr(backend, "_said_short", False):
|
|
739
|
+
backend._said_short = True
|
|
740
|
+
log(
|
|
741
|
+
f"broker: the agent was told a decision may take up to {int(limit)} s; this broker stops it after "
|
|
742
|
+
f"{int(own)} s (its --timeout), and each message tells the agent so"
|
|
743
|
+
)
|
|
744
|
+
return min(own, room)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _limit_note(req, timeout) -> str:
|
|
748
|
+
"""The first line of a message whose agent this broker stops well before the limit the run told it (a shorter
|
|
749
|
+
--timeout, or a request that waited long for a broker): the limit that holds for this decision, so the agent is
|
|
750
|
+
never stopped earlier than it was told. Empty when the run's limit holds (less the minute kept to write the
|
|
751
|
+
answer)."""
|
|
752
|
+
limit = req.get("limit")
|
|
753
|
+
if not limit or timeout is None or timeout >= float(limit) - 2 * ANSWER_MARGIN:
|
|
754
|
+
return ""
|
|
755
|
+
s = int(timeout)
|
|
756
|
+
s -= s % 60 if s >= 120 else 0 # whole minutes
|
|
757
|
+
stated = "Each decision may take up to" in (req.get("system") or "") # the neutral prompts say it
|
|
758
|
+
return f"(This decision may take up to {duration(s)}{', less than your instructions state' if stated else ''}.)\n\n"
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _sync_workspace(folder, ws, req, log):
|
|
762
|
+
"""Bring the broker's workspace to the run's: the request's files, every manifest file the workspace lacks (from
|
|
763
|
+
<run>/files/), and no inbox/ or reference/ file the run does not have."""
|
|
764
|
+
k = req.get("k")
|
|
765
|
+
for p in ws.glob("*"):
|
|
766
|
+
if _PROGRAM.fullmatch(p.name) and not p.is_symlink():
|
|
767
|
+
p.unlink()
|
|
768
|
+
for rel, text in (req.get("files") or {}).items():
|
|
769
|
+
if not _safe_rel(rel) or not _write_in(ws, rel, text):
|
|
770
|
+
log(f"broker: ignored unsafe path in request {k}: {rel!r}")
|
|
771
|
+
manifest = req.get("manifest")
|
|
772
|
+
if manifest is None: # a training side older than the manifest
|
|
773
|
+
return
|
|
774
|
+
have = {}
|
|
775
|
+
for rel, p in _files(ws, _safe_rel).items():
|
|
776
|
+
try:
|
|
777
|
+
have[rel] = _sha(p.read_text(errors="replace"))
|
|
778
|
+
except OSError:
|
|
779
|
+
continue
|
|
780
|
+
fetched = 0
|
|
781
|
+
for rel, sha in manifest.items():
|
|
782
|
+
if have.get(rel) == sha or not _safe_rel(rel):
|
|
783
|
+
continue
|
|
784
|
+
raw = folder.read(f"files/{rel}")
|
|
785
|
+
text = raw.decode("utf-8", "replace") if raw is not None else None
|
|
786
|
+
if text is not None and _sha(text) == sha and _write_in(ws, rel, text):
|
|
787
|
+
fetched += 1
|
|
788
|
+
else:
|
|
789
|
+
log(f"broker: request {k}: could not fetch {rel} from the run's files/")
|
|
790
|
+
stale = [rel for rel in have if PurePosixPath(rel).parts[0] in ("inbox", "reference") and rel not in manifest]
|
|
791
|
+
for rel in stale:
|
|
792
|
+
(ws / rel).unlink(missing_ok=True)
|
|
793
|
+
if fetched or stale:
|
|
794
|
+
log(f"broker: request {k}: fetched {fetched} files the workspace lacked, removed {len(stale)} the run has not")
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _serve(root, backend, workdir, ssh, poll, run, log, beat_state, keep_serving, give_up_after=None, avoid=None):
|
|
798
|
+
box = root.path
|
|
799
|
+
pinned = run is not None
|
|
800
|
+
waiting_logged = False
|
|
801
|
+
failing_since = None
|
|
802
|
+
while run is None:
|
|
803
|
+
try:
|
|
804
|
+
run = _pick_run(root, ssh, avoid)
|
|
805
|
+
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
|
806
|
+
log(f"broker: could not list the runs of the mailbox: {exc}")
|
|
807
|
+
failing_since = time.time() if failing_since is None else failing_since
|
|
808
|
+
if give_up_after and time.time() - failing_since > give_up_after:
|
|
809
|
+
raise SystemExit(f"broker: the mailbox has been unreadable; giving up ({exc})") from None
|
|
810
|
+
time.sleep(RETRY_SECONDS)
|
|
811
|
+
continue
|
|
812
|
+
failing_since = None
|
|
813
|
+
if run is None:
|
|
814
|
+
if not waiting_logged:
|
|
815
|
+
log(f"broker: waiting for a run in {ssh + ':' if ssh else ''}{box}")
|
|
816
|
+
waiting_logged = True
|
|
817
|
+
time.sleep(poll)
|
|
818
|
+
beat_state["run"] = run
|
|
819
|
+
folder = _Folder(f"{root.path}/{run}", ssh)
|
|
820
|
+
work = Path(workdir).expanduser() / run
|
|
821
|
+
ws = work / "workspace"
|
|
822
|
+
ws.mkdir(parents=True, exist_ok=True)
|
|
823
|
+
state_f = work / "broker-state.json"
|
|
824
|
+
saved = json.loads(state_f.read_text()) if state_f.exists() else dict(backend={})
|
|
825
|
+
fresh = not saved.get("backend")
|
|
826
|
+
backend.restore(saved.get("backend", {}))
|
|
827
|
+
backend.setup(work, ws)
|
|
828
|
+
log(f"broker: serving run {run} in {ssh + ':' if ssh else ''}{box}")
|
|
829
|
+
idle_since = time.time()
|
|
830
|
+
failures, failing_since = 0, None
|
|
831
|
+
unsent = None # (k, attempt, response bytes) of an answer whose write to the mailbox failed
|
|
832
|
+
while True:
|
|
833
|
+
if unsent is not None:
|
|
834
|
+
try:
|
|
835
|
+
if not folder.lease(f"claim-{unsent[0]:04d}.json", "answer", body=_json(unsent[2]), expected=unsent[3]):
|
|
836
|
+
log(f"broker: request {unsent[0]} lease was lost; discarded the late answer")
|
|
837
|
+
else:
|
|
838
|
+
log(f"broker: wrote the answer to request {unsent[0]} after a failed write")
|
|
839
|
+
unsent = None
|
|
840
|
+
except (RuntimeError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as exc:
|
|
841
|
+
log(f"broker: could not retry the answer to request {unsent[0]} ({exc}); still checking new requests")
|
|
842
|
+
try:
|
|
843
|
+
pending = _pending(folder)
|
|
844
|
+
done = folder.read("done")
|
|
845
|
+
except (RuntimeError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as exc:
|
|
846
|
+
failures += 1
|
|
847
|
+
failing_since = failing_since or time.time()
|
|
848
|
+
minutes = (time.time() - failing_since) / 60
|
|
849
|
+
if failures == 1 or failures % 10 == 0:
|
|
850
|
+
log(f"broker: {failures} failed reads of the mailbox in a row ({minutes:.0f} min); last: {exc}")
|
|
851
|
+
if give_up_after and time.time() - failing_since > give_up_after:
|
|
852
|
+
raise SystemExit(
|
|
853
|
+
f"broker: the mailbox has been unreadable for {minutes:.0f} min; giving up ({exc})"
|
|
854
|
+
) from None
|
|
855
|
+
time.sleep(RETRY_SECONDS)
|
|
856
|
+
continue
|
|
857
|
+
if failures:
|
|
858
|
+
log(f"broker: the mailbox is readable again after {failures} failed reads")
|
|
859
|
+
failures, failing_since = 0, None
|
|
860
|
+
if pending is None:
|
|
861
|
+
if done is not None:
|
|
862
|
+
log("broker: run finished")
|
|
863
|
+
return None
|
|
864
|
+
if (keep_serving or not pinned) and time.time() - idle_since > IDLE_SWITCH:
|
|
865
|
+
if _other_waiting(root, ssh, run, log):
|
|
866
|
+
log(f"broker: run {run} is idle; switching to a run with a pending request")
|
|
867
|
+
return ("switch", run)
|
|
868
|
+
idle_since = time.time()
|
|
869
|
+
time.sleep(poll)
|
|
870
|
+
continue
|
|
871
|
+
k, attempt, _ = pending
|
|
872
|
+
if done is not None and _json(done).get("attempt") == attempt:
|
|
873
|
+
log("broker: run finished")
|
|
874
|
+
return None
|
|
875
|
+
if unsent is not None and (k, attempt) == unsent[:2]:
|
|
876
|
+
time.sleep(poll)
|
|
877
|
+
continue
|
|
878
|
+
try:
|
|
879
|
+
answered = _answer_request(folder, backend, work, ws, run, k, attempt, fresh, beat_state, log)
|
|
880
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
881
|
+
claim = beat_state.get("claim")
|
|
882
|
+
beat_state["claim"] = None
|
|
883
|
+
if claim is not None:
|
|
884
|
+
try:
|
|
885
|
+
folder.lease(PurePosixPath(claim[0]).name, "release", expected=claim[1])
|
|
886
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired):
|
|
887
|
+
pass
|
|
888
|
+
log(f"broker: request {k}: the mailbox failed ({exc}); trying again")
|
|
889
|
+
time.sleep(RETRY_SECONDS)
|
|
890
|
+
continue
|
|
891
|
+
if answered is None: # cancelled, claimed by another broker, or being rewritten: look again shortly
|
|
892
|
+
time.sleep(poll)
|
|
893
|
+
if keep_serving and unsent is None and _other_waiting(root, ssh, run, log):
|
|
894
|
+
return ("switch", run)
|
|
895
|
+
continue
|
|
896
|
+
fresh = False
|
|
897
|
+
resp, payload = answered
|
|
898
|
+
held = beat_state["claim"][1]
|
|
899
|
+
beat_state["claim"] = None
|
|
900
|
+
state_f.write_text(json.dumps(dict(backend=backend.state()), indent=1))
|
|
901
|
+
try:
|
|
902
|
+
if not folder.lease(f"claim-{k:04d}.json", "answer", body=resp, expected=held):
|
|
903
|
+
log(f"broker: request {k} lease was lost; discarded the answer")
|
|
904
|
+
continue
|
|
905
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
906
|
+
log(f"broker: could not write the answer to request {k} ({exc}); trying again")
|
|
907
|
+
unsent = (k, attempt, payload, held)
|
|
908
|
+
idle_since = time.time()
|
|
909
|
+
failed = resp.get("error") or resp.get("returncode") not in (0, None)
|
|
910
|
+
why = f": {error_tail(resp.get('error') or 'no error text')}" if failed else ""
|
|
911
|
+
secs, code = resp.get("seconds") or 0, resp.get("returncode")
|
|
912
|
+
log(f"broker: answered request {k} in {secs:.0f} s, return code {code}{why}")
|
|
913
|
+
if keep_serving and unsent is None and _other_waiting(root, ssh, run, log):
|
|
914
|
+
return ("switch", run) # take the runs that wait in turn
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _other_waiting(root, ssh, run, log) -> bool:
|
|
918
|
+
try:
|
|
919
|
+
other = _pick_run(root, ssh, avoid=run)
|
|
920
|
+
return other is not None and other != run and _pending(_Folder(f"{root.path}/{other}", ssh)) is not None
|
|
921
|
+
except (RuntimeError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as exc:
|
|
922
|
+
log(f"broker: could not look at the other runs: {exc}")
|
|
923
|
+
return False
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _answer_request(folder, backend, work, ws, run, k, attempt, fresh, beat_state, log):
|
|
927
|
+
"""Serve request k: claim it, sync the workspace, run the agent, collect its answer. (response, bytes), or None
|
|
928
|
+
if it is not ours to answer now. Errors of the agent become an error answer; mailbox errors propagate."""
|
|
929
|
+
if _json(folder.read(f"cancel-{k:04d}.json")).get("attempt") == attempt:
|
|
930
|
+
return None # the run ended with this request in flight; it will not read an answer
|
|
931
|
+
req = _json(folder.read(f"request-{k:04d}.json"))
|
|
932
|
+
if req.get("attempt") != attempt:
|
|
933
|
+
return None # latest.json and the request are being rewritten; read again
|
|
934
|
+
if not _claim(folder, k, attempt, beat_state["me"], log):
|
|
935
|
+
if not beat_state.get(f"said-{k}"):
|
|
936
|
+
beat_state[f"said-{k}"] = True
|
|
937
|
+
log(f"broker: request {k} is being answered by another broker")
|
|
938
|
+
return None
|
|
939
|
+
held = folder._held[f"claim-{k:04d}.json"]
|
|
940
|
+
beat_state["claim"] = (f"{run}/claim-{k:04d}.json", held)
|
|
941
|
+
if _json(folder.read(f"response-{k:04d}.json")).get("attempt") == attempt:
|
|
942
|
+
folder.lease(f"claim-{k:04d}.json", "release", expected=held) # another broker answered it and let go of its claim before ours
|
|
943
|
+
beat_state["claim"] = None
|
|
944
|
+
return None
|
|
945
|
+
folder.remove(f"unavailable-{k:04d}.json") # left by a broker that stopped while its agent was unavailable
|
|
946
|
+
req["absent"] = _json(folder.read(f"absent-{k:04d}.json")).get("seconds", 0.0) # the run had no broker this long
|
|
947
|
+
_sync_workspace(folder, ws, req, log)
|
|
948
|
+
known = dict(req.get("manifest") or {})
|
|
949
|
+
rec = work / "decisions" / f"{k:03d}-{attempt}"
|
|
950
|
+
rec.mkdir(parents=True, exist_ok=True)
|
|
951
|
+
message = req.get("message") or ""
|
|
952
|
+
note = getattr(backend, "lost_note", None)
|
|
953
|
+
if fresh and req.get("answered") and note is not None:
|
|
954
|
+
message = note(ws) + message # this broker has no conversation of the run's agent: say so
|
|
955
|
+
timeout = _timeout_for(backend, req, log)
|
|
956
|
+
if timeout is not None:
|
|
957
|
+
backend.timeout = timeout
|
|
958
|
+
message = _limit_note(req, timeout) + message # stopped earlier than the run said: the agent is told
|
|
959
|
+
if not folder.lease(f"claim-{k:04d}.json", "renew", expected=held):
|
|
960
|
+
beat_state["claim"] = None
|
|
961
|
+
log(f"broker: request {k} lease was lost before starting the agent; backing off")
|
|
962
|
+
return None
|
|
963
|
+
(rec / "message.txt").write_text(message)
|
|
964
|
+
watching = dict(on=True)
|
|
965
|
+
backend.log = lambda text, k=k: log(f"broker: request {k}: {redact_secrets(text)}")
|
|
966
|
+
|
|
967
|
+
def watch(k=k, attempt=attempt, watching=watching):
|
|
968
|
+
"""Stop the agent if the run ends or cancels this request while the agent works."""
|
|
969
|
+
while watching["on"]:
|
|
970
|
+
try:
|
|
971
|
+
if (_json(folder.read(f"claim-{k:04d}.json")).get("generation") != held["generation"]
|
|
972
|
+
and watching["on"]):
|
|
973
|
+
log(f"broker: request {k} lease was lost; stopping the agent")
|
|
974
|
+
backend.cancel()
|
|
975
|
+
return
|
|
976
|
+
c = folder.read(f"cancel-{k:04d}.json")
|
|
977
|
+
d = folder.read("done")
|
|
978
|
+
if (c and _json(c).get("attempt") == attempt) or d is not None:
|
|
979
|
+
log(f"broker: request {k} was cancelled by the run; stopping the agent")
|
|
980
|
+
backend.cancel()
|
|
981
|
+
return
|
|
982
|
+
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired):
|
|
983
|
+
pass
|
|
984
|
+
for _ in range(5):
|
|
985
|
+
if not watching["on"]:
|
|
986
|
+
return
|
|
987
|
+
time.sleep(1)
|
|
988
|
+
|
|
989
|
+
threading.Thread(target=watch, name="opal-broker-cancel-watch", daemon=True).start()
|
|
990
|
+
|
|
991
|
+
def unavailable_at_run(reason, k=k, attempt=attempt):
|
|
992
|
+
"""While the agent is unavailable, the run sees why (unavailable-NNNN.json), so it waits too, and says so."""
|
|
993
|
+
try:
|
|
994
|
+
if reason is None:
|
|
995
|
+
folder.remove(f"unavailable-{k:04d}.json")
|
|
996
|
+
else:
|
|
997
|
+
kind = (unavailable.classify([reason]) or ("down", reason))[0]
|
|
998
|
+
body = dict(attempt=attempt, kind=kind, reason=redact_secrets(reason), t=time.time())
|
|
999
|
+
folder.write(f"unavailable-{k:04d}.json", json.dumps(body).encode())
|
|
1000
|
+
except (RuntimeError, OSError, subprocess.TimeoutExpired) as exc:
|
|
1001
|
+
log(f"broker: request {k}: could not tell the run that the agent is unavailable ({exc})")
|
|
1002
|
+
|
|
1003
|
+
if hasattr(backend, "on_unavailable"):
|
|
1004
|
+
backend.on_unavailable = unavailable_at_run
|
|
1005
|
+
backend.budget_left = req.get("budget_left")
|
|
1006
|
+
try:
|
|
1007
|
+
out = backend.decide(ws, message, req.get("system"), rec)
|
|
1008
|
+
except Exception: # an agent backend's failure is this request's answer, not the broker's end
|
|
1009
|
+
out = dict(text="", cost_usd=None, returncode=-1, error=traceback.format_exc(limit=4)[-2000:], seconds=0.0)
|
|
1010
|
+
finally:
|
|
1011
|
+
watching["on"] = False
|
|
1012
|
+
out = dict(out)
|
|
1013
|
+
for field in ("error", "text"):
|
|
1014
|
+
if out.get(field) is not None:
|
|
1015
|
+
out[field] = redact_secrets(out[field])
|
|
1016
|
+
backend.cancelled = False # the backend serves later requests
|
|
1017
|
+
files = {}
|
|
1018
|
+
for p in sorted(ws.glob("*")):
|
|
1019
|
+
if _PROGRAM.fullmatch(p.name) and p.is_file() and not p.is_symlink():
|
|
1020
|
+
files[p.name] = p.read_text(errors="replace")
|
|
1021
|
+
resp = dict(
|
|
1022
|
+
run_id=req["run_id"],
|
|
1023
|
+
attempt=attempt,
|
|
1024
|
+
k=k,
|
|
1025
|
+
files=files,
|
|
1026
|
+
agent_files=_agent_files(ws, known, log),
|
|
1027
|
+
broker=beat_state["me"],
|
|
1028
|
+
agent_timeout=getattr(backend, "timeout", None),
|
|
1029
|
+
**{x: out.get(x) for x in ("text", "cost_usd", "returncode", "error", "seconds")},
|
|
1030
|
+
**{x: out[x] for x in ("usage", "session", "rollout") if x in out},
|
|
1031
|
+
**({"unavailable_seconds": out["unavailable_seconds"]} if out.get("unavailable_seconds") else {}),
|
|
1032
|
+
)
|
|
1033
|
+
(rec / "response.json").write_text(json.dumps(resp, indent=1))
|
|
1034
|
+
return resp, json.dumps(resp).encode()
|