raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..budgets import check_before_delegate
|
|
12
|
+
from ..core import clock, db
|
|
13
|
+
from ..core import identity as I
|
|
14
|
+
from ..core import model as model_mod
|
|
15
|
+
from ..core import transcript as T
|
|
16
|
+
from ..core.brief import UNTRUSTED_RULE, quote_data
|
|
17
|
+
from ..core.errors import Denied, IdentityError, Precondition, RaidlockError
|
|
18
|
+
from ..core.identity import RUNTIME_SHORT
|
|
19
|
+
from ..core.lead_env import assert_worker_env, scrub_worker_env
|
|
20
|
+
from ..core.store import atomic_write_text
|
|
21
|
+
from ..lore import capture, mark_used
|
|
22
|
+
from ..scan import scan_patch
|
|
23
|
+
from . import brief, isolation, policy
|
|
24
|
+
from . import workspace as W
|
|
25
|
+
from .base import Collector, Process, Redactor, short
|
|
26
|
+
from .claude_code import ClaudeCode
|
|
27
|
+
from .codex import Codex
|
|
28
|
+
from .gemini import Gemini
|
|
29
|
+
from .shell import Shell
|
|
30
|
+
|
|
31
|
+
ADAPTERS = {"codex": Codex, "claude": ClaudeCode, "gemini": Gemini, "shell": Shell}
|
|
32
|
+
ROLES = ("worker", "reviewer")
|
|
33
|
+
SANDBOXES = ("read-only", "workspace-write")
|
|
34
|
+
MAX_RELAYS = 40
|
|
35
|
+
VERIFY_TIMEOUT = 900
|
|
36
|
+
NOTE_CAP = 1800
|
|
37
|
+
MODEL_ID = re.compile(r"^[A-Za-z0-9._:/@-]{1,100}$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Run:
|
|
41
|
+
def __init__(self, **fields):
|
|
42
|
+
self.__dict__.update(fields)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def agent_name(adapter, role, card_id):
|
|
46
|
+
return f"{RUNTIME_SHORT[adapter.runtime]}-{role}-{card_id}"[:40].rstrip("-")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def runs_dir(board, card_id):
|
|
50
|
+
return os.path.join(board.home, "runs", board.name, card_id)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _unknown_option(path):
|
|
54
|
+
try:
|
|
55
|
+
with open(path, encoding="utf-8", errors="replace") as stream:
|
|
56
|
+
return "unknown option" in stream.read()[-4000:].lower()
|
|
57
|
+
except OSError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def prune_runs(board, card_id):
|
|
62
|
+
"""Delete the clones and agent homes of a closed card's runs; logs, patches, run records and an opted-in
|
|
63
|
+
quarantine stay."""
|
|
64
|
+
root = runs_dir(board, card_id)
|
|
65
|
+
removed = 0
|
|
66
|
+
for run in sorted(os.listdir(root)) if os.path.isdir(root) and not os.path.islink(root) else []:
|
|
67
|
+
path = os.path.join(root, run)
|
|
68
|
+
if os.path.islink(path) or not os.path.isdir(path):
|
|
69
|
+
continue
|
|
70
|
+
if os.path.exists(os.path.join(path, "started.json")) and not os.path.exists(os.path.join(path, "run.json")):
|
|
71
|
+
continue
|
|
72
|
+
for name in sorted(os.listdir(path)):
|
|
73
|
+
child = os.path.join(path, name)
|
|
74
|
+
if name != "quarantine" and (os.path.isdir(child) or os.path.islink(child)):
|
|
75
|
+
W._remove(child)
|
|
76
|
+
removed += 1
|
|
77
|
+
return removed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_numbers(board, card_id):
|
|
81
|
+
root = runs_dir(board, card_id)
|
|
82
|
+
names = os.listdir(root) if os.path.isdir(root) else []
|
|
83
|
+
return sorted(int(n) for n in names if n.isdigit())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def latest_patch(board, card_id):
|
|
87
|
+
"""(run dir, BASE) of the newest worker run that produced an accepted changes.patch, else (None, None)."""
|
|
88
|
+
for n in reversed(run_numbers(board, card_id)):
|
|
89
|
+
folder = os.path.join(runs_dir(board, card_id), str(n))
|
|
90
|
+
try:
|
|
91
|
+
with open(os.path.join(folder, "run.json"), encoding="utf-8") as f:
|
|
92
|
+
info = json.load(f)
|
|
93
|
+
except (OSError, ValueError):
|
|
94
|
+
continue
|
|
95
|
+
if info.get("role") == "worker" and os.path.exists(os.path.join(folder, "changes.patch")):
|
|
96
|
+
return folder, info.get("base") or ""
|
|
97
|
+
return None, None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Relay:
|
|
101
|
+
"""Summarized transcript records (agent messages, command lines), appended in batches under the board lock."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, board, agent, card_id, limit=MAX_RELAYS):
|
|
104
|
+
self.board, self.agent, self.card, self.limit = board, agent, card_id, limit
|
|
105
|
+
self.pending, self.sent, self.dropped = [], 0, 0
|
|
106
|
+
self._lock = threading.Lock()
|
|
107
|
+
|
|
108
|
+
def add(self, event):
|
|
109
|
+
kind = event.get("kind")
|
|
110
|
+
if kind == "message" and event.get("text") and not event.get("delta"):
|
|
111
|
+
text, data = short(event["text"]), {"event": "message"}
|
|
112
|
+
elif kind == "command":
|
|
113
|
+
code = event.get("exit")
|
|
114
|
+
text = "$ " + event.get("command", "") + (f" (exit {code})" if code is not None else "")
|
|
115
|
+
data = {"event": "command", "exit": code}
|
|
116
|
+
else:
|
|
117
|
+
return
|
|
118
|
+
with self._lock:
|
|
119
|
+
if self.sent + len(self.pending) >= self.limit:
|
|
120
|
+
self.dropped += 1
|
|
121
|
+
return
|
|
122
|
+
self.pending.append(
|
|
123
|
+
T.make(
|
|
124
|
+
"relay",
|
|
125
|
+
self.board._from(self.agent),
|
|
126
|
+
text=text,
|
|
127
|
+
task=self.card,
|
|
128
|
+
context=f"{self.board.name}/{self.card}",
|
|
129
|
+
src="runner",
|
|
130
|
+
data=data,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def flush(self):
|
|
135
|
+
with self._lock:
|
|
136
|
+
rows, self.pending = self.pending, []
|
|
137
|
+
if not rows:
|
|
138
|
+
return
|
|
139
|
+
with self.board.lock():
|
|
140
|
+
for row in rows:
|
|
141
|
+
self.board.transcript.append(row)
|
|
142
|
+
self.sent += len(rows)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Heartbeat(threading.Thread):
|
|
146
|
+
"""Renews the lease on a timer and flushes relays; stops the agent if the lease is lost."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, board, card_id, caller, interval, relay, on_lost):
|
|
149
|
+
super().__init__(daemon=True)
|
|
150
|
+
self.board, self.card, self.caller, self.interval = board, card_id, caller, interval
|
|
151
|
+
self.relay, self.on_lost = relay, on_lost
|
|
152
|
+
self.done = threading.Event()
|
|
153
|
+
self.lost = ""
|
|
154
|
+
self.beats = 0
|
|
155
|
+
|
|
156
|
+
def run(self):
|
|
157
|
+
with db.released():
|
|
158
|
+
self._beat()
|
|
159
|
+
|
|
160
|
+
def _beat(self):
|
|
161
|
+
while not self.done.wait(self.interval):
|
|
162
|
+
with contextlib.suppress(RaidlockError):
|
|
163
|
+
self.relay.flush()
|
|
164
|
+
if self.caller is None:
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
self.board.heartbeat(self.card, self.caller)
|
|
168
|
+
self.beats += 1
|
|
169
|
+
except (Denied, IdentityError) as e:
|
|
170
|
+
self.lost = e.message
|
|
171
|
+
self.on_lost("lease lost: " + e.message)
|
|
172
|
+
return
|
|
173
|
+
except RaidlockError:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
def stop(self):
|
|
177
|
+
self.done.set()
|
|
178
|
+
if self.is_alive():
|
|
179
|
+
self.join(timeout=30)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def preflight(board, card, name):
|
|
183
|
+
"""Refuse early, before joining or cloning, when the card cannot be claimed for `name`."""
|
|
184
|
+
cid, lane = card["id"], card["lane"]
|
|
185
|
+
if lane in ("done", "review"):
|
|
186
|
+
raise Precondition(
|
|
187
|
+
f"{cid} is in {lane}; a worker cannot take it",
|
|
188
|
+
card=cid,
|
|
189
|
+
hint=f"raidlock delegate {cid} --to <runtime> --as reviewer" if lane == "review" else "",
|
|
190
|
+
)
|
|
191
|
+
if card["owner"] and card["owner"] != name and lane != "todo":
|
|
192
|
+
raise Denied(
|
|
193
|
+
f"{cid} is owned by {card['owner']}",
|
|
194
|
+
card=cid,
|
|
195
|
+
owner=card["owner"],
|
|
196
|
+
hint=f"raidlock release {cid} (lead), then delegate again",
|
|
197
|
+
)
|
|
198
|
+
info = board.claimable().get(cid)
|
|
199
|
+
if info is None:
|
|
200
|
+
raise Denied(f"{cid} is not claimable now", card=cid, hint="raidlock claimable")
|
|
201
|
+
if not info["ok"]:
|
|
202
|
+
raise Precondition(
|
|
203
|
+
f"cannot delegate {cid}: " + "; ".join(info["reasons"]),
|
|
204
|
+
card=cid,
|
|
205
|
+
reasons=info["reasons"],
|
|
206
|
+
hint="raidlock claimable",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _review_preflight(board, card):
|
|
211
|
+
cid = card["id"]
|
|
212
|
+
if card["lane"] != "review":
|
|
213
|
+
raise Precondition(f"{cid} is in {card['lane']}, not review", card=cid)
|
|
214
|
+
checkout = ((board.leases().get(cid) or {}).get("checkout") or {}).get("path")
|
|
215
|
+
if not latest_patch(board, cid)[0] and not (checkout and os.path.isdir(checkout)):
|
|
216
|
+
raise Precondition(f"nothing to review for {cid}: no runner patch and no recorded checkout", card=cid)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def prepare(board, card_id):
|
|
220
|
+
"""A fresh numbered run folder: $RAIDLOCK_HOME/runs/<workspace>/<card>/<n>/ (outside the workspace)."""
|
|
221
|
+
numbers = run_numbers(board, card_id)
|
|
222
|
+
path = os.path.join(runs_dir(board, card_id), str((numbers[-1] if numbers else 0) + 1))
|
|
223
|
+
os.makedirs(path, mode=0o700)
|
|
224
|
+
return path
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _note(text):
|
|
228
|
+
text = " ".join(str(text).split())
|
|
229
|
+
return text if len(text) <= NOTE_CAP else text[: NOTE_CAP - 3] + "..."
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _rel(board, path):
|
|
233
|
+
home = os.path.expanduser("~")
|
|
234
|
+
return "~" + path[len(home) :] if path.startswith(home + os.sep) else path
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _stored_token(board, name):
|
|
238
|
+
try:
|
|
239
|
+
with open(I.token_path(board.home, board.uid, name), encoding="utf-8") as f:
|
|
240
|
+
return f.read().strip()
|
|
241
|
+
except OSError:
|
|
242
|
+
return ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _write_json(path, data, redactor=None):
|
|
246
|
+
data = json.loads(json.dumps(data, ensure_ascii=False, default=str))
|
|
247
|
+
data = (redactor or Redactor())(data)
|
|
248
|
+
atomic_write_text(path, json.dumps(data, indent=2, sort_keys=True, ensure_ascii=False, default=str) + "\n")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _scrub_file(path, redactor):
|
|
252
|
+
try:
|
|
253
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
254
|
+
text = f.read()
|
|
255
|
+
except OSError:
|
|
256
|
+
return
|
|
257
|
+
clean = redactor.text(text)
|
|
258
|
+
if clean != text:
|
|
259
|
+
atomic_write_text(path, clean)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def delegate(
|
|
263
|
+
board,
|
|
264
|
+
lead,
|
|
265
|
+
card_id,
|
|
266
|
+
to,
|
|
267
|
+
role="worker",
|
|
268
|
+
sandbox=None,
|
|
269
|
+
model=None,
|
|
270
|
+
cmd=None,
|
|
271
|
+
timeout=3600.0,
|
|
272
|
+
heartbeat=60.0,
|
|
273
|
+
env=None,
|
|
274
|
+
tools=None,
|
|
275
|
+
unsafe_verify=False,
|
|
276
|
+
raw=False,
|
|
277
|
+
agent=None,
|
|
278
|
+
resume=None,
|
|
279
|
+
ref=None,
|
|
280
|
+
):
|
|
281
|
+
"""Run one card through an external CLI as `<runtime>-<role>-<card>` (or `agent`) and hand it back. `resume` is a
|
|
282
|
+
resume packet whose validated patch is applied to the fresh clone and whose text joins the prompt as quoted data.
|
|
283
|
+
Returns a result dict."""
|
|
284
|
+
env = dict(os.environ if env is None else env)
|
|
285
|
+
if not lead.is_lead:
|
|
286
|
+
raise IdentityError(
|
|
287
|
+
"delegate runs as the lead: it joins and claims on the agent's behalf",
|
|
288
|
+
agent=lead.name,
|
|
289
|
+
hint="run raidlock delegate from the lead's session",
|
|
290
|
+
)
|
|
291
|
+
if to not in ADAPTERS:
|
|
292
|
+
raise Precondition(f"--to must be one of {', '.join(ADAPTERS)}", to=to)
|
|
293
|
+
if role not in ROLES:
|
|
294
|
+
raise Precondition(f"--as must be one of {', '.join(ROLES)}", role=role)
|
|
295
|
+
sandbox = sandbox or ("read-only" if role == "reviewer" else "workspace-write")
|
|
296
|
+
if sandbox not in SANDBOXES:
|
|
297
|
+
raise Precondition(f"--sandbox must be one of {', '.join(SANDBOXES)}", sandbox=sandbox)
|
|
298
|
+
if role == "reviewer" and sandbox != "read-only":
|
|
299
|
+
raise Precondition("reviewers run read-only", sandbox=sandbox)
|
|
300
|
+
if model and not MODEL_ID.match(model):
|
|
301
|
+
raise Precondition(f"invalid model id {model!r}", model=model)
|
|
302
|
+
if not math.isfinite(float(timeout)) or float(timeout) <= 0:
|
|
303
|
+
raise Precondition("worker timeout must be a positive finite number of seconds")
|
|
304
|
+
adapter = ADAPTERS[to]()
|
|
305
|
+
card = board.card(card_id)
|
|
306
|
+
budget = check_before_delegate(board, card)
|
|
307
|
+
if not budget["allowed"]:
|
|
308
|
+
raise Precondition("delegation budget exhausted: " + "; ".join(budget["reasons"]), card=card_id)
|
|
309
|
+
target = policy.target(board.workspace, board.dir, card["locks"])
|
|
310
|
+
name = agent_name(adapter, role, card["id"]) if agent is None else model_mod.agent_name(agent)
|
|
311
|
+
if adapter.name == "shell":
|
|
312
|
+
adapter.command(Run(cmd=cmd, parent_env=env))
|
|
313
|
+
if tools is not None:
|
|
314
|
+
if adapter.name != "claude":
|
|
315
|
+
raise Precondition("--tools applies to --to claude only", to=to)
|
|
316
|
+
tools = adapter.check_tools(tools, sandbox)
|
|
317
|
+
if role == "worker":
|
|
318
|
+
preflight(board, card, name)
|
|
319
|
+
else:
|
|
320
|
+
_review_preflight(board, card)
|
|
321
|
+
run_dir = prepare(board, card["id"])
|
|
322
|
+
run = Run(
|
|
323
|
+
card=card,
|
|
324
|
+
role=role,
|
|
325
|
+
agent=name,
|
|
326
|
+
timeout=float(timeout),
|
|
327
|
+
heartbeat=float(heartbeat),
|
|
328
|
+
sandbox=sandbox,
|
|
329
|
+
model=model or "",
|
|
330
|
+
cmd=cmd,
|
|
331
|
+
run_dir=run_dir,
|
|
332
|
+
tools=tools,
|
|
333
|
+
clone=os.path.join(run_dir, "repo"),
|
|
334
|
+
schema=brief.schema(role),
|
|
335
|
+
parent_env=env,
|
|
336
|
+
rels=target["rels"],
|
|
337
|
+
ci=target["ci"],
|
|
338
|
+
repo=target["repo"],
|
|
339
|
+
run_id=uuid.uuid4().hex[:12],
|
|
340
|
+
schema_path=os.path.join(run_dir, "schema.json"),
|
|
341
|
+
last_path=os.path.join(run_dir, "last.md"),
|
|
342
|
+
prompt_path=os.path.join(run_dir, "prompt.md"),
|
|
343
|
+
events_path=os.path.join(run_dir, "events.jsonl"),
|
|
344
|
+
stderr_path=os.path.join(run_dir, "stderr.log"),
|
|
345
|
+
unsafe_verify=bool(unsafe_verify),
|
|
346
|
+
raw=bool(raw),
|
|
347
|
+
resume=resume,
|
|
348
|
+
ref=ref,
|
|
349
|
+
)
|
|
350
|
+
joined = board.join(
|
|
351
|
+
lead,
|
|
352
|
+
agent=name,
|
|
353
|
+
team=(card.get("team") or None) if role == "worker" else None,
|
|
354
|
+
role=role,
|
|
355
|
+
runtime=adapter.runtime,
|
|
356
|
+
provider=adapter.provider,
|
|
357
|
+
flag_model=model,
|
|
358
|
+
session=run.run_id,
|
|
359
|
+
card=card["id"],
|
|
360
|
+
)
|
|
361
|
+
run.token = joined["token"]
|
|
362
|
+
from ..resume import _secrets
|
|
363
|
+
|
|
364
|
+
run.redactor = Redactor.for_env(env, [run.token, *_secrets(board, lead, card)])
|
|
365
|
+
run.billing = joined.get("billing") or "unknown"
|
|
366
|
+
if role == "worker":
|
|
367
|
+
return _work(board, lead, adapter, run, target)
|
|
368
|
+
if name not in (card.get("reviewers") or []):
|
|
369
|
+
board.assign_reviewers(card["id"], lead, list(card.get("reviewers") or []) + [name])
|
|
370
|
+
return _review(board, adapter, run, target)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _start(board, adapter, run, rels_base):
|
|
374
|
+
_write_json(run.schema_path, run.schema)
|
|
375
|
+
capture(board)
|
|
376
|
+
run.brief = board.brief(run.card["id"])
|
|
377
|
+
prompt = brief.render(run.brief["text"], run.role, run.rels, rels_base)
|
|
378
|
+
if run.card.get("lock_exclusions"):
|
|
379
|
+
prompt += "\nExcluded lock paths (do not change): " + ", ".join(run.card["lock_exclusions"]) + "\n"
|
|
380
|
+
if getattr(run, "resume", None):
|
|
381
|
+
prompt += _resume_section(run.resume)
|
|
382
|
+
atomic_write_text(run.prompt_path, prompt)
|
|
383
|
+
run.prompt = prompt
|
|
384
|
+
child_env = adapter.child_env(run.parent_env)
|
|
385
|
+
child_env.update(adapter.prepare(run))
|
|
386
|
+
child_env = scrub_worker_env(child_env)
|
|
387
|
+
child_env["RAIDLOCK_AGENT_ID"] = run.agent
|
|
388
|
+
if getattr(run, "cap", None):
|
|
389
|
+
child_env["RAIDLOCK_CAP"] = run.cap
|
|
390
|
+
else:
|
|
391
|
+
child_env["RAIDLOCK_TOKEN"] = run.token
|
|
392
|
+
assert_worker_env(child_env)
|
|
393
|
+
run.argv = adapter.command(run)
|
|
394
|
+
return child_env
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _execute(board, adapter, run, caller, child_env, timeout, interval):
|
|
398
|
+
collected = Collector()
|
|
399
|
+
relay = Relay(board, run.agent, run.card["id"])
|
|
400
|
+
proc = Process(
|
|
401
|
+
run.argv,
|
|
402
|
+
run.clone,
|
|
403
|
+
child_env,
|
|
404
|
+
run.prompt,
|
|
405
|
+
run.events_path,
|
|
406
|
+
run.stderr_path,
|
|
407
|
+
timeout,
|
|
408
|
+
redactor=run.redactor,
|
|
409
|
+
raw=run.raw,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def on_event(event):
|
|
413
|
+
collected.add(event)
|
|
414
|
+
relay.add(event)
|
|
415
|
+
|
|
416
|
+
beat = Heartbeat(board, run.card["id"], caller, interval, relay, proc.stop)
|
|
417
|
+
beat.start()
|
|
418
|
+
start = time.monotonic()
|
|
419
|
+
try:
|
|
420
|
+
code, killed = proc.run(adapter, on_event)
|
|
421
|
+
finally:
|
|
422
|
+
beat.stop()
|
|
423
|
+
with contextlib.suppress(RaidlockError):
|
|
424
|
+
relay.flush()
|
|
425
|
+
run.duration_ms = int((time.monotonic() - start) * 1000)
|
|
426
|
+
if proc.proc is not None and run.brief.get("lore_ids"):
|
|
427
|
+
mark_used(board, run.brief.get("lore_ids", []))
|
|
428
|
+
_scrub_file(run.last_path, run.redactor)
|
|
429
|
+
run.exit, run.killed, run.lost = code, killed, beat.lost
|
|
430
|
+
run.relays = {"sent": relay.sent, "dropped": relay.dropped}
|
|
431
|
+
if collected.billing:
|
|
432
|
+
run.billing = collected.billing
|
|
433
|
+
if killed:
|
|
434
|
+
run.failure = killed
|
|
435
|
+
elif code != 0:
|
|
436
|
+
run.failure = f"agent exited {code}" + (f": {collected.error}" if collected.error else "")
|
|
437
|
+
if _unknown_option(run.stderr_path):
|
|
438
|
+
run.failure += f"; the {adapter.binary} CLI rejected an option, so it is likely too old: update it"
|
|
439
|
+
elif collected.error:
|
|
440
|
+
run.failure = "agent reported an error: " + collected.error
|
|
441
|
+
else:
|
|
442
|
+
run.failure = ""
|
|
443
|
+
return collected
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _cost(board, caller, run, collected):
|
|
447
|
+
if not collected.usage.total and collected.usd is None:
|
|
448
|
+
return None
|
|
449
|
+
return board.record_cost(
|
|
450
|
+
run.card["id"],
|
|
451
|
+
caller,
|
|
452
|
+
model_id=collected.model or run.model or None,
|
|
453
|
+
usage=collected.usage,
|
|
454
|
+
usd=collected.usd,
|
|
455
|
+
duration_ms=run.duration_ms,
|
|
456
|
+
billing=run.billing,
|
|
457
|
+
)["entry"]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _work(board, lead, adapter, run, target):
|
|
461
|
+
cid = run.card["id"]
|
|
462
|
+
run.source_dirty = W.dirty(target["path"])
|
|
463
|
+
run.base, run.branch = W.clone(target["path"], run.clone, ref=run.ref)
|
|
464
|
+
run.source = target["path"]
|
|
465
|
+
if run.resume:
|
|
466
|
+
with board.lock():
|
|
467
|
+
current = board.card(cid)
|
|
468
|
+
target = policy.target(board.workspace, board.dir, current["locks"])
|
|
469
|
+
run.rels, run.ci = target["rels"], target["ci"]
|
|
470
|
+
_apply_resume(run)
|
|
471
|
+
_write_json(
|
|
472
|
+
os.path.join(run.run_dir, "started.json"),
|
|
473
|
+
{
|
|
474
|
+
"run_id": run.run_id,
|
|
475
|
+
"card": cid,
|
|
476
|
+
"agent": run.agent,
|
|
477
|
+
"role": "worker",
|
|
478
|
+
"base": run.base,
|
|
479
|
+
"branch": run.branch,
|
|
480
|
+
},
|
|
481
|
+
)
|
|
482
|
+
config = W.snapshot_config(run.clone)
|
|
483
|
+
board.trust_checkout(lead, cid, run.clone, target["path"])
|
|
484
|
+
grant = board.claim(cid, lead, for_agent=run.agent, checkout=run.clone, runner_base=run.base)
|
|
485
|
+
run.cap = grant["cap"]
|
|
486
|
+
run.redactor = Redactor([*run.redactor.secrets, run.cap])
|
|
487
|
+
worker = board.caller(agent=run.agent, cap=run.cap, fence=grant["fence"], env={})
|
|
488
|
+
try:
|
|
489
|
+
child_env = _start(board, adapter, run, run.base)
|
|
490
|
+
board.note(
|
|
491
|
+
cid,
|
|
492
|
+
worker,
|
|
493
|
+
_note(
|
|
494
|
+
f"runner: {adapter.runtime} · {adapter.provider} · model {run.model or 'default'} · "
|
|
495
|
+
f"billing {run.billing} · sandbox {run.sandbox} · clone "
|
|
496
|
+
f"{_rel(board, run.clone)} at {run.base[:12]}"
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
collected = _execute(board, adapter, run, worker, child_env, run.timeout, run.heartbeat)
|
|
500
|
+
except BaseException as e:
|
|
501
|
+
_abandon(board, worker, cid, f"runner error before the agent finished: {e}")
|
|
502
|
+
raise
|
|
503
|
+
if run.lost:
|
|
504
|
+
_write_json(
|
|
505
|
+
os.path.join(run.run_dir, "run.json"), _summary(run, adapter, None, None, None, None, []), run.redactor
|
|
506
|
+
)
|
|
507
|
+
return {
|
|
508
|
+
"card": cid,
|
|
509
|
+
"agent": run.agent,
|
|
510
|
+
"lane": board.card(cid)["lane"],
|
|
511
|
+
"status": "lost",
|
|
512
|
+
"reason": run.lost,
|
|
513
|
+
"run_dir": run.run_dir,
|
|
514
|
+
"_exit": 2,
|
|
515
|
+
}
|
|
516
|
+
try:
|
|
517
|
+
return _finish(board, lead, adapter, run, worker, collected, config)
|
|
518
|
+
except BaseException as e:
|
|
519
|
+
_abandon(board, worker, cid, f"runner error after the agent finished: {e}")
|
|
520
|
+
raise
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _resume_section(packet):
|
|
524
|
+
lines = (packet.get("text") or "").splitlines()
|
|
525
|
+
return (
|
|
526
|
+
"\n## Resume packet (an earlier attempt on this card stopped; its validated work is already applied)\n"
|
|
527
|
+
+ UNTRUSTED_RULE
|
|
528
|
+
+ "\n"
|
|
529
|
+
+ "\n".join(quote_data(line) for line in lines)
|
|
530
|
+
+ "\n"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _apply_resume(run):
|
|
535
|
+
"""Put the interrupted attempt's validated patch into the fresh clone, uncommitted, at its recorded BASE."""
|
|
536
|
+
patch = run.resume.get("patch") or ""
|
|
537
|
+
if not patch:
|
|
538
|
+
return
|
|
539
|
+
if not os.path.isfile(patch) or os.path.islink(patch):
|
|
540
|
+
raise Precondition("the resume patch is missing", card=run.card["id"])
|
|
541
|
+
with open(patch, "rb") as f:
|
|
542
|
+
data = f.read()
|
|
543
|
+
from ..resume import validate_patch
|
|
544
|
+
|
|
545
|
+
_, problems = validate_patch(data, run.rels, run.ci, run.redactor)
|
|
546
|
+
if problems:
|
|
547
|
+
raise Precondition("the resume patch failed validation; it is never applied", card=run.card["id"])
|
|
548
|
+
base = run.resume.get("base") or ""
|
|
549
|
+
if base and base != run.base:
|
|
550
|
+
W.git(run.clone, "checkout", "--quiet", "--detach", base)
|
|
551
|
+
run.base = base
|
|
552
|
+
W.git(run.clone, "apply", "--whitespace=nowarn", "-", binary=True, input=data)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _abandon(board, worker, cid, text):
|
|
556
|
+
try:
|
|
557
|
+
if board.card(cid)["lane"] == "doing":
|
|
558
|
+
board.move(cid, "blocked", worker, note=_note(text))
|
|
559
|
+
except RaidlockError:
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _dispose_sensitive(run, patch_path, store):
|
|
564
|
+
artifacts = [patch_path, store, run.clone]
|
|
565
|
+
if run.raw:
|
|
566
|
+
quarantine = os.path.join(run.run_dir, "quarantine")
|
|
567
|
+
os.mkdir(quarantine, 0o700)
|
|
568
|
+
os.chmod(quarantine, 0o700)
|
|
569
|
+
for artifact in artifacts:
|
|
570
|
+
os.replace(artifact, os.path.join(quarantine, os.path.basename(artifact)))
|
|
571
|
+
else:
|
|
572
|
+
for artifact in artifacts:
|
|
573
|
+
W._remove(artifact)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _finish(board, lead, adapter, run, worker, collected, config):
|
|
577
|
+
cid = run.card["id"]
|
|
578
|
+
W.restore(run.clone, config)
|
|
579
|
+
store = os.path.join(run.run_dir, "lead.git")
|
|
580
|
+
paths, tree = W.audit(run.source, run.base, run.clone, store)
|
|
581
|
+
current = board.card(cid)
|
|
582
|
+
target = policy.target(board.workspace, board.dir, current["locks"])
|
|
583
|
+
found = policy.check(paths, target["rels"], run.ci, current.get("allow_protected", ()))
|
|
584
|
+
excluded = []
|
|
585
|
+
if current.get("lock_exclusions"):
|
|
586
|
+
excluded = policy.target(board.workspace, board.dir, current["lock_exclusions"])["rels"]
|
|
587
|
+
found["outside"] = sorted(set(found["outside"]) | {p for p in paths if policy.contained(p, excluded, run.ci)})
|
|
588
|
+
from ..scan import clone_links
|
|
589
|
+
|
|
590
|
+
found["denied"] = sorted(
|
|
591
|
+
set(found["denied"])
|
|
592
|
+
| set(
|
|
593
|
+
policy.check_links(
|
|
594
|
+
clone_links(Path(run.clone)),
|
|
595
|
+
target["rels"],
|
|
596
|
+
run.ci,
|
|
597
|
+
paths=paths,
|
|
598
|
+
allow_protected=current.get("allow_protected", ()),
|
|
599
|
+
excluded=excluded,
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
)
|
|
603
|
+
violation = bool(found["outside"] or found["denied"])
|
|
604
|
+
patch_path = os.path.join(run.run_dir, "rejected.patch" if violation else "changes.patch")
|
|
605
|
+
patch_bytes = W.write_patch(store, run.base, tree, patch_path) if paths else 0
|
|
606
|
+
findings, scan_error = [], ""
|
|
607
|
+
if paths:
|
|
608
|
+
try:
|
|
609
|
+
with open(patch_path, "rb") as patch:
|
|
610
|
+
findings = scan_patch(
|
|
611
|
+
patch.read(), rels=target["rels"], ci=run.ci, allow_protected=current.get("allow_protected", ())
|
|
612
|
+
)
|
|
613
|
+
except RaidlockError as exc:
|
|
614
|
+
scan_error = exc.message
|
|
615
|
+
if findings or scan_error:
|
|
616
|
+
violation = True
|
|
617
|
+
_dispose_sensitive(run, patch_path, store)
|
|
618
|
+
findings = run.redactor(findings)
|
|
619
|
+
patch_path, patch_bytes = "", 0
|
|
620
|
+
found["scan"] = findings
|
|
621
|
+
if not paths and os.path.exists(patch_path):
|
|
622
|
+
os.remove(patch_path)
|
|
623
|
+
verify, skipped = None, ""
|
|
624
|
+
if run.card.get("verify_cmd") and not violation and not run.failure:
|
|
625
|
+
verify, skipped = _verify(board, lead, run)
|
|
626
|
+
agent_hb = brief.handback(adapter.final(run, collected), fallback_text=collected.final)
|
|
627
|
+
lane, reason = "review", ""
|
|
628
|
+
if run.failure:
|
|
629
|
+
lane, reason = "blocked", run.failure
|
|
630
|
+
elif violation:
|
|
631
|
+
parts = []
|
|
632
|
+
if found["outside"]:
|
|
633
|
+
parts.append("outside the card's locks: " + ", ".join(found["outside"][:20]))
|
|
634
|
+
if found["denied"]:
|
|
635
|
+
parts.append("denied paths: " + ", ".join(found["denied"][:20]))
|
|
636
|
+
if findings:
|
|
637
|
+
parts.append("patch safety findings: " + ", ".join(sorted({item["rule"] for item in findings})))
|
|
638
|
+
if scan_error:
|
|
639
|
+
parts.append("patch safety scan failed: " + scan_error)
|
|
640
|
+
lane, reason = "blocked", "changes " + "; ".join(parts)
|
|
641
|
+
elif verify and not verify["passed"]:
|
|
642
|
+
kind = verify["isolation"] if verify["isolation"] in ("sandbox-exec", "bwrap") else None
|
|
643
|
+
detail = isolation.failure_reason(verify["exit"], kind)
|
|
644
|
+
lane, reason = "blocked", detail or f"verify failed (exit {verify['exit']}): {verify['cmd']}"
|
|
645
|
+
elif agent_hb["status"] == "blocked":
|
|
646
|
+
lane, reason = "blocked", "the agent reported blocked"
|
|
647
|
+
evidence = list(agent_hb["evidence"])
|
|
648
|
+
evidence.append(
|
|
649
|
+
f"runner: {len(paths)} path(s) changed against {run.base[:12]}"
|
|
650
|
+
+ (f"; patch {_rel(board, patch_path)} ({patch_bytes} bytes)" if patch_path and paths else "")
|
|
651
|
+
)
|
|
652
|
+
if verify:
|
|
653
|
+
evidence.append(
|
|
654
|
+
f"runner verify: `{verify['cmd']}` exit {verify['exit']} in {verify['duration_ms']} ms"
|
|
655
|
+
f" ({verify['isolation']})"
|
|
656
|
+
)
|
|
657
|
+
elif skipped:
|
|
658
|
+
evidence.append("runner verify: not run, " + skipped)
|
|
659
|
+
elif run.card.get("verify"):
|
|
660
|
+
evidence.append("runner verify: no verify command on the card; check by hand: " + run.card["verify"][:200])
|
|
661
|
+
status = agent_hb["status"] if lane == "review" else "blocked"
|
|
662
|
+
questions = list(agent_hb["open_questions"]) + ([reason] if reason else [])
|
|
663
|
+
handback = brief.cap({"status": status, "evidence": evidence, "paths_changed": paths, "open_questions": questions})
|
|
664
|
+
_write_json(os.path.join(run.run_dir, "handback.json"), handback, run.redactor)
|
|
665
|
+
cost = _cost(board, worker, run, collected)
|
|
666
|
+
if verify:
|
|
667
|
+
with board.lock():
|
|
668
|
+
board.transcript.append(
|
|
669
|
+
T.make(
|
|
670
|
+
"artifact",
|
|
671
|
+
board._from(run.agent),
|
|
672
|
+
task=cid,
|
|
673
|
+
context=f"{board.name}/{cid}",
|
|
674
|
+
text=f"runner verify {'passed' if verify['passed'] else 'failed'}: {verify['cmd']}",
|
|
675
|
+
src="runner",
|
|
676
|
+
artifacts=[
|
|
677
|
+
T.artifact(
|
|
678
|
+
"runner-verify",
|
|
679
|
+
verify["tail"],
|
|
680
|
+
data={"exit": verify["exit"], "passed": verify["passed"]},
|
|
681
|
+
kind="verify",
|
|
682
|
+
)
|
|
683
|
+
],
|
|
684
|
+
)
|
|
685
|
+
)
|
|
686
|
+
patch_meta = {
|
|
687
|
+
"path": _rel(board, patch_path) if patch_path and paths else "",
|
|
688
|
+
"bytes": patch_bytes,
|
|
689
|
+
"base": run.base,
|
|
690
|
+
"files": len(paths),
|
|
691
|
+
"rejected": violation,
|
|
692
|
+
}
|
|
693
|
+
with board.lock():
|
|
694
|
+
board.transcript.append(
|
|
695
|
+
T.make(
|
|
696
|
+
"handback",
|
|
697
|
+
board._from(run.agent),
|
|
698
|
+
task=cid,
|
|
699
|
+
context=f"{board.name}/{cid}",
|
|
700
|
+
src="runner",
|
|
701
|
+
text=f"{status}: {len(paths)} path(s) changed" + (f"; {reason}" if reason else ""),
|
|
702
|
+
artifacts=[
|
|
703
|
+
T.artifact("handback", data=handback, kind="handback"),
|
|
704
|
+
T.artifact("rejected.patch" if violation else "changes.patch", data=patch_meta, kind="patch"),
|
|
705
|
+
],
|
|
706
|
+
data={
|
|
707
|
+
"billing": run.billing,
|
|
708
|
+
"sandbox": run.sandbox,
|
|
709
|
+
"runtime": adapter.runtime,
|
|
710
|
+
"model": collected.model or run.model,
|
|
711
|
+
"usd": collected.usd,
|
|
712
|
+
},
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
summary = f"{len(paths)} path(s) changed" + (
|
|
716
|
+
f", verify {'passed' if verify['passed'] else 'failed'}" if verify else ""
|
|
717
|
+
)
|
|
718
|
+
patch_note = f"; patch {_rel(board, patch_path)}" if patch_path and paths else ""
|
|
719
|
+
board.move(cid, lane, worker, note=_note(reason or summary + patch_note))
|
|
720
|
+
capture(board)
|
|
721
|
+
_write_json(
|
|
722
|
+
os.path.join(run.run_dir, "run.json"),
|
|
723
|
+
_summary(
|
|
724
|
+
run,
|
|
725
|
+
adapter,
|
|
726
|
+
collected,
|
|
727
|
+
found,
|
|
728
|
+
verify,
|
|
729
|
+
cost,
|
|
730
|
+
paths,
|
|
731
|
+
lane=lane,
|
|
732
|
+
reason=reason,
|
|
733
|
+
patch=patch_meta,
|
|
734
|
+
verify_skipped=skipped,
|
|
735
|
+
),
|
|
736
|
+
run.redactor,
|
|
737
|
+
)
|
|
738
|
+
return {
|
|
739
|
+
"card": cid,
|
|
740
|
+
"agent": run.agent,
|
|
741
|
+
"role": "worker",
|
|
742
|
+
"runtime": adapter.runtime,
|
|
743
|
+
"model": collected.model or run.model,
|
|
744
|
+
"billing": run.billing,
|
|
745
|
+
"lane": lane,
|
|
746
|
+
"status": status,
|
|
747
|
+
"reason": reason,
|
|
748
|
+
"paths_changed": paths,
|
|
749
|
+
"outside": found["outside"],
|
|
750
|
+
"denied": found["denied"],
|
|
751
|
+
"patch": patch_path if paths else "",
|
|
752
|
+
"verify": verify,
|
|
753
|
+
"verify_skipped": skipped,
|
|
754
|
+
"usage": collected.usage.as_dict(),
|
|
755
|
+
"usd": collected.usd,
|
|
756
|
+
"run_dir": run.run_dir,
|
|
757
|
+
"handback": handback,
|
|
758
|
+
"_exit": 0 if lane == "review" else 2,
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _verify(board, lead, run):
|
|
763
|
+
"""Run the card's verify command in the worker clone without the lead's env, HOME or board state, inside a
|
|
764
|
+
sandbox when the host has one. Without one it runs only with unsafe_verify. Returns (verify, skipped)."""
|
|
765
|
+
with board.lead_write(lead, "start runner verification"):
|
|
766
|
+
pass
|
|
767
|
+
kind = isolation.mode()
|
|
768
|
+
if kind is None and not run.unsafe_verify:
|
|
769
|
+
return None, (
|
|
770
|
+
f"no sandbox on this host for automatic verify; run `raidlock verify {run.card['id']} "
|
|
771
|
+
f"--cwd {_rel(board, run.clone)}` by hand or delegate with --unsafe-verify"
|
|
772
|
+
)
|
|
773
|
+
cmd = run.card["verify_cmd"]
|
|
774
|
+
deny = isolation.hidden(run.parent_env, [board.home, board.dir])
|
|
775
|
+
code, tail, ms = isolation.run(
|
|
776
|
+
cmd,
|
|
777
|
+
run.clone,
|
|
778
|
+
run.parent_env,
|
|
779
|
+
VERIFY_TIMEOUT,
|
|
780
|
+
kind=kind,
|
|
781
|
+
agent=run.agent,
|
|
782
|
+
deny_read=deny,
|
|
783
|
+
redact=run.redactor.text,
|
|
784
|
+
)
|
|
785
|
+
label = kind or "unsafe: scrubbed env, no sandbox"
|
|
786
|
+
rec = {
|
|
787
|
+
"ts": clock.iso(),
|
|
788
|
+
"by": lead.name,
|
|
789
|
+
"cmd": cmd,
|
|
790
|
+
"cwd": run.clone,
|
|
791
|
+
"exit": code,
|
|
792
|
+
"passed": code == 0,
|
|
793
|
+
"tail": tail,
|
|
794
|
+
"duration_ms": ms,
|
|
795
|
+
"rev": run.card.get("rev", 0),
|
|
796
|
+
"isolation": label,
|
|
797
|
+
}
|
|
798
|
+
with board.lead_write(lead, "record runner verification"):
|
|
799
|
+
card = board.card(run.card["id"])
|
|
800
|
+
card.setdefault("verifications", []).append(rec)
|
|
801
|
+
board._note(card, lead.name, f"verify {'passed' if rec['passed'] else 'failed'} (exit {code}; {label})")
|
|
802
|
+
board._save_card(card)
|
|
803
|
+
board._record(
|
|
804
|
+
"verify",
|
|
805
|
+
lead.name,
|
|
806
|
+
f"verify {'passed' if rec['passed'] else 'failed'}: {cmd}",
|
|
807
|
+
task=card["id"],
|
|
808
|
+
artifacts=[T.artifact("verify", tail, data={"exit": code, "passed": rec["passed"]}, kind="verify")],
|
|
809
|
+
)
|
|
810
|
+
return {k: rec[k] for k in ("cmd", "exit", "passed", "tail", "duration_ms", "isolation")}, ""
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _summary(run, adapter, collected, found, verify, cost, paths, **extra):
|
|
814
|
+
return {
|
|
815
|
+
"run_id": run.run_id,
|
|
816
|
+
"card": run.card["id"],
|
|
817
|
+
"agent": run.agent,
|
|
818
|
+
"role": run.role,
|
|
819
|
+
"runtime": adapter.runtime,
|
|
820
|
+
"provider": adapter.provider,
|
|
821
|
+
"model": (collected.model if collected else "") or run.model,
|
|
822
|
+
"billing": run.billing,
|
|
823
|
+
"sandbox": run.sandbox,
|
|
824
|
+
"repo": run.repo,
|
|
825
|
+
"base": run.base,
|
|
826
|
+
"branch": getattr(run, "branch", ""),
|
|
827
|
+
"source_dirty": getattr(run, "source_dirty", False),
|
|
828
|
+
"argv": run.argv,
|
|
829
|
+
"brief_tokens": run.brief["tokens"],
|
|
830
|
+
"exit": run.exit,
|
|
831
|
+
"failure": run.failure,
|
|
832
|
+
"lost": run.lost,
|
|
833
|
+
"lore_tokens": run.brief.get("lore_tokens", 0),
|
|
834
|
+
"duration_ms": run.duration_ms,
|
|
835
|
+
"relays": run.relays,
|
|
836
|
+
"events": collected.summary() if collected else None,
|
|
837
|
+
"paths": paths,
|
|
838
|
+
"check": found,
|
|
839
|
+
"verify": verify,
|
|
840
|
+
"cost": cost,
|
|
841
|
+
"finished": clock.iso(),
|
|
842
|
+
**extra,
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _review(board, adapter, run, target):
|
|
847
|
+
cid = run.card["id"]
|
|
848
|
+
reviewer = board.caller(agent=run.agent, token=run.token, env={})
|
|
849
|
+
worker_dir, worker_base = latest_patch(board, cid)
|
|
850
|
+
run.base, run.branch = W.clone(target["path"], run.clone, ref=run.ref)
|
|
851
|
+
run.reviewed = "patch" if worker_dir else "checkout"
|
|
852
|
+
if run.reviewed == "patch":
|
|
853
|
+
patch = os.path.join(worker_dir, "changes.patch")
|
|
854
|
+
run.patch_from = worker_dir
|
|
855
|
+
if worker_base and worker_base != run.base:
|
|
856
|
+
W.git(run.clone, "checkout", "--quiet", "--detach", worker_base)
|
|
857
|
+
run.base = worker_base
|
|
858
|
+
W.apply_and_commit(run.clone, patch)
|
|
859
|
+
else:
|
|
860
|
+
checkout = ((board.leases().get(cid) or {}).get("checkout") or {}).get("path")
|
|
861
|
+
if checkout and os.path.isdir(checkout):
|
|
862
|
+
src_base = run.base
|
|
863
|
+
run.base, run.branch = W.clone(checkout, run.clone)
|
|
864
|
+
merge = W.git(run.clone, "merge-base", "HEAD", src_base, check=False).strip()
|
|
865
|
+
run.base = merge or run.base
|
|
866
|
+
child_env = _start(board, adapter, run, run.base)
|
|
867
|
+
collected = _execute(board, adapter, run, None, child_env, run.timeout, run.heartbeat)
|
|
868
|
+
if run.failure:
|
|
869
|
+
_write_json(
|
|
870
|
+
os.path.join(run.run_dir, "run.json"), _summary(run, adapter, collected, None, None, None, []), run.redactor
|
|
871
|
+
)
|
|
872
|
+
_cost(board, reviewer, run, collected)
|
|
873
|
+
return {
|
|
874
|
+
"card": cid,
|
|
875
|
+
"agent": run.agent,
|
|
876
|
+
"role": "reviewer",
|
|
877
|
+
"status": "failed",
|
|
878
|
+
"reason": run.failure,
|
|
879
|
+
"run_dir": run.run_dir,
|
|
880
|
+
"_exit": 2,
|
|
881
|
+
}
|
|
882
|
+
result = brief.review(adapter.final(run, collected), fallback_text=collected.final)
|
|
883
|
+
result = brief.cap(result)
|
|
884
|
+
_write_json(os.path.join(run.run_dir, "review.json"), result, run.redactor)
|
|
885
|
+
cost = _cost(board, reviewer, run, collected)
|
|
886
|
+
done = board.review(cid, reviewer, result["verdict"], note=_note(result["summary"]))
|
|
887
|
+
with board.lock():
|
|
888
|
+
board.transcript.append(
|
|
889
|
+
T.make(
|
|
890
|
+
"artifact",
|
|
891
|
+
board._from(run.agent),
|
|
892
|
+
task=cid,
|
|
893
|
+
context=f"{board.name}/{cid}",
|
|
894
|
+
src="runner",
|
|
895
|
+
text=f"review {result['verdict']}: {short(result['summary'], 300)}",
|
|
896
|
+
artifacts=[T.artifact("review", data=result, kind="review")],
|
|
897
|
+
data={"billing": run.billing, "reviewed": run.reviewed, "base": run.base},
|
|
898
|
+
)
|
|
899
|
+
)
|
|
900
|
+
_write_json(
|
|
901
|
+
os.path.join(run.run_dir, "run.json"),
|
|
902
|
+
_summary(run, adapter, collected, None, None, cost, [], verdict=result["verdict"], reviewed=run.reviewed),
|
|
903
|
+
run.redactor,
|
|
904
|
+
)
|
|
905
|
+
return {
|
|
906
|
+
"card": cid,
|
|
907
|
+
"agent": run.agent,
|
|
908
|
+
"role": "reviewer",
|
|
909
|
+
"runtime": adapter.runtime,
|
|
910
|
+
"model": collected.model or run.model,
|
|
911
|
+
"billing": run.billing,
|
|
912
|
+
"verdict": result["verdict"],
|
|
913
|
+
"summary": result["summary"],
|
|
914
|
+
"findings": result["findings"],
|
|
915
|
+
"lane": done["lane"],
|
|
916
|
+
"approvals": done["approvals"],
|
|
917
|
+
"usage": collected.usage.as_dict(),
|
|
918
|
+
"usd": collected.usd,
|
|
919
|
+
"run_dir": run.run_dir,
|
|
920
|
+
"_exit": 0,
|
|
921
|
+
}
|