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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. 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
+ }