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.
Files changed (49) hide show
  1. opal/__init__.py +7 -0
  2. opal/_version.py +3 -0
  3. opal/agent.py +394 -0
  4. opal/backends/__init__.py +31 -0
  5. opal/backends/_codex_client.py +450 -0
  6. opal/backends/_privacy.py +116 -0
  7. opal/backends/base.py +248 -0
  8. opal/backends/claude.py +665 -0
  9. opal/backends/codex.py +356 -0
  10. opal/backends/mailbox.py +1034 -0
  11. opal/backends/unavailable.py +161 -0
  12. opal/cli.py +396 -0
  13. opal/context.py +111 -0
  14. opal/defaults.json +11 -0
  15. opal/defaults.py +54 -0
  16. opal/estimate.py +120 -0
  17. opal/hf.py +109 -0
  18. opal/misc/__init__.py +2 -0
  19. opal/misc/aggressive.py +157 -0
  20. opal/misc/audit.py +84 -0
  21. opal/misc/boundary.py +226 -0
  22. opal/misc/checkpoint.py +399 -0
  23. opal/misc/ddp.py +147 -0
  24. opal/misc/integrity.py +263 -0
  25. opal/misc/nested.py +67 -0
  26. opal/misc/neutral_analysis.py +166 -0
  27. opal/misc/precision.py +210 -0
  28. opal/misc/recovery.py +231 -0
  29. opal/misc/schedulers.py +214 -0
  30. opal/misc/setup.py +253 -0
  31. opal/misc/skip.py +165 -0
  32. opal/misc/taskfacts.py +293 -0
  33. opal/misc/waiting.py +58 -0
  34. opal/programs.py +482 -0
  35. opal/prompts/example_updater.py +252 -0
  36. opal/prompts/neutral.md +206 -0
  37. opal/report.py +1001 -0
  38. opal/report_page.py +175 -0
  39. opal/report_privacy.py +159 -0
  40. opal/results.py +80 -0
  41. opal/schedule.py +134 -0
  42. opal/summaries.py +224 -0
  43. opal/wrapper.py +1674 -0
  44. opal_optimizer-0.3.5.dist-info/METADATA +232 -0
  45. opal_optimizer-0.3.5.dist-info/RECORD +49 -0
  46. opal_optimizer-0.3.5.dist-info/WHEEL +5 -0
  47. opal_optimizer-0.3.5.dist-info/entry_points.txt +2 -0
  48. opal_optimizer-0.3.5.dist-info/licenses/LICENSE +21 -0
  49. opal_optimizer-0.3.5.dist-info/top_level.txt +1 -0
@@ -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()