raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/routing.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Provider routing: `raid next` suggests the next claimable card and the provider and model to run it on.
|
|
2
|
+
|
|
3
|
+
Rules live in `.board/routing.json` and are written only by the lead. Routing reads plan headroom from
|
|
4
|
+
`raidlock.limits`; it never claims, delegates or changes a card, so every board gate still applies."""
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import fnmatch
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from . import limits as LM
|
|
12
|
+
from .core import paths as P
|
|
13
|
+
from .core import store
|
|
14
|
+
from .core.errors import Precondition
|
|
15
|
+
from .core.hints import lead_cmd, lead_secret_src
|
|
16
|
+
from .core.identity import RUNTIME_SHORT, short_runtime
|
|
17
|
+
from .core.model import card_id, line_text
|
|
18
|
+
|
|
19
|
+
PROVIDERS = ("claude", "codex", "gemini", "shell")
|
|
20
|
+
QUOTAS = ("plan", "none")
|
|
21
|
+
FILE = "routing.json"
|
|
22
|
+
RULE_KEYS = ("name", "label", "path", "team", "provider", "model", "fallback")
|
|
23
|
+
MODEL_ID = re.compile(r"^[A-Za-z0-9._:/@-]{1,100}$")
|
|
24
|
+
LABEL = re.compile(r"^[a-z0-9][a-z0-9_-]{0,39}$")
|
|
25
|
+
WEEKLY_MINUTES = 10080
|
|
26
|
+
FIVE_HOUR_MINUTES = 300
|
|
27
|
+
DEFAULTS = {
|
|
28
|
+
"rules": [],
|
|
29
|
+
"labels": {},
|
|
30
|
+
"default": {"provider": "", "model": "", "fallback": []},
|
|
31
|
+
"weekly_max": 90,
|
|
32
|
+
"five_hour_max": 95,
|
|
33
|
+
"quota": {"shell": "none"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config(board):
|
|
38
|
+
"""The routing config merged over the defaults."""
|
|
39
|
+
found = store.read_doc(board._p(FILE), "routing")
|
|
40
|
+
out = copy.deepcopy(DEFAULTS)
|
|
41
|
+
if not isinstance(found, dict):
|
|
42
|
+
raise Precondition(f"corrupt {FILE}: expected an object")
|
|
43
|
+
if not all(isinstance(found.get(k) or {}, dict) for k in ("quota", "labels")):
|
|
44
|
+
raise Precondition(f"corrupt {FILE}: quota and labels must be objects")
|
|
45
|
+
for key in ("rules", "default", "weekly_max", "five_hour_max"):
|
|
46
|
+
if key in found:
|
|
47
|
+
out[key] = validate(key, found[key])
|
|
48
|
+
for name, value in (found.get("quota") or {}).items():
|
|
49
|
+
out["quota"][name] = validate("quota." + name, value)
|
|
50
|
+
for cid, value in (found.get("labels") or {}).items():
|
|
51
|
+
out["labels"][cid] = validate("label." + cid, value)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _provider(value, key):
|
|
56
|
+
value = short_runtime(value) if isinstance(value, str) else value
|
|
57
|
+
if value not in PROVIDERS:
|
|
58
|
+
raise Precondition(f"{key} must be one of {', '.join(PROVIDERS)}", key=key)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _providers(value, key):
|
|
63
|
+
if not isinstance(value, list):
|
|
64
|
+
raise Precondition(f"{key} must be a list of providers", key=key)
|
|
65
|
+
out = []
|
|
66
|
+
for item in value:
|
|
67
|
+
item = _provider(item, key)
|
|
68
|
+
if item not in out:
|
|
69
|
+
out.append(item)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _model(value, key):
|
|
74
|
+
if value in (None, ""):
|
|
75
|
+
return ""
|
|
76
|
+
if not isinstance(value, str) or not MODEL_ID.match(value):
|
|
77
|
+
raise Precondition(f"{key} is not a valid model id", key=key)
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _percent(value, key):
|
|
82
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 < value <= 100:
|
|
83
|
+
raise Precondition(f"{key} must be a number in (0, 100]", key=key)
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _labels(value, key):
|
|
88
|
+
if not isinstance(value, list):
|
|
89
|
+
raise Precondition(f"{key} must be a list of labels", key=key)
|
|
90
|
+
for item in value:
|
|
91
|
+
if not isinstance(item, str) or not LABEL.match(item):
|
|
92
|
+
raise Precondition(f"{key}: invalid label {item!r}; use [a-z0-9_-], max 40", key=key)
|
|
93
|
+
return sorted(set(value))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def check_rule(rule, index=0):
|
|
97
|
+
"""Validate one rule: a match (label, path or team), a provider, an optional model and fallbacks."""
|
|
98
|
+
key = f"rules[{index}]"
|
|
99
|
+
if not isinstance(rule, dict) or set(rule) - set(RULE_KEYS):
|
|
100
|
+
raise Precondition(f"{key} must be an object with keys from {', '.join(RULE_KEYS)}", key=key)
|
|
101
|
+
if not any(rule.get(k) for k in ("label", "path", "team")):
|
|
102
|
+
raise Precondition(f"{key} needs label, path or team to match", key=key)
|
|
103
|
+
out = {"name": line_text(rule.get("name") or f"rule-{index + 1}", key + ".name", 80)}
|
|
104
|
+
if rule.get("label"):
|
|
105
|
+
out["label"] = _labels([rule["label"]], key + ".label")[0]
|
|
106
|
+
if rule.get("path"):
|
|
107
|
+
raw = str(rule["path"]).strip()
|
|
108
|
+
P.canonical(re.sub(r"[*?\[\]]", "x", raw))
|
|
109
|
+
out["path"] = raw.rstrip("/")
|
|
110
|
+
if rule.get("team"):
|
|
111
|
+
out["team"] = line_text(rule["team"], key + ".team", 40)
|
|
112
|
+
out["provider"] = _provider(rule.get("provider"), key + ".provider")
|
|
113
|
+
out["model"] = _model(rule.get("model"), key + ".model")
|
|
114
|
+
out["fallback"] = [p for p in _providers(rule.get("fallback") or [], key + ".fallback") if p != out["provider"]]
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate(key, value):
|
|
119
|
+
"""Validate one config key and return the value to store; None clears it."""
|
|
120
|
+
if key == "rules":
|
|
121
|
+
if value is None:
|
|
122
|
+
return []
|
|
123
|
+
if not isinstance(value, list):
|
|
124
|
+
raise Precondition("rules must be a list")
|
|
125
|
+
return [check_rule(rule, i) for i, rule in enumerate(value)]
|
|
126
|
+
if key == "default":
|
|
127
|
+
if value is None:
|
|
128
|
+
return copy.deepcopy(DEFAULTS["default"])
|
|
129
|
+
if not isinstance(value, dict) or set(value) - {"provider", "model", "fallback"}:
|
|
130
|
+
raise Precondition("default must be an object with provider, model and fallback")
|
|
131
|
+
provider = _provider(value["provider"], "default.provider") if value.get("provider") else ""
|
|
132
|
+
return {
|
|
133
|
+
"provider": provider,
|
|
134
|
+
"model": _model(value.get("model"), "default.model"),
|
|
135
|
+
"fallback": _providers(value.get("fallback") or [], "default.fallback"),
|
|
136
|
+
}
|
|
137
|
+
if key in ("weekly_max", "five_hour_max"):
|
|
138
|
+
return DEFAULTS[key] if value is None else _percent(value, key)
|
|
139
|
+
if key.startswith("quota."):
|
|
140
|
+
_provider(key[len("quota.") :], key)
|
|
141
|
+
if value is not None and value not in QUOTAS:
|
|
142
|
+
raise Precondition(f"{key} must be one of {', '.join(QUOTAS)}", key=key)
|
|
143
|
+
return value
|
|
144
|
+
if key.startswith("label."):
|
|
145
|
+
card_id(key[len("label.") :])
|
|
146
|
+
return None if value is None else _labels(value, key)
|
|
147
|
+
raise Precondition(
|
|
148
|
+
f"unknown routing key {key}; known: rules, default, weekly_max, five_hour_max, quota.<provider>, label.<card>",
|
|
149
|
+
key=key,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def set_config(board, caller, key, value):
|
|
154
|
+
"""Lead-only: validate and store one routing key under the board lock."""
|
|
155
|
+
value = validate(key, value)
|
|
156
|
+
if key.startswith("label."):
|
|
157
|
+
board.card(key[len("label.") :])
|
|
158
|
+
with board.lead_write(caller, f"change routing {key}"):
|
|
159
|
+
cfg = config(board)
|
|
160
|
+
if key.startswith("quota."):
|
|
161
|
+
cfg["quota"] = dict(cfg.get("quota") or {})
|
|
162
|
+
if value is None:
|
|
163
|
+
cfg["quota"].pop(key[len("quota.") :], None)
|
|
164
|
+
else:
|
|
165
|
+
cfg["quota"][key[len("quota.") :]] = value
|
|
166
|
+
elif key.startswith("label."):
|
|
167
|
+
cfg["labels"] = dict(cfg.get("labels") or {})
|
|
168
|
+
if value:
|
|
169
|
+
cfg["labels"][key[len("label.") :]] = value
|
|
170
|
+
else:
|
|
171
|
+
cfg["labels"].pop(key[len("label.") :], None)
|
|
172
|
+
else:
|
|
173
|
+
cfg[key] = value
|
|
174
|
+
store.write_doc(board._p(FILE), "routing", cfg)
|
|
175
|
+
board.index.bump(board.uid)
|
|
176
|
+
return {"key": key, "value": value, "routing": cfg}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def labels_of(cfg, card):
|
|
180
|
+
return list((cfg.get("labels") or {}).get(card["id"], []))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _path_match(pattern, lock):
|
|
184
|
+
if any(ch in pattern for ch in "*?["):
|
|
185
|
+
return fnmatch.fnmatchcase(lock, pattern) or fnmatch.fnmatchcase(lock + "/", pattern)
|
|
186
|
+
return P.overlaps(pattern, lock)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def matches(rule, card, cfg):
|
|
190
|
+
if rule.get("label") and rule["label"] not in labels_of(cfg, card):
|
|
191
|
+
return False
|
|
192
|
+
if rule.get("team") and rule["team"] != card.get("team"):
|
|
193
|
+
return False
|
|
194
|
+
return not (rule.get("path") and not any(_path_match(rule["path"], lock) for lock in card.get("locks", [])))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def rule_for(cfg, card):
|
|
198
|
+
return next((r for r in cfg.get("rules") or [] if matches(r, card, cfg)), None)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _window_kind(window):
|
|
202
|
+
minutes = window.get("window_minutes") or 0
|
|
203
|
+
if minutes >= WEEKLY_MINUTES:
|
|
204
|
+
return "weekly"
|
|
205
|
+
if 0 < minutes <= FIVE_HOUR_MINUTES:
|
|
206
|
+
return "5h"
|
|
207
|
+
return "other"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def headroom(provider, cfg, collected):
|
|
211
|
+
"""{provider, ok, left, reason, exhausted, retry_after}. Unknown or stale limits count as no headroom."""
|
|
212
|
+
if (cfg.get("quota") or {}).get(provider) == "none":
|
|
213
|
+
return {"provider": provider, "ok": True, "left": 100.0, "reason": "no plan quota", "exhausted": False}
|
|
214
|
+
found = next((p for p in (collected or {}).get("providers", []) if p.get("provider") == provider), None)
|
|
215
|
+
status = (found or {}).get("status", "unavailable")
|
|
216
|
+
windows = (found or {}).get("windows") or []
|
|
217
|
+
base = {"provider": provider, "ok": False, "left": 0.0, "exhausted": False}
|
|
218
|
+
if status != "ok" or not windows:
|
|
219
|
+
detail = status if status != "ok" else "no windows"
|
|
220
|
+
return {**base, "reason": f"{provider} headroom unknown ({detail}); treated as no headroom"}
|
|
221
|
+
resets = [w.get("resets_in_seconds") for w in windows if isinstance(w.get("resets_in_seconds"), int)]
|
|
222
|
+
if found.get("limit_reached"):
|
|
223
|
+
return {
|
|
224
|
+
**base,
|
|
225
|
+
"exhausted": True,
|
|
226
|
+
"retry_after": max(resets) if resets else None,
|
|
227
|
+
"reason": f"{provider} quota exhausted ({found['limit_reached']})",
|
|
228
|
+
}
|
|
229
|
+
five = None
|
|
230
|
+
for w in windows:
|
|
231
|
+
kind, used = _window_kind(w), w.get("used_percent")
|
|
232
|
+
if not isinstance(used, (int, float)):
|
|
233
|
+
continue
|
|
234
|
+
if kind == "weekly" and used >= cfg["weekly_max"]:
|
|
235
|
+
return {
|
|
236
|
+
**base,
|
|
237
|
+
"exhausted": used >= 100,
|
|
238
|
+
"retry_after": w.get("resets_in_seconds"),
|
|
239
|
+
"reason": f"{provider} weekly window {used:g}% used (no new work at {cfg['weekly_max']:g}% or more)",
|
|
240
|
+
}
|
|
241
|
+
if kind == "5h":
|
|
242
|
+
five = used
|
|
243
|
+
if used >= cfg["five_hour_max"]:
|
|
244
|
+
return {
|
|
245
|
+
**base,
|
|
246
|
+
"retry_after": w.get("resets_in_seconds"),
|
|
247
|
+
"reason": f"{provider} 5h window {used:g}% used; wait for the reset",
|
|
248
|
+
}
|
|
249
|
+
peak = max((w["used_percent"] for w in windows if isinstance(w.get("used_percent"), (int, float))), default=0)
|
|
250
|
+
left = 100.0 - (five if five is not None else peak)
|
|
251
|
+
return {**base, "ok": True, "left": left, "reason": f"{provider} has {left:g}% 5h headroom"}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def route(board, card, provider=None, exclude=(), collected=None, cfg=None):
|
|
255
|
+
"""Pick a provider and model for `card`. Returns {provider, model, rule, reason, checked}; provider is "" when
|
|
256
|
+
nothing has headroom. An explicit `provider` is checked but never swapped."""
|
|
257
|
+
cfg = cfg or config(board)
|
|
258
|
+
collected = LM.collect() if collected is None else collected
|
|
259
|
+
rule = rule_for(cfg, card)
|
|
260
|
+
base = rule or cfg.get("default") or {}
|
|
261
|
+
if provider:
|
|
262
|
+
candidates = [_provider(provider, "provider")]
|
|
263
|
+
ranked = False
|
|
264
|
+
elif base.get("provider"):
|
|
265
|
+
candidates = [base["provider"], *base.get("fallback", [])]
|
|
266
|
+
ranked = False
|
|
267
|
+
else:
|
|
268
|
+
candidates = list(base.get("fallback") or []) or [p for p in PROVIDERS if p != "shell"]
|
|
269
|
+
ranked = True
|
|
270
|
+
candidates = [p for i, p in enumerate(candidates) if p not in exclude and p not in candidates[:i]]
|
|
271
|
+
checked = [headroom(p, cfg, collected) for p in candidates]
|
|
272
|
+
usable = [c for c in checked if c["ok"]]
|
|
273
|
+
if ranked:
|
|
274
|
+
usable.sort(key=lambda c: -c["left"])
|
|
275
|
+
pick = usable[0] if usable else None
|
|
276
|
+
model = ""
|
|
277
|
+
if pick and pick["provider"] == base.get("provider"):
|
|
278
|
+
model = base.get("model", "")
|
|
279
|
+
return {
|
|
280
|
+
"card": card["id"],
|
|
281
|
+
"provider": pick["provider"] if pick else "",
|
|
282
|
+
"model": model,
|
|
283
|
+
"rule": (rule or {}).get("name", "default"),
|
|
284
|
+
"labels": labels_of(cfg, card),
|
|
285
|
+
"reason": pick["reason"] if pick else "; ".join(c["reason"] for c in checked) or "no candidate provider",
|
|
286
|
+
"checked": checked,
|
|
287
|
+
"fallback": bool(pick and checked and pick["provider"] != checked[0]["provider"]),
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def allowed_for(cfg, card, provider):
|
|
292
|
+
"""True when the card's rule (if any) lets `provider` run it."""
|
|
293
|
+
rule = rule_for(cfg, card)
|
|
294
|
+
return rule is None or provider in [rule["provider"], *rule.get("fallback", [])]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def caller_provider(board, caller):
|
|
298
|
+
record = board.agents().get(caller.name, {}) if caller.name and not caller.is_lead else {}
|
|
299
|
+
short = RUNTIME_SHORT.get(record.get("runtime", ""), "")
|
|
300
|
+
return short if short in PROVIDERS else ""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def suggest(board, caller, provider=None, for_review=False, collected=None):
|
|
304
|
+
"""The next card for the caller with its route. Read-only: no claim, no lease, no card change."""
|
|
305
|
+
cfg = config(board)
|
|
306
|
+
collected = LM.collect() if collected is None else collected
|
|
307
|
+
agent = caller.name if caller.name and not caller.is_lead else None
|
|
308
|
+
provider = provider or caller_provider(board, caller)
|
|
309
|
+
if provider:
|
|
310
|
+
_provider(provider, "provider")
|
|
311
|
+
room = headroom(provider, cfg, collected)
|
|
312
|
+
if not room["ok"]:
|
|
313
|
+
raise Precondition(
|
|
314
|
+
f"no new work routed to {provider}: {room['reason']}",
|
|
315
|
+
provider=provider,
|
|
316
|
+
retry_after=room.get("retry_after"),
|
|
317
|
+
hint=_headroom_hint(board) if "headroom unknown" in room["reason"] else "raidlock limits",
|
|
318
|
+
)
|
|
319
|
+
queue = board.claimable(agent=agent, for_review=for_review)
|
|
320
|
+
cards = {c["id"]: c for c in board.cards()}
|
|
321
|
+
open_ids = [cid for cid, v in queue.items() if v["ok"] and cid in cards]
|
|
322
|
+
open_ids.sort(key=lambda cid: (cards[cid]["lane"] != "todo", cards[cid].get("created", ""), cid))
|
|
323
|
+
skipped = []
|
|
324
|
+
agents = board.agents()
|
|
325
|
+
for cid in open_ids:
|
|
326
|
+
card = cards[cid]
|
|
327
|
+
exclude = ()
|
|
328
|
+
if for_review:
|
|
329
|
+
owner_short = RUNTIME_SHORT.get(agents.get(card.get("owner"), {}).get("runtime", ""), "")
|
|
330
|
+
exclude = (owner_short,) if owner_short in PROVIDERS and owner_short != "shell" else ()
|
|
331
|
+
if provider and provider in exclude:
|
|
332
|
+
skipped.append(f"{cid}: review needs a provider other than {provider}")
|
|
333
|
+
continue
|
|
334
|
+
elif provider and not allowed_for(cfg, card, provider):
|
|
335
|
+
skipped.append(f"{cid}: rule {rule_for(cfg, card)['name']} routes it elsewhere")
|
|
336
|
+
continue
|
|
337
|
+
r = route(board, card, provider=provider, exclude=exclude, collected=collected, cfg=cfg)
|
|
338
|
+
if not r["provider"]:
|
|
339
|
+
skipped.append(f"{cid}: {r['reason']}")
|
|
340
|
+
continue
|
|
341
|
+
return {
|
|
342
|
+
**r,
|
|
343
|
+
"title": card["title"],
|
|
344
|
+
"lane": card["lane"],
|
|
345
|
+
"command": _command(r, agent, for_review, board if caller.is_lead else None),
|
|
346
|
+
}
|
|
347
|
+
reason = "; ".join(skipped[:5]) if skipped else ("nothing waiting for review" if for_review else "nothing open")
|
|
348
|
+
unknown = any("headroom unknown" in item for item in skipped)
|
|
349
|
+
raise Precondition(f"no card to route: {reason}", hint=_headroom_hint(board) if unknown else "raidlock claimable")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _headroom_hint(board):
|
|
353
|
+
return (
|
|
354
|
+
"raidlock limits shows what is known; run raidlock limits --install-statusline once to record Claude Code "
|
|
355
|
+
"plan usage, or "
|
|
356
|
+
"mark a provider as having no plan quota: " + lead_cmd(board, "routing set quota.<provider> '\"none\"'")
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _command(r, agent, for_review, lead_board=None):
|
|
361
|
+
if agent:
|
|
362
|
+
return f"raidlock review {r['card']} --verdict approve" if for_review else f"raidlock claim {r['card']}"
|
|
363
|
+
parts = ["raidlock", "delegate", r["card"], "--to", r["provider"]]
|
|
364
|
+
if lead_board is not None:
|
|
365
|
+
parts[1:1] = ["--lead-secret", lead_secret_src(lead_board)]
|
|
366
|
+
if for_review:
|
|
367
|
+
parts += ["--as", "reviewer"]
|
|
368
|
+
if r.get("model"):
|
|
369
|
+
parts += ["--model", r["model"]]
|
|
370
|
+
return " ".join(parts)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _next_setup(p):
|
|
374
|
+
p.add_argument(
|
|
375
|
+
"--provider", default=None, type=short_runtime, choices=PROVIDERS, help="route only to this provider"
|
|
376
|
+
)
|
|
377
|
+
p.add_argument("--for-review", action="store_true", help="suggest a card waiting for review instead")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _next_handler(ctx, a):
|
|
381
|
+
r = suggest(ctx.board, ctx.caller(), provider=a.provider, for_review=a.for_review)
|
|
382
|
+
model = f" model {r['model']}" if r["model"] else ""
|
|
383
|
+
via = " (fallback)" if r["fallback"] else ""
|
|
384
|
+
text = f"next: {r['card']}: {r['title']}\nroute: {r['provider']}{model}{via}, rule {r['rule']}; {r['reason']}"
|
|
385
|
+
return r, text + f"\nNext: {r['command']}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _routing_setup(p):
|
|
389
|
+
p.add_argument("routing_action", nargs="?", default="show", choices=("show", "set", "explain"))
|
|
390
|
+
p.add_argument("key", nargs="?", help="config key for set, card id for explain")
|
|
391
|
+
p.add_argument("value", nargs="?", help="JSON value for set; null clears")
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _routing_handler(ctx, a):
|
|
395
|
+
if a.routing_action == "set":
|
|
396
|
+
if a.key is None or a.value is None:
|
|
397
|
+
raise Precondition("routing set needs a key and a JSON value")
|
|
398
|
+
try:
|
|
399
|
+
value = json.loads(a.value)
|
|
400
|
+
except ValueError:
|
|
401
|
+
raise Precondition("routing value must be valid JSON") from None
|
|
402
|
+
r = set_config(ctx.board, ctx.caller(), a.key, value)
|
|
403
|
+
return r, f"routing {a.key} set"
|
|
404
|
+
if a.routing_action == "explain":
|
|
405
|
+
if not a.key:
|
|
406
|
+
raise Precondition("routing explain needs a card id")
|
|
407
|
+
r = route(ctx.board, ctx.board.card(a.key))
|
|
408
|
+
lines = [f"{a.key}: rule {r['rule']} -> {r['provider'] or 'no provider'}"]
|
|
409
|
+
lines += [f" {c['provider']}: {'ok' if c['ok'] else 'no'}, {c['reason']}" for c in r["checked"]]
|
|
410
|
+
return r, "\n".join(lines)
|
|
411
|
+
cfg = config(ctx.board)
|
|
412
|
+
return {"routing": cfg}, json.dumps(cfg, indent=2, sort_keys=True)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def register_cli(add_command):
|
|
416
|
+
add_command("next", "suggest the next card for you and the provider to run it on", _next_setup, _next_handler)
|
|
417
|
+
add_command(
|
|
418
|
+
"routing", "show, set or explain provider routing rules", _routing_setup, _routing_handler, mutating=True
|
|
419
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Runners: delegate a card to an external agent CLI in an isolated clone, enforce its locks, hand it back."""
|
|
2
|
+
|
|
3
|
+
SAMPLE = (
|
|
4
|
+
"codex-worker-api handed api to review (3 file(s) changed, verify passed). "
|
|
5
|
+
"Patch: ~/ws/.board/runs/api/changes.patch\nNext: raidlock show api"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_tokens(register):
|
|
10
|
+
register(
|
|
11
|
+
"delegate output",
|
|
12
|
+
lambda board: {
|
|
13
|
+
"tokens": -(-len(SAMPLE) // 4),
|
|
14
|
+
"detail": "one line per delegate run; prompts and events stay on disk",
|
|
15
|
+
},
|
|
16
|
+
)
|