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
raidlock/core/board.py ADDED
@@ -0,0 +1,2251 @@
1
+ import copy
2
+ import inspect
3
+ import json
4
+ import math
5
+ import os
6
+ import re
7
+ import time
8
+ import uuid
9
+ from contextlib import contextmanager
10
+ from functools import wraps
11
+ from urllib.parse import urlsplit
12
+
13
+ from . import advisories, clock, db, migrate, model, models, registry, repos, store
14
+ from . import billing as billing_mod
15
+ from . import brief as B
16
+ from . import cost as C
17
+ from . import identity as I
18
+ from . import lead as LD
19
+ from . import leases as L
20
+ from . import messages as M
21
+ from . import status as S
22
+ from . import transcript as T
23
+ from .db import Index
24
+ from .errors import Denied, IdentityError, Precondition, StaleLead, Timeout
25
+ from .hints import lead_cmd
26
+ from .lock import FileLock
27
+ from .paths import parse_list
28
+
29
+ DEFAULT_SETTINGS = {
30
+ "auth": "token",
31
+ "require_review": {"count": 0, "other_provider": False},
32
+ "ttl_minutes": 45,
33
+ "hold_minutes": 60,
34
+ "brief_max_tokens": 1200,
35
+ "lead_store": "file",
36
+ "require_verify": False,
37
+ "require_card_for_lead_edits": True,
38
+ "board_first_subagents": True,
39
+ }
40
+ SETTING_KEYS = (
41
+ "auth",
42
+ "require_review",
43
+ "ttl_minutes",
44
+ "brief_max_tokens",
45
+ "lead_store",
46
+ "require_verify",
47
+ "hold_minutes",
48
+ "require_card_for_lead_edits",
49
+ "board_first_subagents",
50
+ )
51
+ LEAD_CARD_RULE = (
52
+ "the lead holds no card covering this path (require_card_for_lead_edits is on). "
53
+ "Add a card that locks it (raid --lead-secret FILE new TITLE --locks REPO/PATH), claim it "
54
+ "(raid --lead-secret FILE claim CARD), or: "
55
+ "raid --lead-secret FILE config set require_card_for_lead_edits off"
56
+ )
57
+ ENFORCE_KEYS = ("require_card_for_lead_edits", "board_first_subagents")
58
+ PR_SETTING_KEYS = ("pr.redact", "pr.terms", "pr.base", "pr.remote")
59
+ TAIL_LINES = 40
60
+
61
+
62
+ def find_board_dir(path=None, env=None):
63
+ """Resolve a `.board` directory from an explicit path, RAIDLOCK_BOARD, BOARD_DIR, or by walking up from cwd."""
64
+ env = os.environ if env is None else env
65
+ explicit = path or env.get("RAIDLOCK_BOARD") or env.get("BOARD_DIR")
66
+ if explicit:
67
+ explicit = os.path.abspath(os.path.expanduser(explicit))
68
+ if os.path.basename(explicit.rstrip(os.sep)) == ".board" and os.path.isdir(explicit):
69
+ return explicit
70
+ if os.path.isdir(os.path.join(explicit, ".board")):
71
+ return os.path.join(explicit, ".board")
72
+ raise Precondition(f"no board at {explicit}", board=explicit, hint=f"raidlock init {explicit}")
73
+ cur = os.getcwd()
74
+ while True:
75
+ if os.path.isdir(os.path.join(cur, ".board")):
76
+ return os.path.join(cur, ".board")
77
+ parent = os.path.dirname(cur)
78
+ if parent == cur:
79
+ raise Precondition(
80
+ "no board found here or in any parent folder", hint="raidlock up (or pass --board PATH)"
81
+ )
82
+ cur = parent
83
+
84
+
85
+ def normalize_review_setting(value):
86
+ if isinstance(value, int):
87
+ return {"count": value, "other_provider": False}
88
+ value = dict(value or {})
89
+ return {"count": int(value.get("count", 0)), "other_provider": bool(value.get("other_provider", False))}
90
+
91
+
92
+ def serialized_write(fn):
93
+ """Keep credential validation and its mutation in the same board-lock interval."""
94
+ signature = inspect.signature(fn)
95
+
96
+ @wraps(fn)
97
+ def wrapped(self, *args, **kwargs):
98
+ caller = signature.bind(self, *args, **kwargs).arguments.get("caller")
99
+ with self.lock():
100
+ previous = getattr(self, "_validated_lead", None)
101
+ try:
102
+ if caller and caller.is_lead:
103
+ self._lead_write(caller)
104
+ self._validated_lead = caller
105
+ result = fn(self, *args, **kwargs)
106
+ if caller and caller.is_lead:
107
+ self._record(
108
+ "status",
109
+ caller.name,
110
+ f"lead action: {fn.__name__}",
111
+ data={
112
+ "event": "lead_action",
113
+ "action": fn.__name__,
114
+ "session": caller.session,
115
+ "lead_fence": caller.lead_fence,
116
+ },
117
+ )
118
+ return result
119
+ finally:
120
+ self._validated_lead = previous
121
+
122
+ return wrapped
123
+
124
+
125
+ class Board:
126
+ """One workspace board (`<workspace>/.board`). Every mutation takes the board lock; lease writes also take the
127
+ global lock."""
128
+
129
+ def __init__(self, board_dir, env=None, home=None):
130
+ migrate.reject_legacy(board_dir)
131
+ self.dir = os.path.abspath(board_dir)
132
+ self.workspace = os.path.dirname(self.dir)
133
+ self.env = dict(os.environ if env is None else env)
134
+ self.home = home or registry.home()
135
+ self.index = Index(self.home)
136
+ self._resolver_cache = None
137
+ self._cache = None
138
+ self._lead_session = None
139
+ self.proc_table = None
140
+ self.keychain = None
141
+ self.meta = store.read_json(self._p("board.json"))
142
+ if self.meta is None:
143
+ raise Precondition(f"{self.dir} is not a raidlock board; run `raidlock init`", board=self.dir)
144
+ store.check_schema(self.meta, self._p("board.json"))
145
+ self.transcript = T.Transcript(self.dir, self.index, self.uid)
146
+ self._sync_mirror()
147
+ with self.lock():
148
+ for lease in self.index.leases(self.uid).values():
149
+ if lease["expires"] is None:
150
+ lease["keys"] = [{**key, "advisory": True} for key in lease["keys"]]
151
+ lease["expires"] = clock.later(
152
+ float(self.settings["hold_minutes"]) * 60,
153
+ start=clock.parse(lease["renewed"]),
154
+ )
155
+ self.index.put_lease(self.uid, lease)
156
+
157
+ def _sync_mirror(self):
158
+ folder = self._p("cards")
159
+ n = sum(1 for f in os.listdir(folder) if f.endswith(".json")) if os.path.isdir(folder) else 0
160
+ if n != self.index.card_count(self.uid):
161
+ with self.lock():
162
+ cards = self.cards()
163
+ self.index.put_cards(self.uid, cards)
164
+ if any(c.get("costs") for c in cards) and not self.index.cost_count(self.uid):
165
+ self.index.rebuild_costs(self.uid, cards)
166
+
167
+ @classmethod
168
+ def open(cls, path=None, env=None, home=None):
169
+ """Open the board at `path` (workspace or .board dir), or find it from RAIDLOCK_BOARD / cwd."""
170
+ return cls(find_board_dir(path, env), env=env, home=home)
171
+
172
+ @classmethod
173
+ def init(
174
+ cls, workspace=".", lead=None, runtime="human", provider=None, model_id=None, auth=True, env=None, home=None
175
+ ):
176
+ """Create a board in `workspace` (idempotent). Returns (board, lead_secret or None when the board existed)."""
177
+ workspace = os.path.abspath(workspace)
178
+ board_dir = os.path.join(workspace, ".board")
179
+ migrate.reject_legacy(board_dir)
180
+ os.makedirs(os.path.join(board_dir, "cards"), exist_ok=True)
181
+ if not os.path.exists(os.path.join(board_dir, ".gitignore")):
182
+ store.atomic_write_text(os.path.join(board_dir, ".gitignore"), "*\n")
183
+ created = False
184
+ initial_session = None
185
+ with FileLock(os.path.join(board_dir, ".lock")):
186
+ if not os.path.exists(os.path.join(board_dir, "board.json")) and not migrate.needs_migration(board_dir):
187
+ name = model.agent_name(lead or I.default_name(runtime, "lead", "", set()))
188
+ initial_session = LD.session_of(os.environ if env is None else env)
189
+ if not all(initial_session):
190
+ raise IdentityError("cannot establish lead lease without a session process anchor")
191
+ settings = dict(DEFAULT_SETTINGS, auth="token" if auth else "off")
192
+ store.write_json(
193
+ os.path.join(board_dir, "board.json"),
194
+ {
195
+ "schema": store.SCHEMA,
196
+ "uid": uuid.uuid4().hex,
197
+ "workspace": os.path.basename(workspace),
198
+ "created": clock.iso(),
199
+ "lead": name,
200
+ "settings": settings,
201
+ },
202
+ )
203
+ created = True
204
+ board = cls(board_dir, env=env, home=home)
205
+ token = None
206
+ if created:
207
+ sid, pid, start = initial_session
208
+ board._enroll(board.lead, "lead", "", runtime, provider, model_id, None, None, None, sid, issue=False)
209
+ with board.lock():
210
+ token = board.leadlease().establish(sid, pid, start)
211
+ board._lead_session = (sid, pid, start)
212
+ board._record(
213
+ "status",
214
+ board.lead,
215
+ f"lead lease established for session {sid} (fence 1)",
216
+ data={"event": "lead_established", "session": sid, "lead_fence": 1},
217
+ )
218
+ board.register()
219
+ return board, token
220
+
221
+ def _p(self, *parts):
222
+ return os.path.join(self.dir, *parts)
223
+
224
+ @contextmanager
225
+ def lock(self):
226
+ """The board lock. Reads of agents.json and teams.json are cached while it is held."""
227
+ with FileLock(self._p(".lock")):
228
+ outer = self._cache is None
229
+ if outer:
230
+ self._cache = {}
231
+ try:
232
+ yield self
233
+ finally:
234
+ if outer:
235
+ self._cache = None
236
+
237
+ @property
238
+ def lead(self):
239
+ return self.meta.get("lead", "lead")
240
+
241
+ @property
242
+ def uid(self):
243
+ return self.meta["uid"]
244
+
245
+ @property
246
+ def name(self):
247
+ return self.meta.get("workspace") or os.path.basename(self.workspace)
248
+
249
+ @property
250
+ def settings(self):
251
+ self.meta = store.read_json(self._p("board.json"))
252
+ s = dict(DEFAULT_SETTINGS)
253
+ s.update(self.meta.get("settings", {}))
254
+ s["require_review"] = normalize_review_setting(s.get("require_review"))
255
+ return s
256
+
257
+ def ttl_seconds(self, ttl_minutes=None):
258
+ return float(ttl_minutes if ttl_minutes is not None else self.settings["ttl_minutes"]) * 60
259
+
260
+ def register(self, refresh=True):
261
+ """Register this board in the global registry; returns the workspace name. With refresh=False it only
262
+ writes when the board is missing from the registry."""
263
+ if not refresh:
264
+ name = self.index.registered_name(self.uid, self.dir)
265
+ if name:
266
+ return name
267
+ return registry.register(self.index, self.uid, self.dir)
268
+
269
+ def warnings(self):
270
+ provider = registry.synced_folder(self.workspace)
271
+ return [f"workspace is inside a {provider} synced folder; sync can replay old board files"] if provider else []
272
+
273
+ def caller(self, agent=None, token=None, fence=None, env=None, lead_secret=None, cap=None, session=None):
274
+ """Resolve the calling agent from flags and the environment (RAIDLOCK_AGENT_ID, RAIDLOCK_AGENT, RAIDLOCK_TOKEN,
275
+ RAIDLOCK_CAP, RAIDLOCK_FENCE, RAIDLOCK_LEAD_SECRET, RAIDLOCK_LEAD_SECRET_FILE). Lead rights need the presented
276
+ lead secret, from the session that holds the lead lease."""
277
+ env = self.env if env is None else env
278
+
279
+ def check(secret):
280
+ sid, pid, start = LD.session_of(env, session, self._table())
281
+ with self.lock():
282
+ fence_value = self._lead_check(secret, sid, pid=pid, start=start)
283
+ if self.leadlease().lease():
284
+ self.leadlease().touch(secret, sid, pid, start)
285
+ self._lead_session = (sid, pid, start)
286
+ return fence_value, sid
287
+
288
+ return I.resolve(
289
+ self.home,
290
+ self.uid,
291
+ self.settings,
292
+ self.agents(),
293
+ self.lead,
294
+ agent=agent,
295
+ token=token,
296
+ fence=fence,
297
+ env=env,
298
+ lead_secret=lead_secret,
299
+ cap=cap,
300
+ lead_check=check,
301
+ )
302
+
303
+ def _table(self):
304
+ return (self.proc_table or LD.process_table)()
305
+
306
+ def leadlease(self):
307
+ """The board's lead lease (`.board/lead.json`) with its secret store."""
308
+ return LD.Lead(
309
+ self.dir,
310
+ I.token_path(self.home, self.uid, self.lead),
311
+ f"{self.uid}/{self.lead}",
312
+ self.ttl_seconds(),
313
+ self.settings.get("lead_store", "file"),
314
+ keychain=self.keychain,
315
+ table=self._table() if self.proc_table else None,
316
+ )
317
+
318
+ def _lead_check(self, secret, sid, any_session=False, pid=None, start=None):
319
+ try:
320
+ return self.leadlease().check(secret, sid, any_session=any_session, pid=pid, start=start)
321
+ except (LD.ForeignLead, StaleLead) as e:
322
+ event = "lead_stale_fence" if isinstance(e, StaleLead) else "lead_foreign_session"
323
+ with self.lock():
324
+ self._record(
325
+ "status",
326
+ self.lead,
327
+ f"ALERT {event}: {e.message}",
328
+ data={
329
+ "event": event,
330
+ "alert": True,
331
+ "session": sid,
332
+ "lead_session": e.details.get("lead_session", ""),
333
+ "lead_fence": e.details.get("fence", 0),
334
+ },
335
+ )
336
+ raise
337
+
338
+ def _lead_write(self, caller):
339
+ """Every lead write: re-check the presented secret and its fence against the lease under the board lock,
340
+ renew the lease, and remember the session for the transcript."""
341
+ with self.lock():
342
+ if getattr(self, "_validated_lead", None) is caller:
343
+ return
344
+ sid, pid, start = LD.session_of(self.env, caller.session, self._table())
345
+ checked = self._lead_check(caller.lead_secret, caller.session, pid=pid, start=start)
346
+ if checked != caller.lead_fence:
347
+ raise StaleLead("caller lead fence does not match its secret", fence=caller.lead_fence)
348
+ fence_value, replaced = self.leadlease().touch(caller.lead_secret, sid, pid, start)
349
+ if replaced:
350
+ caller.lead_secret = replaced
351
+ self._record(
352
+ "status",
353
+ self.lead,
354
+ f"lead lease adopted by session {sid} (fence {fence_value})",
355
+ data={"event": "lead_adopted", "session": sid, "lead_fence": fence_value},
356
+ )
357
+ caller.lead_fence = fence_value
358
+ self._lead_session = (sid, pid, start)
359
+
360
+ def _require_lead(self, caller, action):
361
+ I.require_lead(caller, action)
362
+ self._lead_write(caller)
363
+
364
+ @contextmanager
365
+ def lead_write(self, caller, action):
366
+ """Authorize extension writes against the current lease and hold the board lock through the mutation."""
367
+ with self.lock():
368
+ self._require_lead(caller, action)
369
+ yield self
370
+ self._record(
371
+ "status",
372
+ caller.name,
373
+ f"lead action: {action}",
374
+ data={
375
+ "event": "lead_action",
376
+ "action": action,
377
+ "session": caller.session,
378
+ "lead_fence": caller.lead_fence,
379
+ },
380
+ )
381
+
382
+ def lead_status(self):
383
+ """The lead lease without its secret: session, fence, expiry, liveness and store."""
384
+ lease = self.leadlease()
385
+ return {"lead": self.lead, **lease.summary(), "secret_file": I.token_path(self.home, self.uid, self.lead)}
386
+
387
+ @serialized_write
388
+ def lead_heartbeat(self, caller):
389
+ self._require_lead(caller, "renew the lead lease")
390
+ return self.lead_status()
391
+
392
+ @serialized_write
393
+ def lead_handover(self, caller, to_session):
394
+ """Lead-only: rotate the lead secret, bump the lead fence and bind the lease to `to_session`."""
395
+ self._require_lead(caller, "hand the lead over")
396
+ with self.lock():
397
+ r = self.leadlease().handover(to_session)
398
+ self._lead_session = None
399
+ self._record(
400
+ "status",
401
+ self.lead,
402
+ f"lead handed over from session {r['from']} to session {r['to']} (fence {r['fence']})",
403
+ data={"event": "lead_handover", "from": r["from"], "to": r["to"], "lead_fence": r["fence"]},
404
+ )
405
+ return {**r, "secret_file": I.token_path(self.home, self.uid, self.lead)}
406
+
407
+ def lead_takeover(self, lead_secret=None, env=None, session=None):
408
+ """Take the lead lease after it expired. Needs the current lead secret; refused for worker credentials.
409
+ Logged loudly as an alert."""
410
+ env = self.env if env is None else env
411
+ secret = I.presented_secret(lead_secret, env)
412
+ if any(env.get(k) for k in ("RAIDLOCK_AGENT_ID", "RAIDLOCK_TOKEN", "RAIDLOCK_CAP")):
413
+ raise IdentityError("workers cannot take over the lead", hint="unset worker credentials first")
414
+ if not secret:
415
+ raise IdentityError("takeover needs the current lead secret", hint=I.LEAD_HINT)
416
+ sid, pid, start = LD.session_of(env, session, self._table())
417
+ with self.lock():
418
+ self._lead_check(secret, sid, any_session=True)
419
+ lease = self.leadlease()
420
+ r = lease.takeover(sid, pid, start)
421
+ prev = r["previous"]
422
+ self._lead_session = (sid, pid, start)
423
+ self._record(
424
+ "status",
425
+ self.lead,
426
+ f"ALERT LEAD TAKEOVER: session {sid} took the lead from session "
427
+ f"{prev['session'] or '(none)'} whose lease expired at "
428
+ f"{prev['expires'] or '-'}; lead fence now {r['fence']}",
429
+ data={
430
+ "event": "lead_takeover",
431
+ "alert": True,
432
+ "session": sid,
433
+ "previous": prev,
434
+ "lead_fence": r["fence"],
435
+ },
436
+ )
437
+ return {**r, "secret_file": I.token_path(self.home, self.uid, self.lead)}
438
+
439
+ def agents(self):
440
+ return self._cached("agents.json", "agents")
441
+
442
+ def teams(self):
443
+ return self._cached("teams.json", "teams")
444
+
445
+ def _cached(self, name, key):
446
+ if self._cache is None:
447
+ return store.read_doc(self._p(name), key)
448
+ if name not in self._cache:
449
+ self._cache[name] = store.read_doc(self._p(name), key)
450
+ return copy.deepcopy(self._cache[name])
451
+
452
+ def leases(self):
453
+ return self.index.leases(self.uid)
454
+
455
+ def version(self):
456
+ """Cheap change counters: {workspace, global}. Use them as ETags for polling."""
457
+ return self.index.versions(self.uid)
458
+
459
+ def cards(self):
460
+ """All cards, sorted by id. Unchanged files (same mtime and size) are served from the DB mirror."""
461
+ folder = self._p("cards")
462
+ names = sorted(f for f in os.listdir(folder) if f.endswith(".json")) if os.path.isdir(folder) else []
463
+ cached = self.index.card_docs(self.uid)
464
+ out, fresh, stats = [], [], {}
465
+ for name in names:
466
+ cid = name[:-5]
467
+ try:
468
+ st = os.stat(os.path.join(folder, name))
469
+ except FileNotFoundError:
470
+ continue
471
+ key = (st.st_mtime_ns, st.st_size)
472
+ doc, mtime, size = cached.get(cid, (None, None, None))
473
+ if doc and (mtime, size) == key:
474
+ out.append(json.loads(doc))
475
+ continue
476
+ card = store.read_doc(os.path.join(folder, name), "card")
477
+ out.append(card)
478
+ fresh.append(card)
479
+ stats[cid] = key
480
+ if fresh:
481
+ self.index.put_cards(self.uid, fresh, stats, replace=False, bump=False)
482
+ return out
483
+
484
+ def card(self, card_id):
485
+ path = self._p("cards", model.card_id(card_id) + ".json")
486
+ try:
487
+ st = os.stat(path)
488
+ except FileNotFoundError:
489
+ raise Precondition(f"no card {card_id}", card=card_id, hint="raidlock show") from None
490
+ doc, mtime, size = self.index.card_doc(self.uid, card_id)
491
+ if doc and (mtime, size) == (st.st_mtime_ns, st.st_size):
492
+ return json.loads(doc)
493
+ data = store.read_json(path)
494
+ if data is None:
495
+ raise Precondition(f"no card {card_id}", card=card_id, hint="raidlock show")
496
+ store.check_schema(data, card_id)
497
+ return data["card"]
498
+
499
+ def brief(self, card_id, max_tokens=None):
500
+ """Deterministic agent brief for a card with its token estimate (ceil(chars/4)), capped at brief_max_tokens."""
501
+ card = self.card(card_id)
502
+ cap = int(max_tokens or self.settings["brief_max_tokens"])
503
+ lanes = self.index.lanes(self.uid, card.get("depends", []))
504
+ minimal = B.render(dict(card, notes=[], output="", ticket="", reviewers=[]), lanes, cap)
505
+ try:
506
+ from ..lore import lessons_for
507
+ except ImportError:
508
+ return B.render(card, lanes, cap)
509
+ lore = lessons_for(self, card, min(200, max(0, cap - minimal["tokens"])))
510
+ result = B.render(card, lanes, cap, context=lore["text"])
511
+ result.update(lore_tokens=lore["tokens"], lore_ids=[item["id"] for item in lore["lessons"]])
512
+ return result
513
+
514
+ def _save_card(self, card):
515
+ card["updated"] = clock.iso()
516
+ path = self._p("cards", card["id"] + ".json")
517
+ store.write_doc(path, "card", card)
518
+ st = os.stat(path)
519
+ self.index.put_card(self.uid, card, (st.st_mtime_ns, st.st_size))
520
+
521
+ def _save_agents(self, agents):
522
+ store.write_doc(self._p("agents.json"), "agents", agents)
523
+ if self._cache is not None:
524
+ self._cache["agents.json"] = copy.deepcopy(agents)
525
+ self.index.bump(self.uid)
526
+
527
+ def _from(self, name):
528
+ a = self.agents().get(name, {})
529
+ out = {
530
+ "agent": name,
531
+ "provider": a.get("provider", ""),
532
+ "runtime": a.get("runtime", ""),
533
+ "model": a.get("model", ""),
534
+ }
535
+ if name == self.lead:
536
+ out["session"] = (
537
+ self._lead_session[0] if self._lead_session else self.leadlease().lease().get("session", "")
538
+ )
539
+ return out
540
+
541
+ def _record(self, kind, by, text="", task="", **kw):
542
+ rec = T.make(
543
+ kind, self._from(by), text=text, task=task, context=f"{self.name}/{task}" if task else self.name, **kw
544
+ )
545
+ return self.transcript.append(rec)
546
+
547
+ def _note(self, card, by, text):
548
+ card["notes"].append(
549
+ {"ts": clock.iso(), "by": by, "model": self.agents().get(by, {}).get("model", ""), "text": text}
550
+ )
551
+
552
+ def _auth(self, caller, card=None, scoped_ok=False):
553
+ I.require_auth(caller, self.settings, self.env, self.lead, self.agents())
554
+ if caller.scope_card and not scoped_ok and card != caller.scope_card:
555
+ raise Denied(
556
+ f"{caller.name}'s capability is scoped to card {caller.scope_card}",
557
+ agent=caller.name,
558
+ card=card or "",
559
+ hint="use the agent token for other work",
560
+ )
561
+ if caller.scope_card:
562
+ current = self.card(caller.scope_card)
563
+ if current.get("owner") != caller.name or current.get("fence") != caller.fence or current.get("released"):
564
+ raise Denied("card capability is stale or released", card=caller.scope_card)
565
+ if caller.is_lead:
566
+ self._lead_write(caller)
567
+
568
+ def _touch(self, name):
569
+ self.index.touch_agent(self.uid, name)
570
+
571
+ def _resolver(self):
572
+ if self._resolver_cache is None:
573
+ self._resolver_cache = repos.Resolver(self.workspace, self.dir)
574
+ return self._resolver_cache
575
+
576
+ def _enroll(
577
+ self, name, role, team, runtime, provider, declared, flag, reported, effort, session, issue=True, billing=None
578
+ ):
579
+ agents = self.agents()
580
+ runtime = runtime or "shell"
581
+ if runtime not in I.RUNTIMES:
582
+ raise Precondition(
583
+ f"runtime must be one of {', '.join(I.RUNTIMES)}",
584
+ runtime=runtime,
585
+ hint="the short names claude, codex and gemini work too",
586
+ )
587
+ provider = provider or I.RUNTIME_PROVIDER[runtime]
588
+ if provider not in I.PROVIDERS:
589
+ raise Precondition(f"provider must be one of {', '.join(I.PROVIDERS)}", provider=provider)
590
+ found = models.detect(runtime, declared=declared, flag=flag, reported=reported, effort=effort, env=self.env)
591
+ found.update(billing_mod.detect(runtime, declared=billing, env=self.env))
592
+ old = agents.get(name, {})
593
+ record = {
594
+ **old,
595
+ "team": team if team is not None else old.get("team", ""),
596
+ "role": role,
597
+ "provider": provider,
598
+ "runtime": runtime,
599
+ "session": session or old.get("session", ""),
600
+ "joined": old.get("joined") or clock.iso(),
601
+ "last_seen": clock.iso(),
602
+ "card": old.get("card", ""),
603
+ **found,
604
+ }
605
+ if not found["model"] and old.get("model"):
606
+ record.update({k: old[k] for k in ("model", "model_version", "effort", "model_source") if k in old})
607
+ record["display"] = f"{name} · {models.display(record['model'], self.home)} · {runtime}"
608
+ agents[name] = record
609
+ self._save_agents(agents)
610
+ providers = {a.get("provider") for a in agents.values()} - {None, "", "human"}
611
+ if len(providers) >= 2:
612
+ meta = store.read_json(self._p("board.json"))
613
+ settings = copy.deepcopy(meta.get("settings", {}))
614
+ explicit = meta.get("explicit_settings", [])
615
+ review = normalize_review_setting(settings.get("require_review"))
616
+ if "require_review.count" not in explicit:
617
+ review["count"] = max(1, review["count"])
618
+ if "require_review.other_provider" not in explicit:
619
+ review["other_provider"] = True
620
+ settings["require_review"] = review
621
+ if "require_verify" not in explicit:
622
+ settings["require_verify"] = True
623
+ if settings != meta.get("settings"):
624
+ meta["settings"] = settings
625
+ store.write_json(self._p("board.json"), meta)
626
+ self._record(
627
+ "status",
628
+ name,
629
+ "multi-provider review and verify defaults enabled",
630
+ data={"event": "gate_defaults", "settings": settings},
631
+ )
632
+ self.meta = meta
633
+ self.index.put_agent(self.uid, name, record)
634
+ token = I.issue_token(self.home, self.uid, name) if issue else None
635
+ return {"agent": name, "token": token, **record}
636
+
637
+ @serialized_write
638
+ def join(
639
+ self,
640
+ caller,
641
+ agent=None,
642
+ team=None,
643
+ role="worker",
644
+ runtime="shell",
645
+ provider=None,
646
+ model_id=None,
647
+ flag_model=None,
648
+ reported_model=None,
649
+ effort=None,
650
+ session="",
651
+ card="",
652
+ billing=None,
653
+ ):
654
+ """Join (or update) an agent. Returns the record plus `token` when one was issued or rotated."""
655
+ if role not in model.ROLES:
656
+ raise Precondition(f"role must be one of {', '.join(model.ROLES)}", role=role)
657
+ with self.lock():
658
+ agents = self.agents()
659
+ own = caller.name if caller.name and not caller.is_lead else ""
660
+ name = agent or own or I.default_name(runtime, role, card, set(agents))
661
+ if caller.subagent and name != caller.name:
662
+ raise IdentityError(f"RAIDLOCK_AGENT_ID={caller.name} cannot join as {name}", agent=name)
663
+ model.agent_name(name)
664
+ if role == "lead" or name == self.lead:
665
+ self._require_lead(caller, "join as lead")
666
+ if name != self.lead:
667
+ raise Precondition(f"the lead of this board is {self.lead}", agent=name)
668
+ if team:
669
+ model.team_name(team)
670
+ if team not in self.teams():
671
+ raise Precondition(
672
+ f"no team {team}; the lead creates it with `raidlock team add {team}`", team=team
673
+ )
674
+ if caller.scope_card:
675
+ self._auth(caller)
676
+ keep_token = self._may_rejoin(caller, name, agents)
677
+ result = self._enroll(
678
+ name,
679
+ role,
680
+ team,
681
+ runtime,
682
+ provider,
683
+ model_id,
684
+ flag_model,
685
+ reported_model,
686
+ effort,
687
+ session or LD.session_of(self.env)[0],
688
+ issue=not keep_token and name != self.lead,
689
+ billing=billing,
690
+ )
691
+ self._record("status", name, f"{name} joined as {role}" + (f" on team {team}" if team else ""))
692
+ self.register()
693
+ return result
694
+
695
+ def _may_rejoin(self, caller, name, agents):
696
+ if name not in agents:
697
+ return False
698
+ if caller.name == name and caller.authenticated:
699
+ return True
700
+ if caller.is_lead or not I.has_token(self.home, self.uid, name):
701
+ return False
702
+ raise IdentityError(f"{name} already joined; present its token or ask the lead to re-issue it", agent=name)
703
+
704
+ @serialized_write
705
+ def add_team(self, caller, name, max_active=2, mission="", repos_list=None):
706
+ self._require_lead(caller, "manage teams")
707
+ model.team_name(name)
708
+ mission = model.line_text(mission, "mission", 500)
709
+ with self.lock():
710
+ teams = self.teams()
711
+ old = teams.get(name, {})
712
+ teams[name] = {
713
+ "max": int(max_active if max_active is not None else old.get("max", 2)),
714
+ "mission": mission or old.get("mission", ""),
715
+ "repos": parse_list(repos_list) or old.get("repos", []),
716
+ }
717
+ store.write_doc(self._p("teams.json"), "teams", teams)
718
+ self._cache.pop("teams.json", None)
719
+ self.index.bump(self.uid)
720
+ return {"team": name, **teams[name]}
721
+
722
+ @serialized_write
723
+ def remove_team(self, caller, name):
724
+ self._require_lead(caller, "manage teams")
725
+ with self.lock():
726
+ teams = self.teams()
727
+ if teams.pop(name, None) is None:
728
+ raise Precondition(f"no team {name}", team=name)
729
+ store.write_doc(self._p("teams.json"), "teams", teams)
730
+ self._cache.pop("teams.json", None)
731
+ self.index.bump(self.uid)
732
+ return {"team": name, "removed": True}
733
+
734
+ def team_list(self):
735
+ agents = self.agents()
736
+ return {
737
+ n: {**t, "members": sorted(a for a, x in agents.items() if x.get("team") == n)}
738
+ for n, t in sorted(self.teams().items())
739
+ }
740
+
741
+ def _move_lead_secret(self, old, new):
742
+ path = I.token_path(self.home, self.uid, self.lead)
743
+ src = LD.SecretStore(path, f"{self.uid}/{self.lead}", old, self.keychain)
744
+ secret = src.get()
745
+ if not secret:
746
+ return
747
+ dst = LD.SecretStore(path, f"{self.uid}/{self.lead}", new, self.keychain)
748
+ where = dst.put(secret)
749
+ if old == "keychain" and where == "file":
750
+ src.keychain.delete()
751
+
752
+ def get_setting(self, key=None):
753
+ s = self.settings
754
+ if key is None:
755
+ return s
756
+ if key in PR_SETTING_KEYS:
757
+ return s.get(key, "")
758
+ if key not in SETTING_KEYS and not key.startswith("require_review."):
759
+ raise Precondition(f"unknown setting {key}; known: {', '.join(SETTING_KEYS)}", key=key)
760
+ if key.startswith("require_review."):
761
+ return s["require_review"].get(key.split(".", 1)[1])
762
+ return s[key]
763
+
764
+ @serialized_write
765
+ def set_setting(self, caller, key, value):
766
+ """Lead-only. Keys: auth (token|off), ttl_minutes (number), require_review (N), require_review.count,
767
+ require_review.other_provider."""
768
+ self._require_lead(caller, "change settings")
769
+ with self.lock():
770
+ meta = store.read_json(self._p("board.json"))
771
+ settings = dict(DEFAULT_SETTINGS, **meta.get("settings", {}))
772
+ settings["require_review"] = normalize_review_setting(settings.get("require_review"))
773
+ old_store = settings.get("lead_store", "file")
774
+ previous = copy.deepcopy(settings)
775
+ _apply_setting(settings, key, value)
776
+ explicit = set(meta.get("explicit_settings", []))
777
+ explicit.add("require_review.count" if key == "require_review" else key)
778
+ meta["explicit_settings"] = sorted(explicit)
779
+ if settings.get("lead_store", "file") != old_store:
780
+ self._move_lead_secret(old_store, settings["lead_store"])
781
+ meta["settings"] = settings
782
+ store.write_json(self._p("board.json"), meta)
783
+ self.meta = meta
784
+ self.index.bump(self.uid)
785
+ self._record(
786
+ "status",
787
+ caller.name,
788
+ f"setting changed: {key}",
789
+ data={"event": "setting_changed", "key": key, "before": previous, "after": settings},
790
+ )
791
+ return self.settings
792
+
793
+ @serialized_write
794
+ def new_card(
795
+ self,
796
+ caller,
797
+ title,
798
+ card_id=None,
799
+ team="",
800
+ locks=None,
801
+ depends=None,
802
+ scope="",
803
+ verify="",
804
+ verify_cmd="",
805
+ output="",
806
+ ticket="",
807
+ reviewers=None,
808
+ repo=None,
809
+ allow_protected=None,
810
+ ):
811
+ """Create a card in todo. `verify_cmd` (a runnable check) may only be set by the lead."""
812
+ self._auth(caller)
813
+ if verify_cmd:
814
+ self._require_lead(caller, "set a verify command")
815
+ allowed = self._protected_paths(caller, allow_protected) if allow_protected else []
816
+ if repo is not None and repo not in self._resolver().repos:
817
+ raise Precondition(f"unknown workspace repo {repo!r}")
818
+ cid = model.card_id(card_id or model.slug(title))
819
+ lock_list = parse_list(locks)
820
+ resolver = self._resolver()
821
+ for p in lock_list:
822
+ resolver.resolve(p)
823
+ with self.lock():
824
+ if os.path.exists(self._p("cards", cid + ".json")):
825
+ raise Precondition(f"card {cid} exists", card=cid)
826
+ if team and team not in self.teams():
827
+ raise Precondition(f"no team {team}", team=team)
828
+ card = model.new_card(
829
+ cid,
830
+ title,
831
+ clock.iso(),
832
+ team=team,
833
+ locks=lock_list,
834
+ repo=repo or "",
835
+ allow_protected=allowed,
836
+ ticket=ticket,
837
+ scope=scope,
838
+ output=output,
839
+ verify=verify,
840
+ verify_cmd=verify_cmd,
841
+ depends=[model.card_id(d) for d in parse_words(depends)],
842
+ reviewers=parse_words(reviewers),
843
+ )
844
+ if repo:
845
+ self._card_repo(card)
846
+ self._note(card, caller.name, "created")
847
+ self._save_card(card)
848
+ self._record(
849
+ "status",
850
+ caller.name,
851
+ f"created {cid}: {card['title']}",
852
+ task=cid,
853
+ data={"locks": card["locks"], "repo": card["repo"], "allow_protected": allowed},
854
+ )
855
+ self.register(refresh=False)
856
+ return card
857
+
858
+ def import_plan(self, caller, items):
859
+ """Create several cards from dicts (id, title, team, locks, depends, scope, verify, output, ticket).
860
+ Validates every item before creating any. Returns the created cards."""
861
+ self._auth(caller)
862
+ existing = {c["id"] for c in self.cards()}
863
+ prepared = []
864
+ for item in items:
865
+ if not isinstance(item, dict) or not item.get("title"):
866
+ raise Precondition("every plan item needs a title", item=str(item)[:200])
867
+ cid = model.card_id(item.get("id") or model.slug(item["title"]))
868
+ if cid in existing:
869
+ raise Precondition(f"card {cid} exists", card=cid, hint="raidlock show " + cid)
870
+ existing.add(cid)
871
+ prepared.append({**item, "id": cid})
872
+ fields = ("team", "locks", "depends", "scope", "verify", "verify_cmd", "output", "ticket", "reviewers")
873
+ return [
874
+ self.new_card(caller, p["title"], card_id=p["id"], **{k: p.get(k) or "" for k in fields}) for p in prepared
875
+ ]
876
+
877
+ def _verify_hint(self, card):
878
+ if card.get("verify_cmd"):
879
+ return lead_cmd(self, f"verify {card['id']}")
880
+ return (
881
+ lead_cmd(self, f'edit {card["id"]} --verify-cmd "<command>"')
882
+ + f", then verify {card['id']}; or turn the gate off: "
883
+ + lead_cmd(self, "config set require_verify false")
884
+ )
885
+
886
+ def _protected_paths(self, caller, paths):
887
+ self._require_lead(caller, "allow protected paths")
888
+ allowed = parse_list(paths)
889
+ if any(path not in (".github/workflows", ".claude") for path in allowed):
890
+ raise Precondition("only .github/workflows and .claude can be allowed; .board is always denied")
891
+ return allowed
892
+
893
+ @serialized_write
894
+ def edit_card(
895
+ self,
896
+ caller,
897
+ card_id,
898
+ verify_cmd=None,
899
+ verify=None,
900
+ repo=None,
901
+ locks=None,
902
+ depends=None,
903
+ scope=None,
904
+ allow_protected=None,
905
+ exclude_locks=None,
906
+ boss=None,
907
+ ):
908
+ """Lead-only: set or change a card's verify command or verify description. A changed command invalidates
909
+ earlier receipts, because each receipt carries the hash of the command it ran."""
910
+ self._auth(caller)
911
+ self._require_lead(caller, "edit a card")
912
+ changes = {}
913
+ if exclude_locks is not None:
914
+ changes["lock_exclusions"] = parse_list(exclude_locks)
915
+ self._resolver_check(changes["lock_exclusions"])
916
+ if allow_protected is not None:
917
+ changes["allow_protected"] = self._protected_paths(caller, allow_protected)
918
+ if repo is not None:
919
+ if repo not in self._resolver().repos:
920
+ raise Precondition(f"unknown workspace repo {repo!r}")
921
+ changes["repo"] = repo
922
+ if locks is not None:
923
+ changes["locks"] = parse_list(locks)
924
+ self._resolver_check(changes["locks"])
925
+ if depends is not None:
926
+ changes["depends"] = [model.card_id(d) for d in parse_words(depends)]
927
+ if card_id in changes["depends"]:
928
+ raise Precondition("a card cannot depend on itself")
929
+ for dependency in changes["depends"]:
930
+ self.card(dependency)
931
+ if scope is not None:
932
+ changes["scope"] = model.line_text(scope, "scope")
933
+ if boss is not None:
934
+ changes["boss"] = bool(boss)
935
+ if verify_cmd is not None:
936
+ changes["verify_cmd"] = model.line_text(verify_cmd, "verify_cmd")
937
+ if verify is not None:
938
+ changes["verify"] = model.line_text(verify, "verify")
939
+ if not changes:
940
+ raise Precondition(
941
+ "nothing to change", card=card_id, hint=f'raidlock edit {card_id} --verify-cmd "<command>"'
942
+ )
943
+ with self.lock():
944
+ card = self.card(card_id)
945
+ if card["lane"] in ("done", "cancelled"):
946
+ raise Precondition(f"{card_id} is {card['lane']}", card=card_id, hint=f"raidlock reopen {card_id}")
947
+ candidate = {**card, **changes}
948
+ if "repo" in changes or "locks" in changes:
949
+ self._card_repo(candidate)
950
+ self._card_keys(candidate)
951
+ before = {k: card.get(k, "") for k in changes}
952
+ lease = self.index.lease(self.uid, card_id)
953
+ if ("locks" in changes or "lock_exclusions" in changes) and lease:
954
+ self._grant(
955
+ candidate,
956
+ card["owner"],
957
+ candidate["locks"],
958
+ card["fence"],
959
+ self.ttl_seconds(),
960
+ checkout=lease.get("checkout"),
961
+ held=card["lane"] in ("review", "blocked"),
962
+ since=lease["since"],
963
+ )
964
+ self._update_lease(card_id, expires=lease["expires"])
965
+ card.update(changes)
966
+ if set(changes) != {"boss"}:
967
+ card["rev"] = card.get("rev", 0) + 1
968
+ self._note(card, caller.name, "edited " + ", ".join(sorted(changes)))
969
+ self._save_card(card)
970
+ self._record(
971
+ "status",
972
+ caller.name,
973
+ f"edited {card_id}: " + ", ".join(sorted(changes)),
974
+ task=card_id,
975
+ data={"event": "card_edited", "before": before, "after": changes},
976
+ )
977
+ return {"card": card_id, **changes}
978
+
979
+ def _owner_or_lead(self, card, caller):
980
+ self._auth(caller, card["id"])
981
+ if card["owner"] and caller.name == card["owner"]:
982
+ if not caller.fence:
983
+ raise Precondition(
984
+ f"no fence given for {card['id']}",
985
+ card=card["id"],
986
+ hint="export RAIDLOCK_FENCE=<fence printed by raidlock claim>",
987
+ )
988
+ if caller.fence != card["fence"]:
989
+ self._record(
990
+ "status",
991
+ caller.name,
992
+ f"stale fence {caller.fence} rejected for {card['id']}",
993
+ task=card["id"],
994
+ data={"event": "stale_fence", "fence": caller.fence, "current_fence": card["fence"]},
995
+ )
996
+ raise Denied(
997
+ f"fence {caller.fence} for {card['id']} is stale; someone claimed it after you",
998
+ card=card["id"],
999
+ fence=caller.fence,
1000
+ hint=f"raidlock claim {card['id']}",
1001
+ )
1002
+ return "owner"
1003
+ if caller.is_lead:
1004
+ return "lead"
1005
+ raise Denied(f"{card['id']} is owned by {card['owner'] or 'nobody'}", card=card["id"], owner=card["owner"])
1006
+
1007
+ def _keys(self, paths):
1008
+ resolver = self._resolver()
1009
+ return [resolver.resolve(p) for p in paths]
1010
+
1011
+ def _card_keys(self, card, paths=None):
1012
+ keys = self._keys(card.get("locks", []) if paths is None else paths)
1013
+ excluded = self._keys(card.get("lock_exclusions", []))
1014
+ for exclusion in excluded:
1015
+ if not any(repos.key_contains(key, exclusion) and key["rel"] != exclusion["rel"] for key in keys):
1016
+ raise Precondition("excluded paths must be inside a held directory lock")
1017
+ return [
1018
+ {**key, "exclude": [item["rel"] for item in excluded if repos.key_contains(key, item)]} if excluded else key
1019
+ for key in keys
1020
+ ]
1021
+
1022
+ def _grant(self, card, agent, paths, fence, ttl_s, checkout=None, held=False, since=None):
1023
+ return self.index.grant(
1024
+ self.uid,
1025
+ card["id"],
1026
+ agent,
1027
+ paths,
1028
+ [{**key, **({"advisory": True} if held else {})} for key in self._card_keys(card, paths)],
1029
+ fence,
1030
+ float(self.settings["hold_minutes"]) * 60 if held else ttl_s,
1031
+ held=False,
1032
+ since=since,
1033
+ checkout=checkout,
1034
+ )
1035
+
1036
+ def _update_lease(self, card_id, **fields):
1037
+ return self.index.update_lease(self.uid, card_id, **fields)
1038
+
1039
+ def _drop_lease(self, card_id):
1040
+ return self.index.drop_lease(self.uid, card_id)
1041
+
1042
+ def _claim_reasons(self, card, agent, agents, teams, lease=None):
1043
+ resolver = self._resolver()
1044
+ for path in card.get("locks", []):
1045
+ if resolver.repos and resolver._match(path.split("/"))[0] is None:
1046
+ raise Precondition(
1047
+ f"lock {path} matches no repo in workspace {self.workspace}",
1048
+ hint="prefix the lock with a workspace repo: "
1049
+ + (", ".join(sorted(resolver.repos)) or "initialize or declare a git repo in this workspace"),
1050
+ )
1051
+ out = []
1052
+ takeover = ""
1053
+ lane, owner = card["lane"], card["owner"]
1054
+ if lane in ("done", "cancelled"):
1055
+ out.append((3, f"{lane} is terminal; the lead can reopen it"))
1056
+ elif lane == "review":
1057
+ out.append((3, "in review; a `changes` review or the lead sends it back"))
1058
+ if owner and agent and owner != agent:
1059
+ takeover = owner
1060
+ lease = lease if lease is not None else self.index.lease(self.uid, card["id"])
1061
+ if lane == "doing" and lease and L.is_live(lease):
1062
+ out.append((2, f"owned by {owner} with a live lease until {lease['expires']}"))
1063
+ if agent:
1064
+ out.extend(self._member_reasons(card, agent, agents))
1065
+ lanes = self.index.lanes(self.uid, card.get("depends", []))
1066
+ for dep in card.get("depends", []):
1067
+ state = lanes.get(dep, "missing")
1068
+ if state != "done":
1069
+ out.append((3, f"waits on {dep} ({state})"))
1070
+ out.extend(self._cap_reasons(card, teams))
1071
+ clash = self.index.conflicts(self._card_keys(card), self.uid, card["id"])
1072
+ out.extend((2, L.describe(c)) for c in clash)
1073
+ return out, takeover, clash
1074
+
1075
+ def _member_reasons(self, card, agent, agents):
1076
+ rec = agents.get(agent)
1077
+ if rec is None:
1078
+ return [(5, f"{agent} has not joined")]
1079
+ if rec.get("role") == "reviewer":
1080
+ return [(5, f"{agent} is a reviewer; reviewers cannot claim")]
1081
+ if card.get("team") and rec.get("team") != card["team"] and agent != self.lead:
1082
+ return [(5, f"{agent} is on team {rec.get('team') or '(none)'}; {card['id']} is for {card['team']}")]
1083
+ return []
1084
+
1085
+ def _cap_reasons(self, card, teams):
1086
+ team = card.get("team")
1087
+ cap = teams.get(team, {}).get("max") if team else None
1088
+ if cap is None or card["lane"] == "doing":
1089
+ return []
1090
+ n = self.index.doing_in_team(self.uid, team, card["id"])
1091
+ return [(2, f"team {team} at its limit ({n}/{cap} in doing)")] if n >= cap else []
1092
+
1093
+ @serialized_write
1094
+ def claim(
1095
+ self, card_id, caller, take_over=False, ttl_minutes=None, for_agent=None, checkout=None, runner_base=None
1096
+ ):
1097
+ """Grant every lock of the card atomically (across all registered workspaces) and move it to doing.
1098
+
1099
+ Mints a new global fence; returns {card, agent, fence, expires, paths, checkout, taken_over_from}."""
1100
+ self._auth(caller)
1101
+ owner = for_agent or caller.name
1102
+ if owner != caller.name or runner_base:
1103
+ self._require_lead(caller, "claim for another agent")
1104
+ if for_agent and owner not in self.agents():
1105
+ self.join(caller, agent=owner, team=self.card(card_id).get("team") or None)
1106
+ warnings = advisories.pre_claim_warnings(self, card_id, checkout=checkout)
1107
+ with self.lock():
1108
+ card = self.card(card_id)
1109
+ warnings.extend(
1110
+ "Advisory hold: " + L.describe(c)
1111
+ for c in self.index.conflicts(self._keys(card["locks"]), self.uid, card_id, advisory=True)
1112
+ )
1113
+ reasons, prev, clash = self._claim_reasons(card, owner, self.agents(), self.teams())
1114
+ if prev and not take_over:
1115
+ reasons.append((2, f"owned by {prev}; --take-over needed"))
1116
+ if reasons and {c for c, _ in reasons} <= {2}:
1117
+ self._record(
1118
+ "status",
1119
+ owner,
1120
+ f"lease denied on {card_id}: " + "; ".join(r for _, r in reasons),
1121
+ task=card_id,
1122
+ data={
1123
+ "event": "lock_denied",
1124
+ "agent": owner,
1125
+ "reasons": [r for _, r in reasons],
1126
+ "paths": [c.get("path", "") for c in clash or []],
1127
+ },
1128
+ )
1129
+ _raise_reasons(card_id, reasons, clash)
1130
+ if checkout:
1131
+ self._verification_repo(card, checkout)
1132
+ info = repos.checkout_info(checkout) if checkout else None
1133
+ if info is not None and runner_base:
1134
+ info["runner_base"] = runner_base
1135
+ lease = self._grant(card, owner, card["locks"], None, self.ttl_seconds(ttl_minutes), checkout=info)
1136
+ card.update(owner=owner, lane="doing", fence=lease["fence"], released=False, rev=card.get("rev", 0) + 1)
1137
+ text = f"claimed by {owner} (fence {lease['fence']}); lease until {lease['expires']}"
1138
+ text += f"; taken over from {prev}" if prev else ""
1139
+ self._note(card, caller.name, text)
1140
+ self._save_card(card)
1141
+ self._touch(owner)
1142
+ self._record(
1143
+ "status",
1144
+ owner,
1145
+ text,
1146
+ task=card_id,
1147
+ data={"a2a_state": "working", "fence": lease["fence"], "locks": card["locks"]},
1148
+ )
1149
+ self.register(refresh=False)
1150
+ token = I.read_token(self.home, self.uid, owner) if owner != self.lead else ""
1151
+ cap = I.make_cap(token, owner, card_id, lease["fence"]) if token else ""
1152
+ return {
1153
+ "card": card_id,
1154
+ "agent": owner,
1155
+ "fence": lease["fence"],
1156
+ "expires": lease["expires"],
1157
+ "paths": lease["paths"],
1158
+ "checkout": lease["checkout"],
1159
+ "branch": lease["branch"],
1160
+ "taken_over_from": prev,
1161
+ "cap": cap,
1162
+ **({"warnings": warnings} if warnings else {}),
1163
+ }
1164
+
1165
+ @serialized_write
1166
+ def heartbeat(self, card_id, caller, ttl_minutes=None):
1167
+ """Renew the card's lease (owner with fence, or lead). A doing lease renewed after expiry is re-granted and
1168
+ logged as resumed."""
1169
+ with self.lock():
1170
+ card = self.card(card_id)
1171
+ self._owner_or_lead(card, caller)
1172
+ if card["lane"] not in model.HOLD_LANES:
1173
+ raise Precondition(f"{card_id} is in {card['lane']}; nothing to renew", card=card_id)
1174
+ lease, resumed = self._renew(card, ttl_minutes)
1175
+ self._touch(caller.name)
1176
+ return {"card": card_id, "fence": card["fence"], "expires": lease["expires"], "resumed": resumed}
1177
+
1178
+ def _renew(self, card, ttl_minutes=None):
1179
+ lease = self.index.lease(self.uid, card["id"])
1180
+ if card["lane"] != "doing":
1181
+ if not lease:
1182
+ raise Precondition(f"{card['id']} holds no lease; claim it again", card=card["id"])
1183
+ return self._update_lease(card["id"], renewed=clock.iso()), False
1184
+ if lease and L.is_live(lease):
1185
+ return self._update_lease(
1186
+ card["id"], renewed=clock.iso(), expires=clock.later(self.ttl_seconds(ttl_minutes))
1187
+ ), False
1188
+ lease = self._grant(
1189
+ card,
1190
+ card["owner"],
1191
+ card["locks"],
1192
+ card["fence"],
1193
+ self.ttl_seconds(ttl_minutes),
1194
+ since=(lease or {}).get("since"),
1195
+ )
1196
+ self._note(card, card["owner"], "lease resumed after expiry")
1197
+ self._save_card(card)
1198
+ self._record("status", card["owner"], "lease resumed after expiry", task=card["id"])
1199
+ return lease, True
1200
+
1201
+ @serialized_write
1202
+ def note(self, card_id, caller, text):
1203
+ """Append a note. The owner must pass its fence; an owner note renews the lease. The lead secret alone
1204
+ notes on any card, its own included, without renewing a lease."""
1205
+ text = model.line_text(T.redact(text), "note")
1206
+ if not text:
1207
+ raise Precondition("note text is empty")
1208
+ with self.lock():
1209
+ card = self.card(card_id)
1210
+ self._auth(caller, card_id)
1211
+ is_owner = bool(card["owner"]) and caller.name == card["owner"]
1212
+ if is_owner and caller.is_lead and not caller.fence:
1213
+ is_owner = False
1214
+ if is_owner:
1215
+ self._owner_or_lead(card, caller)
1216
+ card["rev"] = card.get("rev", 0) + 1
1217
+ self._note(card, caller.name, text)
1218
+ self._save_card(card)
1219
+ if is_owner and card["lane"] in model.HOLD_LANES:
1220
+ self._renew(card)
1221
+ self._touch(caller.name)
1222
+ self._record("note", caller.name, text, task=card_id)
1223
+ delivered = self._lead_note_to_owner(card, caller, text)
1224
+ return {"card": card_id, "note": text, "delivered_to": delivered}
1225
+
1226
+ def _lead_note_to_owner(self, card, caller, text):
1227
+ owner = card.get("owner") or ""
1228
+ if not caller.is_lead or not owner or owner == caller.name or owner not in self.agents():
1229
+ return ""
1230
+ msg = M.make(
1231
+ caller.name,
1232
+ owner,
1233
+ f"lead note on {card['id']}: {text}",
1234
+ card["id"],
1235
+ "ready",
1236
+ True,
1237
+ self.agents().get(caller.name, {}).get("model", ""),
1238
+ )
1239
+ msg["source"] = "note"
1240
+ store.append_line(M.inbox_path(self.dir, owner), _dumps(msg))
1241
+ self.index.bump(self.uid)
1242
+ self._record(
1243
+ "msg",
1244
+ caller.name,
1245
+ msg["text"],
1246
+ task=card["id"],
1247
+ to=owner,
1248
+ message_id=msg["id"],
1249
+ data={"status": "ready", "urgent": True, "source": "note"},
1250
+ )
1251
+ return owner
1252
+
1253
+ @serialized_write
1254
+ def lock_paths(self, card_id, caller, paths, replace=False, remove=False):
1255
+ """Add lock paths to a card; re-grants the lease atomically when the card holds one."""
1256
+ add = parse_list(paths)
1257
+ if not add:
1258
+ raise Precondition("no paths given")
1259
+ with self.lock():
1260
+ card = self.card(card_id)
1261
+ if replace or remove:
1262
+ self._auth(caller)
1263
+ self._require_lead(caller, "replace or remove locks")
1264
+ elif card["lane"] == "todo" and not card["owner"]:
1265
+ self._auth(caller)
1266
+ self._require_lead(caller, "change locks of an unclaimed card")
1267
+ else:
1268
+ self._owner_or_lead(card, caller)
1269
+ merged = sorted(
1270
+ set(add) if replace else set(card["locks"]) - set(add) if remove else set(card["locks"]) | set(add)
1271
+ )
1272
+ lease = self.index.lease(self.uid, card_id)
1273
+ if lease and card["lane"] in model.HOLD_LANES:
1274
+ self._grant(
1275
+ card,
1276
+ card["owner"],
1277
+ merged,
1278
+ card["fence"],
1279
+ self.ttl_seconds(),
1280
+ held=card["lane"] in ("review", "blocked"),
1281
+ since=lease["since"],
1282
+ )
1283
+ self._update_lease(card_id, expires=lease["expires"])
1284
+ else:
1285
+ self._resolver_check(merged)
1286
+ card["locks"] = merged
1287
+ card["rev"] = card.get("rev", 0) + 1
1288
+ self._note(card, caller.name, "locks now " + ", ".join(merged))
1289
+ self._record(
1290
+ "status",
1291
+ caller.name,
1292
+ "locks updated",
1293
+ task=card_id,
1294
+ data={"locks": merged, "replace": replace, "remove": remove, "fence": card["fence"]},
1295
+ )
1296
+ self._save_card(card)
1297
+ return {"card": card_id, "locks": merged}
1298
+
1299
+ def _resolver_check(self, paths):
1300
+ resolver = self._resolver()
1301
+ for p in paths:
1302
+ resolver.resolve(p)
1303
+
1304
+ @serialized_write
1305
+ def release(self, card_id, caller, note=None):
1306
+ """Drop the card's lease. A doing card goes back to todo with no owner (A2A canceled)."""
1307
+ with self.lock():
1308
+ card = self.card(card_id)
1309
+ if caller.is_lead:
1310
+ self._auth(caller)
1311
+ else:
1312
+ self._owner_or_lead(card, caller)
1313
+ dropped = self._drop_lease(card_id)
1314
+ if card["lane"] == "doing":
1315
+ card.update(lane="todo", owner="", released=True)
1316
+ self._note(card, caller.name, "lease released" + (f" ({_line(note)})" if note else ""))
1317
+ self._save_card(card)
1318
+ self._record(
1319
+ "status", caller.name, "lease released", task=card_id, data={"a2a_state": model.a2a_state(card)}
1320
+ )
1321
+ return {"card": card_id, "released": dropped, "lane": card["lane"]}
1322
+
1323
+ @serialized_write
1324
+ def close_with_evidence(self, card_id, caller, evidence, note):
1325
+ self._auth(caller)
1326
+ self._require_lead(caller, "close a card with evidence")
1327
+ evidence = model.line_text(evidence, "evidence")
1328
+ sha = re.fullmatch(r"[0-9a-f]{7,40}", evidence)
1329
+ try:
1330
+ url = urlsplit(evidence)
1331
+ valid = sha or (url.scheme == "https" and bool(url.hostname) and not any(c.isspace() for c in evidence))
1332
+ except ValueError:
1333
+ valid = False
1334
+ if not valid:
1335
+ raise Precondition(
1336
+ "evidence must be an https URL or a commit in the card's repo; use --evidence https://host/path or SHA"
1337
+ )
1338
+ note = model.line_text(note, "note")
1339
+ if not note:
1340
+ raise Precondition("closing with evidence requires a note; use --note <text>")
1341
+ with self.lock():
1342
+ card = self.card(card_id)
1343
+ if card["lane"] not in ("todo", "doing", "review", "blocked"):
1344
+ raise Precondition(f"{card_id} is already {card['lane']}")
1345
+ if sha:
1346
+ evidence = "commit:" + self._evidence_commit(card, evidence)
1347
+ self._drop_lease(card_id)
1348
+ card.update(lane="done", evidence=evidence, rev=card.get("rev", 0) + 1)
1349
+ text = f"closed with evidence {evidence}: {note}"
1350
+ self._note(card, caller.name, text)
1351
+ self._save_card(card)
1352
+ self._record(
1353
+ "status",
1354
+ caller.name,
1355
+ text,
1356
+ task=card_id,
1357
+ data={"lane": "done", "a2a_state": "completed", "evidence": evidence, "note": note},
1358
+ )
1359
+ return {"card": card_id, "lane": "done", "a2a_state": "completed", "evidence": evidence}
1360
+
1361
+ def _evidence_commit(self, card, ref):
1362
+ repo = self._card_repo(card)
1363
+ if not repo:
1364
+ raise Precondition(f"{card['id']} has no repo to find commit {ref} in; set one with raid edit --repo")
1365
+ from ..runners import workspace
1366
+
1367
+ found = workspace.git_run(str(repo), "rev-parse", "--verify", "--quiet", ref + "^{commit}", timeout=30)
1368
+ if found.returncode:
1369
+ raise Precondition(f"commit {ref} is not in {repo}; use a commit from the card's repo or an https URL")
1370
+ return found.stdout.strip()
1371
+
1372
+ @serialized_write
1373
+ def cancel(self, card_id, caller, reason):
1374
+ self._auth(caller)
1375
+ self._require_lead(caller, "cancel a card")
1376
+ reason = model.line_text(reason, "reason")
1377
+ if not reason:
1378
+ raise Precondition("cancellation requires a reason")
1379
+ with self.lock():
1380
+ card = self.card(card_id)
1381
+ if card["lane"] in ("done", "cancelled"):
1382
+ raise Precondition(f"{card_id} is already {card['lane']}")
1383
+ self._drop_lease(card_id)
1384
+ card.update(lane="cancelled", cancel_reason=reason, rev=card.get("rev", 0) + 1)
1385
+ self._note(card, caller.name, "cancelled: " + reason)
1386
+ self._save_card(card)
1387
+ self._record(
1388
+ "status",
1389
+ caller.name,
1390
+ "cancelled: " + reason,
1391
+ task=card_id,
1392
+ data={"lane": "cancelled", "a2a_state": "canceled", "reason": reason},
1393
+ )
1394
+ return {"card": card_id, "lane": "cancelled", "reason": reason}
1395
+
1396
+ @serialized_write
1397
+ def move(self, card_id, lane, caller, note=None):
1398
+ """Move a card. doing is reachable only through claim; done is lead-only and terminal."""
1399
+ model.lane(lane)
1400
+ note = _line(note) if note else ""
1401
+ if lane == "cancelled":
1402
+ raise Precondition(f"use raidlock cancel {card_id} --reason <reason>")
1403
+ if lane == "doing":
1404
+ raise Denied("only claim moves a card into doing", card=card_id, hint=f"raidlock claim {card_id}")
1405
+ with self.lock():
1406
+ card = self.card(card_id)
1407
+ if card["lane"] in ("done", "cancelled"):
1408
+ raise Precondition(
1409
+ f"{card_id} is {card['lane']}", card=card_id, hint=f"raidlock reopen {card_id} (lead)"
1410
+ )
1411
+ if card["lane"] == lane:
1412
+ raise Precondition(f"{card_id} is already in {lane}", card=card_id)
1413
+ role = self._move_checks(card, lane, caller)
1414
+ self._apply_move(card, lane, role)
1415
+ self._note(card, caller.name, f"-> {lane}" + (f" ({note})" if note else ""))
1416
+ self._save_card(card)
1417
+ self._record(
1418
+ "status",
1419
+ caller.name,
1420
+ f"{card_id} -> {lane}" + (f": {note}" if note else ""),
1421
+ task=card_id,
1422
+ data={"a2a_state": model.a2a_state(card), "lane": lane},
1423
+ )
1424
+ return {"card": card_id, "lane": lane, "a2a_state": model.a2a_state(card), "owner": card["owner"]}
1425
+
1426
+ def _move_checks(self, card, lane, caller):
1427
+ if lane == "done":
1428
+ self._auth(caller)
1429
+ self._require_lead(caller, "move a card to done")
1430
+ self._done_gate(card)
1431
+ return "lead"
1432
+ if lane in ("review", "blocked") and card["lane"] == "todo":
1433
+ raise Precondition(
1434
+ f"{card['id']} is in todo; run raidlock claim {card['id']} before moving to {lane}",
1435
+ card=card["id"],
1436
+ hint=f"raidlock claim {card['id']}",
1437
+ )
1438
+ return self._owner_or_lead(card, caller)
1439
+
1440
+ def _done_gate(self, card):
1441
+ from ..runners import workspace
1442
+ from . import receipts
1443
+
1444
+ settings = self.settings
1445
+ explicit_off = "require_verify" in self.meta.get("explicit_settings", []) and not settings["require_verify"]
1446
+ if not explicit_off and (settings["require_verify"] or card.get("verify_cmd")):
1447
+ last = model.last_verification(card)
1448
+ if not last or not receipts.valid(self, card, last):
1449
+ raise Precondition(
1450
+ f"{card['id']} needs a valid verification receipt", card=card["id"], hint=self._verify_hint(card)
1451
+ )
1452
+ if not last.get("passed") or last.get("exit") != 0:
1453
+ raise Precondition(
1454
+ f"the last verify run of {card['id']} failed",
1455
+ card=card["id"],
1456
+ hint=f"fix the work, commit, then raidlock verify {card['id']}",
1457
+ )
1458
+ where = last.get("cwd")
1459
+ if not where:
1460
+ raise Precondition(f"{card['id']} verification receipt has no checkout")
1461
+ actual = self._verification_repo(card, where)
1462
+ leased_info = (self.index.lease(self.uid, card["id"]) or {}).get("checkout") or {}
1463
+ leased = leased_info.get("path")
1464
+ base = leased_info.get("runner_base")
1465
+ landed = bool(base) and workspace.is_ancestor(where, base, last.get("sha", ""))
1466
+ if leased and (
1467
+ self._verification_repo(card, leased) != actual
1468
+ or (not landed and workspace.head(leased) != last.get("sha"))
1469
+ ):
1470
+ raise Precondition(f"{card['id']} verification receipt does not match the leased repo commit")
1471
+ if last.get("repo") != actual:
1472
+ raise Precondition(f"{card['id']} verification receipt repo does not match the card")
1473
+ if last.get("sha") != workspace.head(where):
1474
+ raise Precondition(f"{card['id']} verification receipt SHA does not match current HEAD")
1475
+ need = dict(self.settings["require_review"])
1476
+ if card.get("allow_protected"):
1477
+ need.update(count=max(1, need["count"]), other_provider=True)
1478
+ got = self.approvals(card)
1479
+ if len(got) < need["count"]:
1480
+ raise Precondition(
1481
+ f"{card['id']} needs {need['count']} approving review(s)"
1482
+ + (" from another provider" if need["other_provider"] else "")
1483
+ + f"; has {len(got)}",
1484
+ card=card["id"],
1485
+ approvals=got,
1486
+ hint=f"a reviewer on {'another' if need['other_provider'] else 'any'} provider runs raidlock review "
1487
+ f"{card['id']} --verdict approve; to request one: "
1488
+ + lead_cmd(self, f"review {card['id']} --assign <reviewer>"),
1489
+ )
1490
+
1491
+ def _apply_move(self, card, lane, role):
1492
+ if lane in ("done", "todo"):
1493
+ self._drop_lease(card["id"])
1494
+ if lane == "todo":
1495
+ card.update(owner="", released=True)
1496
+ if lane in ("review", "blocked"):
1497
+ lease = self.index.lease(self.uid, card["id"])
1498
+ if lease and card["lane"] not in ("review", "blocked"):
1499
+ lease["keys"] = [{**key, "advisory": True} for key in lease["keys"]]
1500
+ lease.update(expires=clock.later(float(self.settings["hold_minutes"]) * 60), renewed=clock.iso())
1501
+ self.index.put_lease(self.uid, lease)
1502
+ if role == "owner":
1503
+ card["rev"] = card.get("rev", 0) + 1
1504
+ card["lane"] = lane
1505
+
1506
+ def approvals(self, card, agents=None):
1507
+ """Approving reviewers that count toward `require_review`: latest verdict per reviewer, after the owner's
1508
+ latest change."""
1509
+ need = dict(self.settings["require_review"])
1510
+ if card.get("allow_protected"):
1511
+ need["other_provider"] = True
1512
+ agents = self.agents() if agents is None else agents
1513
+ owner_provider = agents.get(card.get("owner"), {}).get("provider", "")
1514
+ latest = {}
1515
+ for r in card.get("reviews", []):
1516
+ if r.get("rev", -1) == card.get("rev", 0) and r["verdict"] != "comment":
1517
+ latest[r["by"]] = r
1518
+ out = []
1519
+ for name, r in sorted(latest.items()):
1520
+ if r["verdict"] != "approve" or name == card.get("owner"):
1521
+ continue
1522
+ if card.get("allow_protected") and (not owner_provider or not r.get("provider")):
1523
+ continue
1524
+ if need["other_provider"] and owner_provider and r.get("provider") == owner_provider:
1525
+ continue
1526
+ out.append(name)
1527
+ return out
1528
+
1529
+ @serialized_write
1530
+ def reopen(self, card_id, caller, note=None):
1531
+ """Lead-only: move a done card back to todo."""
1532
+ self._auth(caller)
1533
+ self._require_lead(caller, "reopen a card")
1534
+ with self.lock():
1535
+ card = self.card(card_id)
1536
+ if card["lane"] not in ("done", "cancelled"):
1537
+ raise Precondition(f"{card_id} is in {card['lane']}, not done", card=card_id)
1538
+ card.update(lane="todo", owner="", released=False, rev=card.get("rev", 0) + 1)
1539
+ card.pop("cancel_reason", None)
1540
+ card.pop("evidence", None)
1541
+ self._note(card, caller.name, "reopened" + (f" ({_line(note)})" if note else ""))
1542
+ self._save_card(card)
1543
+ self._record("status", caller.name, f"{card_id} reopened", task=card_id, data={"a2a_state": "submitted"})
1544
+ return {"card": card_id, "lane": "todo"}
1545
+
1546
+ @serialized_write
1547
+ def review(self, card_id, caller, verdict, note=""):
1548
+ """Record a review of a card in review. `changes` sends it back to doing (owner and fence unchanged)."""
1549
+ if verdict not in model.VERDICTS:
1550
+ raise Precondition(f"verdict must be one of {', '.join(model.VERDICTS)}", verdict=verdict)
1551
+ note = model.line_text(T.redact(note or ""), "note")
1552
+ with self.lock():
1553
+ self._auth(caller)
1554
+ card = self.card(card_id)
1555
+ if card["lane"] != "review":
1556
+ raise Precondition(f"{card_id} is in {card['lane']}, not review", card=card_id)
1557
+ if caller.name == card["owner"]:
1558
+ raise Denied("owners cannot review their own card", card=card_id)
1559
+ if card.get("reviewers") and caller.name not in card["reviewers"] and not caller.is_lead:
1560
+ raise Denied(f"{card_id} is assigned to reviewers {', '.join(card['reviewers'])}", card=card_id)
1561
+ agent = self.agents().get(caller.name, {})
1562
+ rec = {
1563
+ "ts": clock.iso(),
1564
+ "by": caller.name,
1565
+ "verdict": verdict,
1566
+ "note": note,
1567
+ "rev": card.get("rev", 0),
1568
+ "provider": agent.get("provider", ""),
1569
+ "model": agent.get("model", ""),
1570
+ }
1571
+ card.setdefault("reviews", []).append(rec)
1572
+ self._note(card, caller.name, f"review {verdict}" + (f": {note}" if note else ""))
1573
+ if verdict == "changes":
1574
+ self._back_to_doing(card)
1575
+ self._save_card(card)
1576
+ self._record(
1577
+ "review",
1578
+ caller.name,
1579
+ note,
1580
+ task=card_id,
1581
+ to=card["owner"],
1582
+ artifacts=[T.artifact("review", note, data={"verdict": verdict, "rev": rec["rev"]}, kind="review")],
1583
+ )
1584
+ return {"card": card_id, "verdict": verdict, "lane": card["lane"], "approvals": self.approvals(card)}
1585
+
1586
+ def _back_to_doing(self, card):
1587
+ if not card["owner"]:
1588
+ card["lane"] = "todo"
1589
+ return
1590
+ if not self.index.lease(self.uid, card["id"]):
1591
+ card["lane"] = "blocked"
1592
+ return
1593
+ card["lane"] = "doing"
1594
+ try:
1595
+ self._grant(card, card["owner"], card["locks"], card["fence"], self.ttl_seconds())
1596
+ except Denied:
1597
+ card["lane"] = "blocked"
1598
+
1599
+ @serialized_write
1600
+ def assign_reviewers(self, card_id, caller, names):
1601
+ """Owner (with fence) or lead sets the card's reviewers. Returns warnings for same-provider reviewers."""
1602
+ names = parse_words(names)
1603
+ with self.lock():
1604
+ card = self.card(card_id)
1605
+ self._owner_or_lead(card, caller)
1606
+ agents = self.agents()
1607
+ for n in names:
1608
+ if n not in agents:
1609
+ raise Precondition(f"{n} has not joined", agent=n)
1610
+ if n == card["owner"]:
1611
+ raise Precondition("the owner cannot review its own card", agent=n)
1612
+ owner_provider = agents.get(card["owner"], {}).get("provider", "")
1613
+ warnings = [
1614
+ f"{n} has the same provider as the owner ({owner_provider})"
1615
+ for n in names
1616
+ if owner_provider and agents[n].get("provider") == owner_provider
1617
+ ]
1618
+ card["reviewers"] = names
1619
+ self._note(card, caller.name, "reviewers: " + (", ".join(names) or "-"))
1620
+ self._save_card(card)
1621
+ return {"card": card_id, "reviewers": names, "warnings": warnings}
1622
+
1623
+ def _card_repo(self, card):
1624
+ from ..runners import policy
1625
+
1626
+ if card.get("repo"):
1627
+ target = self._resolver().repos.get(card["repo"])
1628
+ if not target:
1629
+ raise Precondition(f"unknown workspace repo {card['repo']!r}")
1630
+ if card.get("locks") and policy.target(self.workspace, self.dir, card["locks"])["path"] != target:
1631
+ raise Precondition("card repo and locks name different repositories")
1632
+ return target
1633
+ if card.get("locks"):
1634
+ return policy.target(self.workspace, self.dir, card["locks"])["path"]
1635
+ found = self._resolver().repos
1636
+ return repos.find_root(self.workspace) or (next(iter(found.values())) if len(found) == 1 else None)
1637
+
1638
+ def _verification_source(self, card):
1639
+ lease = self.index.lease(self.uid, card["id"]) or {}
1640
+ target = self._card_repo(card)
1641
+ root = repos.find_root(self.workspace)
1642
+ default = root if root and target and repos.identity(root) == repos.identity(target) else target
1643
+ return (
1644
+ (lease.get("checkout") or {}).get("path") or card.get("verification_checkout") or default or self.workspace
1645
+ )
1646
+
1647
+ def trust_checkout(self, caller, card_id, checkout, source):
1648
+ with self.lead_write(caller, "record checkout provenance"):
1649
+ expected = self._verification_repo(self.card(card_id), source)
1650
+ root = repos.find_root(checkout)
1651
+ if not root:
1652
+ raise Precondition("trusted checkout must be a repository")
1653
+ info = os.stat(root)
1654
+ path = self._p("checkout-provenance.json")
1655
+ records = store.read_json(path, {}) or {}
1656
+ records[os.path.realpath(root)] = {
1657
+ "repo": expected,
1658
+ "identity": repos.identity(root),
1659
+ "device": info.st_dev,
1660
+ "inode": info.st_ino,
1661
+ }
1662
+ store.write_json(path, records)
1663
+
1664
+ def _verification_repo(self, card, where):
1665
+ target = self._card_repo(card)
1666
+ root = repos.find_root(where)
1667
+ if not target:
1668
+ raise Precondition(
1669
+ "the card has no repo to verify",
1670
+ hint=f"raidlock edit {card['id']} --repo <workspace-repo>; then verify with --cwd <checkout>",
1671
+ )
1672
+ expected = repos.identity(target)
1673
+ if root and repos.identity(root) == expected:
1674
+ return expected
1675
+ if root:
1676
+ provenance = store.read_json(os.path.join(self.dir, "checkout-provenance.json"), {}) or {}
1677
+ record = provenance.get(os.path.realpath(root), {})
1678
+ info = os.stat(root)
1679
+ if record == {
1680
+ "repo": expected,
1681
+ "identity": repos.identity(root),
1682
+ "device": info.st_dev,
1683
+ "inode": info.st_ino,
1684
+ }:
1685
+ return expected
1686
+ common = os.path.realpath(repos.common_dir(repos.git_dir(root)))
1687
+ target_common = os.path.realpath(repos.common_dir(repos.git_dir(target)))
1688
+ if common == target_common:
1689
+ return expected
1690
+ url = repos._read_remotes(os.path.join(common, "config")).get("origin", "")
1691
+ parts = urlsplit(url)
1692
+ from urllib.request import url2pathname
1693
+
1694
+ local = url2pathname(parts.path) if parts.scheme == "file" and parts.netloc in ("", "localhost") else url
1695
+ if local and not os.path.isabs(local):
1696
+ local = os.path.join(root, local)
1697
+ if (
1698
+ not repos.remote_url(target)
1699
+ and local
1700
+ and os.path.isdir(local)
1701
+ and os.path.realpath(local) == os.path.realpath(target)
1702
+ ):
1703
+ return expected
1704
+ raise Precondition(
1705
+ f"verification checkout {where} does not belong to the card's repo ({target})",
1706
+ hint=f"use --checkout {target} for claim, or --cwd {target} for verify",
1707
+ )
1708
+
1709
+ def verify(self, card_id, caller, timeout=600, cwd=None):
1710
+ from ..runners import isolation, verifycache, workspace
1711
+ from . import receipts
1712
+
1713
+ self._auth(caller)
1714
+ self._require_lead(caller, "run verify")
1715
+ card = self.card(card_id)
1716
+ cmd = card.get("verify_cmd")
1717
+ if not cmd:
1718
+ raise Precondition(
1719
+ f"{card_id} has no verify command",
1720
+ card=card_id,
1721
+ hint=lead_cmd(self, f'edit {card_id} --verify-cmd "<command>"'),
1722
+ )
1723
+ kind = isolation.mode()
1724
+ if kind is None:
1725
+ raise Precondition("verification requires an isolated verification environment")
1726
+ where = os.path.realpath(cwd or self._verification_source(card))
1727
+ if workspace.git(where, "status", "--porcelain", "--untracked-files=all").strip():
1728
+ raise Precondition(
1729
+ "verification checkout is dirty; receipts are bound to a commit",
1730
+ hint=f"raidlock verify {card_id} --cwd <clean checkout of the card branch>",
1731
+ )
1732
+ repo = self._verification_repo(card, where)
1733
+ sha = workspace.head(where)
1734
+ with verifycache.checkout(where, sha, os.path.join(self.home, "verify"), timeout=timeout + 60) as (
1735
+ clone,
1736
+ gitdir,
1737
+ ):
1738
+ exit_code, tail, ms = isolation.run(
1739
+ cmd,
1740
+ clone,
1741
+ self.env,
1742
+ timeout,
1743
+ kind=kind,
1744
+ deny_read=isolation.hidden(self.env, [self.home, self.dir]),
1745
+ readable=[gitdir],
1746
+ redact=T.redact,
1747
+ )
1748
+ rec = {
1749
+ "ts": clock.iso(),
1750
+ "by": caller.name,
1751
+ "cmd": cmd,
1752
+ "cwd": where,
1753
+ "repo": repo,
1754
+ "exit": exit_code,
1755
+ "passed": exit_code == 0,
1756
+ "tail": tail,
1757
+ "duration_ms": ms,
1758
+ "rev": card.get("rev", 0),
1759
+ "card": card_id,
1760
+ "board": self.uid,
1761
+ "sha": sha,
1762
+ "command_hash": receipts.command_hash(cmd),
1763
+ "runner": "raidlock",
1764
+ "isolation": kind,
1765
+ "owner": card.get("owner", ""),
1766
+ }
1767
+ with self.lock():
1768
+ self._require_lead(caller, "record verify result")
1769
+ current = self.card(card_id)
1770
+ if current.get("verify_cmd") != cmd:
1771
+ raise Precondition("verify command changed during verification")
1772
+ rec = receipts.sign(self, rec)
1773
+ current["verification_checkout"] = where
1774
+ current.setdefault("verifications", []).append(rec)
1775
+ self._note(current, caller.name, f"verify {'passed' if rec['passed'] else 'failed'} (exit {exit_code})")
1776
+ self._save_card(current)
1777
+ self._record(
1778
+ "verify",
1779
+ caller.name,
1780
+ f"verify {'passed' if rec['passed'] else 'failed'}: {cmd}",
1781
+ task=card_id,
1782
+ artifacts=[T.artifact("verify", tail, data=rec, kind="verify")],
1783
+ )
1784
+ return {"card": card_id, **rec}
1785
+
1786
+ def reap(self, caller=None):
1787
+ """Drop expired leases; doing cards become blocked, review/blocked cards keep their lane."""
1788
+ reaped = []
1789
+ with self.lock():
1790
+ leases = self.leases()
1791
+ for card_id, lease in sorted(leases.items()):
1792
+ try:
1793
+ card = self.card(card_id)
1794
+ except Precondition:
1795
+ self._drop_lease(card_id)
1796
+ continue
1797
+ if card["lane"] in ("doing", "review", "blocked") and not L.is_live(lease):
1798
+ self._drop_lease(card_id)
1799
+ if card["lane"] == "doing":
1800
+ card["lane"] = "blocked"
1801
+ self._note(
1802
+ card,
1803
+ "raidlock",
1804
+ f"lease expired; lane {card['lane']} ({card['owner']} may claim again, "
1805
+ "a teammate may claim --take-over)",
1806
+ )
1807
+ self._save_card(card)
1808
+ self._record(
1809
+ "status",
1810
+ card["owner"],
1811
+ f"lease expired; reaped in {card['lane']}",
1812
+ task=card_id,
1813
+ data={"a2a_state": "input-required"},
1814
+ )
1815
+ reaped.append(card_id)
1816
+ return {"reaped": reaped}
1817
+
1818
+ def claimable(self, agent=None, for_review=False, cards=None):
1819
+ """{card_id: {ok, reasons, takeover}} for open cards, or cards awaiting review when `for_review`."""
1820
+ cards = self.cards() if cards is None else cards
1821
+ if for_review:
1822
+ return self._review_queue(cards, agent)
1823
+ agents, teams, leases = self.agents(), self.teams(), self.leases()
1824
+ out = {}
1825
+ for card in cards:
1826
+ if card["lane"] in ("done", "review"):
1827
+ continue
1828
+ lease = leases.get(card["id"])
1829
+ if card["lane"] == "doing" and lease and L.is_live(lease) and card["owner"] != agent:
1830
+ continue
1831
+ try:
1832
+ reasons, takeover, _ = self._claim_reasons(card, agent, agents, teams, lease=lease or {})
1833
+ except Precondition as e:
1834
+ reasons, takeover = [(3, str(e))], ""
1835
+ out[card["id"]] = {
1836
+ "ok": not reasons,
1837
+ "reasons": [r for _, r in reasons],
1838
+ "takeover": takeover,
1839
+ "title": card["title"],
1840
+ "lane": card["lane"],
1841
+ }
1842
+ return out
1843
+
1844
+ def _review_queue(self, cards, agent):
1845
+ out = {}
1846
+ for card in cards:
1847
+ if card["lane"] != "review" or (agent and agent == card["owner"]):
1848
+ continue
1849
+ if agent and card.get("reviewers") and agent not in card["reviewers"]:
1850
+ continue
1851
+ out[card["id"]] = {
1852
+ "ok": True,
1853
+ "reasons": [],
1854
+ "takeover": "",
1855
+ "title": card["title"],
1856
+ "lane": "review",
1857
+ "owner": card["owner"],
1858
+ "reviewers": card.get("reviewers", []),
1859
+ "approvals": self.approvals(card),
1860
+ }
1861
+ return out
1862
+
1863
+ def check_paths(self, paths, card_id=None):
1864
+ """Conflicts for lock paths against every live lease in every registered workspace."""
1865
+ return self.index.conflicts(self._keys(parse_list(paths)), self.uid, card_id or "")
1866
+
1867
+ @serialized_write
1868
+ def check_write(self, path, caller):
1869
+ """Enforcement decision for writing `path` (absolute or relative to cwd): {allowed, reason, card, holder}.
1870
+
1871
+ A denial for a named caller is recorded as a transcript status event with data.event lock_violation."""
1872
+ decision = self._write_decision(path, caller)
1873
+ if not decision["allowed"] and caller.name:
1874
+ with self.lock():
1875
+ self._record(
1876
+ "status",
1877
+ caller.name,
1878
+ f"write denied: {decision['reason']}",
1879
+ task=decision["card"],
1880
+ data={
1881
+ "event": "lock_violation",
1882
+ "agent": caller.name,
1883
+ "path": os.path.abspath(path),
1884
+ "holder": decision["holder"],
1885
+ "reason": decision["reason"],
1886
+ },
1887
+ )
1888
+ return decision
1889
+
1890
+ def _write_decision(self, path, caller):
1891
+ try:
1892
+ self._auth(caller, scoped_ok=True)
1893
+ except (IdentityError, Denied, Precondition, StaleLead) as exc:
1894
+ return _decision(False, exc.message)
1895
+ if not caller.authenticated:
1896
+ return _decision(False, "writes require an authenticated caller")
1897
+ full = os.path.abspath(path)
1898
+ if ".board" in full.replace("\\", "/").split("/"):
1899
+ return _decision(False, "board files are written only by raidlock")
1900
+ loc = repos.locate(full)
1901
+ if not loc:
1902
+ return _decision(True, "outside any git repo")
1903
+ mine, others = [], []
1904
+ for workspace, ws, lease in self.index.holders_of(loc):
1905
+ own = lease.get("agent") == caller.name and ws == self.uid
1906
+ (mine if own else others).append((workspace, lease["card"], lease))
1907
+ for _, card_id, lease in mine:
1908
+ if caller.scope_card and caller.scope_card != card_id:
1909
+ return _decision(False, f"capability is scoped to {caller.scope_card}", card=card_id)
1910
+ if caller.is_lead or (caller.fence and caller.fence == lease.get("fence")):
1911
+ return _decision(True, f"inside the lease of {card_id}", card=card_id, holder=caller.name)
1912
+ return _decision(False, f"stale fence {caller.fence} for {card_id}", card=card_id, holder=caller.name)
1913
+ if others:
1914
+ ws, card_id, lease = others[0]
1915
+ return _decision(
1916
+ False, f"held by {lease['agent']} on {card_id} in workspace {ws}", card=card_id, holder=lease["agent"]
1917
+ )
1918
+ if caller.is_lead:
1919
+ return _decision(True, "lead; not held by anyone")
1920
+ return _decision(False, f"{caller.name or 'caller'} holds no lease covering this path")
1921
+
1922
+ def _busy(self, name):
1923
+ return next(
1924
+ (c["id"] for c in self.index.card_rows(self.uid) if c["owner"] == name and c["lane"] == "doing"), ""
1925
+ )
1926
+
1927
+ @serialized_write
1928
+ def send(self, caller, to, text, task=None, urgent=False):
1929
+ """Send a message. Queued while the recipient is in doing on another card; ready when about its doing card,
1930
+ when urgent (lead only) or when the recipient is idle. Returns the message; `dropped` marks a repeat."""
1931
+ self._auth(caller, scoped_ok=True)
1932
+ text = model.block_text(T.redact(text), "message", M.MAX_BYTES)
1933
+ if not text.strip():
1934
+ raise Precondition("message text is empty")
1935
+ if urgent:
1936
+ self._require_lead(caller, "send urgent messages")
1937
+ with self.lock():
1938
+ if to not in self.agents():
1939
+ raise Precondition(f"no agent {to} on this board", to=to)
1940
+ if task:
1941
+ self.card(task)
1942
+ dup = M.check_limits(self.dir, caller.name, to, task or "", text, clock.now())
1943
+ if dup:
1944
+ return {**dup, "dropped": True}
1945
+ status = M.initial_status(task, urgent, self._busy(to))
1946
+ msg = M.make(
1947
+ caller.name, to, text, task, status, urgent, self.agents().get(caller.name, {}).get("model", "")
1948
+ )
1949
+ store.append_line(M.inbox_path(self.dir, to), _dumps(msg))
1950
+ self.index.bump(self.uid)
1951
+ self._touch(caller.name)
1952
+ self._record(
1953
+ "msg",
1954
+ caller.name,
1955
+ text,
1956
+ task=task or "",
1957
+ to=to,
1958
+ message_id=msg["id"],
1959
+ data={"status": status, "urgent": bool(urgent)},
1960
+ )
1961
+ return {**msg, "dropped": False}
1962
+
1963
+ def inbox(self, caller, show_all=False, wait=0.0, interval=1.0):
1964
+ """Messages for the caller: ready ones are returned and marked read; queued ones stay until it leaves doing.
1965
+ With `wait` > 0, blocks in-process until a message is ready (raises Timeout after `wait` seconds)."""
1966
+ self._auth(caller, scoped_ok=True)
1967
+ deadline = time.monotonic() + wait
1968
+ while True:
1969
+ result = self._read_inbox(caller, show_all)
1970
+ if result["messages"] or wait <= 0:
1971
+ return result
1972
+ if time.monotonic() >= deadline:
1973
+ raise Timeout(f"no message for {caller.name} after {wait:g}s", agent=caller.name)
1974
+ db.close_all()
1975
+ time.sleep(max(0.01, min(interval, deadline - time.monotonic())))
1976
+
1977
+ def _read_inbox(self, caller, show_all):
1978
+ with self.lock():
1979
+ self._auth(caller, scoped_ok=True)
1980
+ rows = M.load(self.dir, caller.name)
1981
+ busy = self._busy(caller.name)
1982
+ changed = M.promote(rows, busy)
1983
+ shown = [dict(m) for m in rows if show_all or m.get("status") == "ready"]
1984
+ for m in rows:
1985
+ if m.get("status") == "ready":
1986
+ m.update(status="read", read=clock.iso())
1987
+ changed = True
1988
+ if changed:
1989
+ M.save(self.dir, caller.name, rows)
1990
+ self.index.bump(self.uid)
1991
+ queued = sum(1 for m in rows if m.get("status") == "queued")
1992
+ return {"agent": caller.name, "messages": shown, "queued": queued, "busy": busy}
1993
+
1994
+ def take_urgent(self, agent):
1995
+ """Urgent messages for `agent` not yet read, marked read as delivered by the hook. Queued (non-urgent)
1996
+ messages stay queued. Used by the PreToolUse hook at the agent's next tool call, even in doing."""
1997
+ if not agent or agent not in self.agents():
1998
+ return []
1999
+ with self.lock():
2000
+ rows = M.load(self.dir, agent)
2001
+ out = []
2002
+ for m in rows:
2003
+ if m.get("urgent") and m.get("status") in ("ready", "queued"):
2004
+ m.update(status="read", read=clock.iso(), delivered="hook")
2005
+ out.append(dict(m))
2006
+ if out:
2007
+ M.save(self.dir, agent, rows)
2008
+ self.index.bump(self.uid)
2009
+ for m in out:
2010
+ self._record(
2011
+ "status",
2012
+ agent,
2013
+ f"urgent message {m['id']} delivered by hook",
2014
+ task=m.get("task", ""),
2015
+ data={"event": "urgent_delivered", "message_id": m["id"], "from": m.get("from", "")},
2016
+ )
2017
+ return out
2018
+
2019
+ def agent_for_session(self, session):
2020
+ """The agent whose recorded session is `session` (from join), or ""."""
2021
+ if not session:
2022
+ return ""
2023
+ hits = sorted(n for n, a in self.agents().items() if a.get("session") == session)
2024
+ return hits[0] if len(hits) == 1 else ""
2025
+
2026
+ def wait(self, card_id, timeout=1800.0, interval=5.0):
2027
+ """Block until the card leaves doing; returns its lane. Raises Timeout (exit 4)."""
2028
+ deadline = time.monotonic() + timeout
2029
+ while True:
2030
+ lane = self.card(card_id)["lane"]
2031
+ if lane != "doing":
2032
+ return {"card": card_id, "lane": lane}
2033
+ if time.monotonic() >= deadline:
2034
+ raise Timeout(f"{card_id} still in doing after {timeout:g}s", card=card_id)
2035
+ db.close_all()
2036
+ time.sleep(max(0.01, min(interval, deadline - time.monotonic())))
2037
+
2038
+ @serialized_write
2039
+ def record_cost(
2040
+ self, card_id, caller, model_id=None, usage=None, usd=None, duration_ms=0, billing=None, agent=None
2041
+ ):
2042
+ """Record one run's usage on a card; runs accumulate. `agent` (lead only when not the caller) attributes the
2043
+ run.
2044
+ Billing comes from the agent (api, plan, unknown) unless given."""
2045
+ self._auth(caller, card_id)
2046
+ name = agent or caller.name
2047
+ if name != caller.name:
2048
+ self._require_lead(caller, "record cost for another agent")
2049
+ usage = usage or C.Usage()
2050
+ if min(usage.as_dict().values()) < 0 or (usd is not None and (not math.isfinite(usd) or usd < 0)):
2051
+ raise Precondition("usage and usd must be finite and not negative")
2052
+ if billing is not None and billing not in C.BILLING:
2053
+ raise Precondition(f"billing must be one of {', '.join(C.BILLING)}")
2054
+ rec = self.agents().get(name, {})
2055
+ model_id = model_id or rec.get("model", "")
2056
+ prices = C.pricing(self.home)
2057
+ with self.lock():
2058
+ card = self.card(card_id)
2059
+ row = C.entry(
2060
+ model_id,
2061
+ usage,
2062
+ usd,
2063
+ duration_ms,
2064
+ prices,
2065
+ name,
2066
+ clock.iso(),
2067
+ billing=billing or rec.get("billing", "unknown"),
2068
+ provider=C.provider_for(model_id, prices, rec.get("provider", "")),
2069
+ )
2070
+ if any(v is not None and not math.isfinite(v) for v in (row.get("usd"), row.get("usd_equiv"))):
2071
+ raise Precondition("money must be finite")
2072
+ card.setdefault("costs", []).append(row)
2073
+ self._save_card(card)
2074
+ self.index.add_cost(self.uid, card_id, card.get("team", ""), name, row)
2075
+ self._record("cost", name, task=card_id, data=row)
2076
+ return {"card": card_id, "entry": row, "total": C.card_total(card)}
2077
+
2078
+ def spend(self, by="provider", since=None, until=None, everywhere=False):
2079
+ """Spend grouped by provider, model, agent, team, card, billing, workspace, day or week; this workspace or
2080
+ all."""
2081
+ for card in self.cards():
2082
+ for row in card.get("costs", []):
2083
+ for key in ("usd", "usd_equiv"):
2084
+ value = row.get(key)
2085
+ if value is not None and (not math.isfinite(value) or value < 0):
2086
+ raise Precondition("invalid-data: nonfinite or negative money")
2087
+ return self.index.spend(by=by, ws=None if everywhere else self.uid, since=since, until=until)
2088
+
2089
+ def costs(self):
2090
+ """Cost totals per card, per team and for the workspace."""
2091
+ return C.totals(self.cards())
2092
+
2093
+ def statuses(self, cards=None):
2094
+ cards = self.index.card_rows(self.uid) if cards is None else cards
2095
+ return S.statuses(self.agents(), cards, self.leases())
2096
+
2097
+ def who(self, cards=None):
2098
+ agents, st = self.agents(), self.statuses(cards)
2099
+ seen = self.index.last_seen(self.uid)
2100
+ spend = {r["key"]: r["usd"] for r in self.index.spend(by="agent", ws=self.uid)["rows"]}
2101
+ out = {
2102
+ name: {
2103
+ **agents.get(name, {}),
2104
+ **({"last_seen": seen[name]} if name in seen else {}),
2105
+ **s,
2106
+ "spend_usd": spend.get(name, 0.0),
2107
+ }
2108
+ for name, s in st.items()
2109
+ }
2110
+ if self.lead in out:
2111
+ lease = self.leadlease().summary()
2112
+ out[self.lead].update(
2113
+ session=lease.get("session", ""),
2114
+ lead_fence=lease.get("fence", 0),
2115
+ lead_live=lease.get("live", False),
2116
+ lead_expires=lease.get("expires", ""),
2117
+ )
2118
+ return out
2119
+
2120
+ def card_view(self, card, leases=None, agents=None):
2121
+ leases = self.leases() if leases is None else leases
2122
+ return {
2123
+ **card,
2124
+ "a2a_state": model.a2a_state(card),
2125
+ "approvals": self.approvals(card, agents),
2126
+ "cost": C.card_total(card),
2127
+ "lease": leases.get(card["id"]),
2128
+ }
2129
+
2130
+ def snapshot(self, transcript_limit=200, messages_limit=50, transcript_since=None):
2131
+ """Everything a UI needs in one read-only dict. `version` changes on every board write (use it as an ETag);
2132
+ pass the last transcript `seq` as `transcript_since` to page the transcript."""
2133
+ version = self.version()
2134
+ cards = self.cards()
2135
+ leases, agents = self.leases(), self.agents()
2136
+ busy = {c["owner"]: c["id"] for c in cards if c["lane"] == "doing" and c.get("owner")}
2137
+ msgs = sorted(M.all_messages(self.dir), key=lambda m: (m.get("sent", ""), m.get("id", "")))[-messages_limit:]
2138
+ return {
2139
+ "schema": store.SCHEMA,
2140
+ "version": version,
2141
+ "etag": f'"{version["workspace"]}-{version["global"]}"',
2142
+ "workspace": self.name,
2143
+ "board": self.dir,
2144
+ "lead": self.lead,
2145
+ "settings": self.settings,
2146
+ "warnings": self.warnings(),
2147
+ "now": clock.iso(),
2148
+ "lead_lease": self.leadlease().summary(),
2149
+ "cards": [self.card_view(c, leases, agents) for c in cards],
2150
+ "teams": self.team_list(),
2151
+ "agents": self.who(cards),
2152
+ "leases": {k: {**v, "live": L.is_live(v)} for k, v in leases.items()},
2153
+ "claimable": self.claimable(cards=cards),
2154
+ "review_queue": self.claimable(for_review=True, cards=cards),
2155
+ "inbox": M.counts(self.dir, busy),
2156
+ "messages": msgs,
2157
+ "costs": C.totals(cards),
2158
+ "transcript": self.transcript.read(limit=transcript_limit, since=transcript_since),
2159
+ }
2160
+
2161
+
2162
+ def parse_words(value):
2163
+ if not value:
2164
+ return []
2165
+ items = value if isinstance(value, (list, tuple)) else value.replace(",", " ").split()
2166
+ return [x.strip() for x in items if x and x.strip()]
2167
+
2168
+
2169
+ def _line(text):
2170
+ return model.line_text(T.redact(text), "note")
2171
+
2172
+
2173
+ def _dumps(obj):
2174
+ return json.dumps(obj, sort_keys=True, ensure_ascii=False)
2175
+
2176
+
2177
+ def _decision(allowed, reason, card="", holder=""):
2178
+ return {"allowed": allowed, "reason": reason, "card": card, "holder": holder}
2179
+
2180
+
2181
+ def _raise_reasons(card_id, reasons, clash):
2182
+ if not reasons:
2183
+ return
2184
+ text = f"cannot claim {card_id}: " + "; ".join(r for _, r in reasons)
2185
+ codes = {c for c, _ in reasons}
2186
+ cls = IdentityError if 5 in codes else Precondition if 3 in codes else Denied
2187
+ hint = (
2188
+ f"raidlock claim {card_id} --take-over"
2189
+ if any("--take-over" in r for _, r in reasons)
2190
+ else f"raidlock check {' '.join(c['path'] for c in clash)}"
2191
+ if clash
2192
+ else f"raidlock up --join --card {card_id}"
2193
+ if 5 in codes
2194
+ else "raidlock claimable"
2195
+ )
2196
+ if clash:
2197
+ hint += "; ask the lead to narrow the held lock with raidlock edit " + clash[0]["card"] + " --locks <files>"
2198
+ raise cls(text, card=card_id, reasons=[r for _, r in reasons], conflicts=clash, hint=hint)
2199
+
2200
+
2201
+ def _apply_setting(settings, key, value):
2202
+ text = str(value).strip().lower()
2203
+ if key == "lead_store":
2204
+ if text not in LD.STORES:
2205
+ raise Precondition(f"lead_store must be one of {', '.join(LD.STORES)}")
2206
+ settings["lead_store"] = text
2207
+ elif key == "auth":
2208
+ if text not in ("token", "off"):
2209
+ raise Precondition("auth must be token or off")
2210
+ settings["auth"] = text
2211
+ elif key in ("ttl_minutes", "hold_minutes"):
2212
+ settings[key] = _positive(value, key)
2213
+ elif key == "brief_max_tokens":
2214
+ settings["brief_max_tokens"] = int(_positive(value, "brief_max_tokens"))
2215
+ elif key.startswith("budget."):
2216
+ from ..budgets import _value
2217
+
2218
+ value = _value(key, value)
2219
+ if value is None:
2220
+ settings.pop(key, None)
2221
+ else:
2222
+ settings[key] = value
2223
+ elif key in PR_SETTING_KEYS:
2224
+ from ..pr import setting_value
2225
+
2226
+ value = setting_value(key, value)
2227
+ if value in ("", []):
2228
+ settings.pop(key, None)
2229
+ else:
2230
+ settings[key] = value
2231
+ elif key in ("require_review", "require_review.count"):
2232
+ settings["require_review"]["count"] = int(_positive(value, key, allow_zero=True))
2233
+ elif key in ("require_review.other_provider", "require_verify", *ENFORCE_KEYS):
2234
+ if text not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
2235
+ raise Precondition(f"{key} must be true or false")
2236
+ if key != "require_review.other_provider":
2237
+ settings[key] = text in ("true", "1", "yes", "on")
2238
+ else:
2239
+ settings["require_review"]["other_provider"] = text in ("true", "1", "yes", "on")
2240
+ else:
2241
+ raise Precondition(f"unknown setting {key}", key=key)
2242
+
2243
+
2244
+ def _positive(value, key, allow_zero=False):
2245
+ try:
2246
+ n = float(value)
2247
+ except (TypeError, ValueError):
2248
+ raise Precondition(f"{key} must be a number", key=key) from None
2249
+ if not math.isfinite(n) or n < 0 or (n == 0 and not allow_zero):
2250
+ raise Precondition(f"{key} must be positive", key=key)
2251
+ return int(n) if n == int(n) else n