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/resurrect.py ADDED
@@ -0,0 +1,419 @@
1
+ """Resurrection: hand a stalled card to a fresh agent with its resume packet, within budget and attempt limits.
2
+
3
+ Only the lead runs it: the explicit `raidlock resurrect <card>` command, or a supervisor that holds the current lead
4
+ lease running `raidlock resurrect --sweep`, which does nothing unless the lead enabled automatic resurrection. The
5
+ replacement is a new agent with a new claim and fence, launched through the runner, which strips every board
6
+ credential from its environment; the lead secret never reaches it. Every attempt is logged as an alert and
7
+ recorded with its provider, reason and cost. A card that cannot be resurrected stays blocked with the reason."""
8
+
9
+ import argparse
10
+ import datetime
11
+ import json
12
+ import os
13
+ import re
14
+ import time
15
+ import uuid
16
+
17
+ from . import duel, routing
18
+ from . import resume as R
19
+ from .budgets import check_before_delegate
20
+ from .core import clock, store
21
+ from .core import leases as L
22
+ from .core.errors import Denied, IdentityError, Precondition, RaidlockError
23
+ from .core.identity import RUNTIME_SHORT, short_runtime
24
+ from .runners.runner import ADAPTERS, MODEL_ID, delegate, run_numbers, runs_dir
25
+
26
+ FILE = "resurrect.json"
27
+ DEFAULTS = {"enabled": False, "max_attempts": 2, "cooldown_minutes": 10, "providers": [], "budget_usd": None}
28
+ QUOTA = re.compile(
29
+ r"usage limit|credit|quota|insufficient|limit reached|rate_limit_reached|exhausted|billing|out of tokens", re.I
30
+ )
31
+ TRANSIENT = re.compile(r"\b429\b|rate.?limit|overloaded|too many requests|temporarily unavailable", re.I)
32
+ ELIGIBLE = ("quota", "transient", "timeout", "lost", "expired", "stopped")
33
+
34
+
35
+ def _load(board):
36
+ data = store.read_doc(board._p(FILE), "resurrect")
37
+ if not isinstance(data, dict):
38
+ raise Precondition(f"corrupt {FILE}")
39
+ policy = dict(DEFAULTS)
40
+ for key, value in (data.get("policy") or {}).items():
41
+ policy[key] = validate(key, value)
42
+ attempts = data.get("attempts") or {}
43
+ if not isinstance(attempts, dict):
44
+ raise Precondition(f"corrupt {FILE}: attempts must be an object")
45
+ return policy, attempts
46
+
47
+
48
+ def policy(board):
49
+ return _load(board)[0]
50
+
51
+
52
+ def attempts(board, card_id):
53
+ return list(_load(board)[1].get(card_id) or [])
54
+
55
+
56
+ def _write(board, policy_value, attempts_value):
57
+ store.write_doc(board._p(FILE), "resurrect", {"policy": policy_value, "attempts": attempts_value})
58
+ board.index.bump(board.uid)
59
+
60
+
61
+ def validate(key, value):
62
+ if key == "enabled":
63
+ if not isinstance(value, bool):
64
+ raise Precondition("enabled must be true or false")
65
+ return value
66
+ if key in ("max_attempts", "cooldown_minutes"):
67
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 1000:
68
+ raise Precondition(f"{key} must be an integer from 0 to 1000")
69
+ return value
70
+ if key == "providers":
71
+ if not isinstance(value, list) or any(p not in ADAPTERS for p in value):
72
+ raise Precondition(f"providers must be a list from {', '.join(ADAPTERS)}")
73
+ return list(dict.fromkeys(value))
74
+ if key == "budget_usd":
75
+ if value is None:
76
+ return None
77
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
78
+ raise Precondition("budget_usd must be a nonnegative number or null")
79
+ return float(value)
80
+ raise Precondition(f"unknown resurrect setting {key}; known: {', '.join(DEFAULTS)}")
81
+
82
+
83
+ def set_policy(board, caller, key, value):
84
+ """Lead-only: change one resurrect setting. Turning it on is logged as an alert."""
85
+ value = validate(key, value)
86
+ with board.lead_write(caller, f"change resurrect {key}"):
87
+ current, ledger = _load(board)
88
+ current[key] = value
89
+ _write(board, current, ledger)
90
+ board._record(
91
+ "status",
92
+ caller.name,
93
+ f"{'ALERT ' if key == 'enabled' and value else ''}resurrect {key} set to {json.dumps(value)}",
94
+ data={"event": "resurrect_policy", "key": key, "value": value, "alert": key == "enabled" and value},
95
+ )
96
+ return current
97
+
98
+
99
+ def evidence(board, card):
100
+ """Why the last attempt stopped, from runner records only: (classification, detail, runtime, run dir)."""
101
+ numbers = run_numbers(board, card["id"])
102
+ for n in reversed(numbers):
103
+ folder = os.path.join(runs_dir(board, card["id"]), str(n))
104
+ info = store.read_json(os.path.join(folder, "run.json"), default=None)
105
+ started = store.read_json(os.path.join(folder, "started.json"), default=None)
106
+ if not isinstance(info, dict):
107
+ if isinstance(started, dict) and started.get("role") == "worker":
108
+ return "stopped", "the runner stopped before the agent finished", "", folder
109
+ continue
110
+ if info.get("role") != "worker":
111
+ continue
112
+ runtime = RUNTIME_SHORT.get(info.get("runtime", ""), info.get("runtime", ""))
113
+ detail = str(info.get("failure") or info.get("lost") or "")
114
+ if info.get("lost"):
115
+ return "lost", detail, runtime, folder
116
+ if not detail:
117
+ return "finished", str(info.get("reason") or ""), runtime, folder
118
+ if QUOTA.search(detail):
119
+ return "quota", detail, runtime, folder
120
+ if TRANSIENT.search(detail):
121
+ return "transient", detail, runtime, folder
122
+ if "timed out" in detail:
123
+ return "timeout", detail, runtime, folder
124
+ return "failed", detail, runtime, folder
125
+ for note in reversed(card.get("notes", [])):
126
+ if note.get("by") == "raidlock" and "lease expired" in note.get("text", ""):
127
+ return "expired", note["text"], "", ""
128
+ return "none", "", "", ""
129
+
130
+
131
+ def _alert(board, lead, card_id, text, **data):
132
+ with board.lock():
133
+ board._record("status", lead.name, "ALERT " + text, task=card_id, data={"alert": True, **data})
134
+
135
+
136
+ def _check_limits(board, card, ledger, max_attempts, budget_usd):
137
+ done = len(ledger)
138
+ if done >= max_attempts:
139
+ raise Denied(
140
+ f"maximum attempts reached for {card['id']}: {done} of {max_attempts} used, attempts exhausted",
141
+ card=card["id"],
142
+ hint=f"raidlock show {card['id']}",
143
+ )
144
+ used = check_before_delegate(board, card)
145
+ if not used["allowed"]:
146
+ raise Denied("resurrect budget exhausted: " + "; ".join(used["reasons"]), card=card["id"])
147
+ spent = used["used"]["card_usd"]
148
+ if budget_usd is not None and spent >= budget_usd:
149
+ raise Denied(
150
+ f"resurrect budget exhausted for {card['id']}: ${spent:.2f} spent of ${budget_usd:.2f}", card=card["id"]
151
+ )
152
+
153
+
154
+ def _check_state(board, card, ledger, cooldown_minutes):
155
+ cid, lane = card["id"], card["lane"]
156
+ lease = board.leases().get(cid)
157
+ if lane == "doing" and lease and L.is_live(lease):
158
+ raise Denied(f"{cid} has a live worker ({card['owner']}); resurrect never starts a second one", card=cid)
159
+ if lane in ("review", "done", "todo"):
160
+ raise Precondition(f"{cid} is in {lane}; only a stalled card (blocked or expired) is resurrected", card=cid)
161
+ record = duel.load(board, cid)
162
+ if record and record.get("status") in ("running", "decide"):
163
+ raise Precondition(f"{cid} is in a duel waiting for the lead; resurrect leaves it alone", card=cid)
164
+ if ledger and cooldown_minutes:
165
+ last = clock.parse(ledger[-1].get("ts", ""))
166
+ ready = last + datetime.timedelta(minutes=cooldown_minutes) if last else None
167
+ if ready and ready > clock.now():
168
+ raise Precondition(f"{cid} is cooling down until {clock.iso(ready)} after its last attempt", card=cid)
169
+
170
+
171
+ def _choose(board, card, pol, to, kind, failed):
172
+ if to:
173
+ if to not in ADAPTERS:
174
+ raise Precondition(f"--to must be one of {', '.join(ADAPTERS)}")
175
+ candidates = [to]
176
+ else:
177
+ candidates = list(pol["providers"]) or ([failed] if failed else [])
178
+ if kind == "quota" and not to:
179
+ candidates = [c for c in candidates if c != failed]
180
+ if not candidates:
181
+ return "", "no allowed provider: set resurrect providers or pass --to"
182
+ cfg = routing.config(board)
183
+ collected = routing.LM.collect()
184
+ reasons = []
185
+ for c in candidates:
186
+ room = routing.headroom(c, cfg, collected)
187
+ if room["ok"]:
188
+ return c, room["reason"]
189
+ reasons.append(room["reason"])
190
+ return "", "; ".join(reasons)
191
+
192
+
193
+ def _name(n, runtime, card_id):
194
+ return f"res{n}-{RUNTIME_SHORT.get(ADAPTERS[runtime].runtime, runtime)}-{card_id}"[:40].rstrip("-")
195
+
196
+
197
+ def attempt(
198
+ board,
199
+ lead,
200
+ card_id,
201
+ to=None,
202
+ model=None,
203
+ cmd=None,
204
+ max_attempts=None,
205
+ budget_usd=None,
206
+ reason="manual",
207
+ dry_run=False,
208
+ timeout=3600.0,
209
+ heartbeat=60.0,
210
+ ):
211
+ """Resurrect one card. Limits are checked first, then the card state, then the provider. Lead only."""
212
+ if not lead.is_lead:
213
+ raise IdentityError("only the lead (or a supervisor holding the lead lease) resurrects cards")
214
+ with board.lead_write(lead, f"consider resurrecting {card_id}"):
215
+ pol, ledger_all = _load(board)
216
+ card = board.card(card_id)
217
+ ledger = list(ledger_all.get(card_id) or [])
218
+ if model and not MODEL_ID.match(model):
219
+ raise Precondition(f"invalid model id {model!r}")
220
+ cap = pol["max_attempts"] if max_attempts is None else validate("max_attempts", max_attempts)
221
+ budget = pol["budget_usd"] if budget_usd is None else validate("budget_usd", budget_usd)
222
+ try:
223
+ _check_limits(board, card, ledger, cap, budget)
224
+ except Denied as e:
225
+ _alert(board, lead, card_id, f"resurrect refused for {card_id}: {e.message}", event="resurrect_refused")
226
+ raise
227
+ board.reap()
228
+ card = board.card(card_id)
229
+ _check_state(board, card, ledger, pol["cooldown_minutes"])
230
+ kind, detail, failed, _ = evidence(board, card)
231
+ provider, why = _choose(board, card, pol, to, kind, failed)
232
+ plan = {
233
+ "card": card_id,
234
+ "attempt": len(ledger) + 1,
235
+ "max_attempts": cap,
236
+ "classification": kind,
237
+ "evidence": detail[:300],
238
+ "from_agent": card.get("owner", ""),
239
+ "from_provider": failed,
240
+ "to": provider,
241
+ "why": why,
242
+ "reason": reason,
243
+ }
244
+ if not provider:
245
+ board.note(card_id, lead, f"resurrect stuck: {why}"[:1900])
246
+ _alert(board, lead, card_id, f"resurrect stuck on {card_id}: {why}", event="resurrect_stuck")
247
+ return {**plan, "status": "stuck"}
248
+ if dry_run:
249
+ return {**plan, "status": "planned"}
250
+ if provider == "shell" and not cmd:
251
+ raise Precondition("resurrecting with shell needs --cmd")
252
+ packet = R.build(board, lead, card_id)
253
+ plan["packet"] = packet["dir"]
254
+ plan["packet_status"] = packet["status"]
255
+ name = _name(plan["attempt"], provider, card_id)
256
+ entry = {"id": uuid.uuid4().hex, **plan, "ts": clock.iso(), "agent": name, "status": "launched"}
257
+ ledger_all.setdefault(card_id, []).append(entry)
258
+ _write(board, pol, ledger_all)
259
+ board.move(card_id, "todo", lead, note=f"resurrect: attempt {plan['attempt']} replaces {card['owner'] or '-'}")
260
+ _alert(
261
+ board,
262
+ lead,
263
+ card_id,
264
+ f"RESURRECT {card_id}: attempt {plan['attempt']}/{cap} to {provider} as {name} "
265
+ f"(was {plan['from_agent'] or '-'} on {failed or '-'}, {kind})",
266
+ event="resurrect",
267
+ attempt=plan["attempt"],
268
+ to=provider,
269
+ agent=name,
270
+ classification=kind,
271
+ )
272
+ try:
273
+ result = delegate(
274
+ board,
275
+ lead,
276
+ card_id,
277
+ provider,
278
+ model=model,
279
+ cmd=cmd,
280
+ timeout=timeout,
281
+ heartbeat=heartbeat,
282
+ agent=name,
283
+ resume={k: packet[k] for k in ("text", "patch", "base")},
284
+ )
285
+ outcome = {"status": "ran", "lane": result.get("lane", ""), "usd": result.get("usd")}
286
+ outcome["run_dir"] = result.get("run_dir", "")
287
+ outcome["result"] = result.get("reason") or result.get("status", "")
288
+ except RaidlockError as e:
289
+ outcome = {"status": "error", "lane": board.card(card_id)["lane"], "result": e.message}
290
+ board.note(card_id, lead, f"resurrect attempt {plan['attempt']} failed to launch: {e.message}"[:1900])
291
+ _alert(board, lead, card_id, f"resurrect launch failed on {card_id}: {e.message}", event="resurrect_error")
292
+ with board.lead_write(lead, f"record resurrect result on {card_id}"):
293
+ pol_now, ledger_now = _load(board)
294
+ rows = ledger_now.get(card_id) or []
295
+ row = next(row for row in rows if row.get("id") == entry["id"])
296
+ row.update(outcome)
297
+ _write(board, pol_now, ledger_now)
298
+ return {**entry, **outcome}
299
+
300
+
301
+ def sweep(board, lead, **kw):
302
+ """One supervisor pass: resurrect eligible stalled cards when enabled. Returns what it did and skipped."""
303
+ if not lead.is_lead:
304
+ raise IdentityError("the sweep runs as the lead: hand the lead lease to the supervisor session first")
305
+ with board.lead_write(lead, "resurrect sweep"):
306
+ pol = _load(board)[0]
307
+ if not pol["enabled"]:
308
+ return {"enabled": False, "acted": [], "skipped": []}
309
+ board.reap()
310
+ acted, skipped = [], []
311
+ for card in board.cards():
312
+ if card["lane"] != "blocked":
313
+ continue
314
+ kind = evidence(board, card)[0]
315
+ if kind not in ELIGIBLE:
316
+ skipped.append({"card": card["id"], "reason": f"not resurrectable ({kind}); needs the lead"})
317
+ continue
318
+ try:
319
+ acted.append(attempt(board, lead, card["id"], reason="sweep", **kw))
320
+ except RaidlockError as e:
321
+ skipped.append({"card": card["id"], "reason": e.message})
322
+ return {"enabled": True, "acted": acted, "skipped": skipped}
323
+
324
+
325
+ def _setup(p):
326
+ p.add_argument("card", nargs="?", help="card id (omit with --sweep)")
327
+ p.add_argument(
328
+ "--to", default=None, type=short_runtime, choices=sorted(ADAPTERS), help="runtime for the replacement"
329
+ )
330
+ p.add_argument("--model", default=None)
331
+ p.add_argument("--cmd", default=None, help="command for --to shell")
332
+ p.add_argument("--max-attempts", type=int, default=None, help="override the attempt limit for this call")
333
+ p.add_argument("--budget", type=float, default=None, help="card spend in USD above which resurrect refuses")
334
+ p.add_argument("--dry-run", action="store_true", help="show the plan without changing anything")
335
+ p.add_argument("--sweep", action="store_true", help="supervisor pass over stalled cards (needs enabled policy)")
336
+ p.add_argument("--watch", type=float, default=0, help="with --sweep: repeat every N seconds")
337
+ p.add_argument("--iterations", type=int, default=0, help=argparse.SUPPRESS)
338
+ p.add_argument("--timeout", type=float, default=3600.0, help=argparse.SUPPRESS)
339
+ p.add_argument("--heartbeat", type=float, default=60.0, help=argparse.SUPPRESS)
340
+
341
+
342
+ def _handler(ctx, a):
343
+ kw = {"timeout": a.timeout, "heartbeat": a.heartbeat}
344
+ if a.sweep:
345
+ if a.card:
346
+ raise Precondition("--sweep takes no card")
347
+ kw.update(
348
+ {
349
+ k: v
350
+ for k, v in {
351
+ "to": a.to,
352
+ "model": a.model,
353
+ "cmd": a.cmd,
354
+ "max_attempts": a.max_attempts,
355
+ "budget_usd": a.budget,
356
+ "dry_run": a.dry_run,
357
+ }.items()
358
+ if v not in (None, False)
359
+ }
360
+ )
361
+ n = 0
362
+ while True:
363
+ r = sweep(ctx.board, ctx.caller(), **kw)
364
+ n += 1
365
+ if not a.watch or (a.iterations and n >= a.iterations):
366
+ break
367
+ time.sleep(a.watch)
368
+ if not r["enabled"]:
369
+ return r, "automatic resurrection is off.\nNext: raidlock resurrect-policy set enabled true (lead)"
370
+ lines = [f"resurrected {x['card']} -> {x.get('to')} ({x.get('status')})" for x in r["acted"]]
371
+ lines += [f"skipped {x['card']}: {x['reason']}" for x in r["skipped"]]
372
+ return r, "\n".join(lines) or "nothing stalled"
373
+ if not a.card:
374
+ raise Precondition("give a card id or --sweep")
375
+ r = attempt(
376
+ ctx.board,
377
+ ctx.caller(),
378
+ a.card,
379
+ to=a.to,
380
+ model=a.model,
381
+ cmd=a.cmd,
382
+ max_attempts=a.max_attempts,
383
+ budget_usd=a.budget,
384
+ dry_run=a.dry_run,
385
+ **kw,
386
+ )
387
+ text = f"{a.card}: {r['status']} (attempt {r['attempt']}/{r['max_attempts']}, {r['classification']})"
388
+ if r.get("to"):
389
+ text += f" -> {r['to']}"
390
+ if r["status"] == "stuck":
391
+ text += f"\nleft blocked: {r['why']}"
392
+ return r, text + f"\nNext: raidlock show {a.card}"
393
+
394
+
395
+ def _policy_setup(p):
396
+ p.add_argument("action", nargs="?", default="show", choices=("show", "set"))
397
+ p.add_argument("key", nargs="?")
398
+ p.add_argument("value", nargs="?", help="JSON value")
399
+
400
+
401
+ def _policy_handler(ctx, a):
402
+ if a.action == "set":
403
+ if a.key is None or a.value is None:
404
+ raise Precondition("resurrect-policy set needs a key and a JSON value")
405
+ try:
406
+ value = json.loads(a.value)
407
+ except ValueError:
408
+ raise Precondition("value must be JSON") from None
409
+ r = set_policy(ctx.board, ctx.caller(), a.key, value)
410
+ return {"policy": r}, f"resurrect {a.key} = {json.dumps(r[a.key])}"
411
+ pol, ledger = _load(ctx.board)
412
+ return {"policy": pol, "attempts": ledger}, json.dumps({"policy": pol}, indent=2, sort_keys=True)
413
+
414
+
415
+ def register_cli(add_command):
416
+ add_command("resurrect", "hand a stalled card to a fresh agent with its resume packet", _setup, _handler, True)
417
+ add_command(
418
+ "resurrect-policy", "show or set automatic resurrection (off by default)", _policy_setup, _policy_handler, True
419
+ )