raidlock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- raidlock/__init__.py +11 -0
- raidlock/__main__.py +5 -0
- raidlock/_python.py +58 -0
- raidlock/achievements.py +156 -0
- raidlock/approvals.py +237 -0
- raidlock/bench.py +286 -0
- raidlock/bridge/__init__.py +186 -0
- raidlock/budgets.py +178 -0
- raidlock/cli.py +2153 -0
- raidlock/commands_install.py +114 -0
- raidlock/core/__init__.py +26 -0
- raidlock/core/advisories.py +16 -0
- raidlock/core/billing.py +54 -0
- raidlock/core/board.py +2251 -0
- raidlock/core/brief.py +111 -0
- raidlock/core/clock.py +40 -0
- raidlock/core/cost.py +198 -0
- raidlock/core/db.py +608 -0
- raidlock/core/errors.py +52 -0
- raidlock/core/exe.py +6 -0
- raidlock/core/hints.py +32 -0
- raidlock/core/identity.py +285 -0
- raidlock/core/lead.py +758 -0
- raidlock/core/lead_env.py +23 -0
- raidlock/core/lead_hook.py +42 -0
- raidlock/core/leases.py +19 -0
- raidlock/core/lock.py +99 -0
- raidlock/core/messages.py +98 -0
- raidlock/core/migrate.py +579 -0
- raidlock/core/model.py +126 -0
- raidlock/core/models.py +109 -0
- raidlock/core/paths.py +61 -0
- raidlock/core/portable.py +53 -0
- raidlock/core/receipts.py +73 -0
- raidlock/core/registry.py +39 -0
- raidlock/core/repos.py +250 -0
- raidlock/core/safeio.py +201 -0
- raidlock/core/status.py +52 -0
- raidlock/core/store.py +107 -0
- raidlock/core/tokens.py +77 -0
- raidlock/core/transcript.py +208 -0
- raidlock/duel.py +371 -0
- raidlock/foresight.py +191 -0
- raidlock/frames.py +162 -0
- raidlock/gitguard.py +753 -0
- raidlock/guard.py +239 -0
- raidlock/hooks.py +1526 -0
- raidlock/infer.py +205 -0
- raidlock/limits.py +685 -0
- raidlock/lore/__init__.py +22 -0
- raidlock/lore/approval.py +145 -0
- raidlock/lore/capture.py +146 -0
- raidlock/lore/cli.py +109 -0
- raidlock/lore/distill.py +17 -0
- raidlock/lore/frontmatter.py +56 -0
- raidlock/lore/inject.py +127 -0
- raidlock/lore/vault.py +264 -0
- raidlock/mcp/__init__.py +34 -0
- raidlock/mcp/resources.py +72 -0
- raidlock/mcp/server.py +184 -0
- raidlock/mcp/tools.py +178 -0
- raidlock/notify.py +144 -0
- raidlock/party.py +239 -0
- raidlock/pr.py +575 -0
- raidlock/prsync.py +318 -0
- raidlock/recipes/README.md +34 -0
- raidlock/recipes/__init__.py +105 -0
- raidlock/recipes/docs-refresh.md +15 -0
- raidlock/recipes/multi-repo-refactor.md +15 -0
- raidlock/recipes/security-audit.md +27 -0
- raidlock/recipes/upgrade-deps.md +15 -0
- raidlock/reputation.py +194 -0
- raidlock/resume.py +329 -0
- raidlock/resurrect.py +419 -0
- raidlock/routing.py +419 -0
- raidlock/runners/__init__.py +16 -0
- raidlock/runners/base.py +341 -0
- raidlock/runners/brief.py +176 -0
- raidlock/runners/claude_code.py +121 -0
- raidlock/runners/cli.py +110 -0
- raidlock/runners/codex.py +114 -0
- raidlock/runners/gemini.py +64 -0
- raidlock/runners/isolation.py +391 -0
- raidlock/runners/policy.py +123 -0
- raidlock/runners/runner.py +921 -0
- raidlock/runners/shell.py +35 -0
- raidlock/runners/verifycache.py +93 -0
- raidlock/runners/workspace.py +376 -0
- raidlock/scan.py +357 -0
- raidlock/snapshot.py +151 -0
- raidlock/treasury.py +210 -0
- raidlock/tui.py +223 -0
- raidlock/web/__init__.py +5 -0
- raidlock/web/achievements/view.css +19 -0
- raidlock/web/achievements/view.js +58 -0
- raidlock/web/bosses/view.css +19 -0
- raidlock/web/bosses/view.js +46 -0
- raidlock/web/cli.py +90 -0
- raidlock/web/data.py +687 -0
- raidlock/web/export.py +153 -0
- raidlock/web/frames/view.css +25 -0
- raidlock/web/frames/view.js +63 -0
- raidlock/web/graph/__init__.py +142 -0
- raidlock/web/graph/view.css +33 -0
- raidlock/web/graph/view.js +136 -0
- raidlock/web/raidmap/__init__.py +205 -0
- raidlock/web/raidmap/view.css +15 -0
- raidlock/web/raidmap/view.js +47 -0
- raidlock/web/replay/__init__.py +258 -0
- raidlock/web/replay/view.css +13 -0
- raidlock/web/replay/view.js +127 -0
- raidlock/web/server.py +292 -0
- raidlock/web/static/app.css +851 -0
- raidlock/web/static/app.js +2102 -0
- raidlock/web/static/fonts/OFL.txt +93 -0
- raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
- raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
- raidlock/web/static/icon-32.png +0 -0
- raidlock/web/static/icon-raid.svg +1 -0
- raidlock/web/static/icon.svg +1 -0
- raidlock/web/static/index.html +122 -0
- raidlock-0.1.0.dist-info/METADATA +186 -0
- raidlock-0.1.0.dist-info/RECORD +126 -0
- raidlock-0.1.0.dist-info/WHEEL +4 -0
- raidlock-0.1.0.dist-info/entry_points.txt +34 -0
- raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from . import clock
|
|
8
|
+
|
|
9
|
+
KINDS = ("msg", "relay", "handback", "note", "status", "cost", "artifact", "review", "verify")
|
|
10
|
+
REDACTED = "[REDACTED]"
|
|
11
|
+
_PATTERNS = [
|
|
12
|
+
re.compile(r"(?<![\w-])t[A-Za-z0-9_-]{32}(?![\w-])"),
|
|
13
|
+
re.compile(r"(?<![\w.])\d+\.[A-Za-z0-9_-]{43}(?![\w-])"),
|
|
14
|
+
re.compile(r"\b[a-z0-9-]{1,64}:\d+:[a-f0-9]{40}\b"),
|
|
15
|
+
re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]+"),
|
|
16
|
+
re.compile(r"\b(?:npm|hf)_[A-Za-z0-9]{10,}"),
|
|
17
|
+
re.compile(r"\bSG\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"),
|
|
18
|
+
re.compile(r"(?i)\b(?:postgres(?:ql)?|mysql)://[^\s/@]+:[^\s/@]+@[^\s'\"]+"),
|
|
19
|
+
re.compile(r"https://hooks\.slack\.com/services/[^\s'\"]+"),
|
|
20
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)", re.S),
|
|
21
|
+
re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
|
|
22
|
+
re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_\-]{16,}"),
|
|
23
|
+
re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b"),
|
|
24
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
|
|
25
|
+
re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}\b"),
|
|
26
|
+
re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"),
|
|
27
|
+
re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b"),
|
|
28
|
+
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
|
|
29
|
+
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/\-]{16,}=*"),
|
|
30
|
+
re.compile(
|
|
31
|
+
r"(?i)\b([A-Za-z0-9_]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|AccountKey|RAIDLOCK_[A-Za-z0-9_]+)[A-Za-z0-9_]*)"
|
|
32
|
+
r"([\"\x27]?\s*[:=]\s*)(['\"]?)[^\s'\",;]{4,}\3"
|
|
33
|
+
),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def redact(text):
|
|
38
|
+
"""Replace secret-looking substrings with [REDACTED]."""
|
|
39
|
+
if not isinstance(text, str) or not text:
|
|
40
|
+
return text
|
|
41
|
+
if re.search(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b", text):
|
|
42
|
+
text = re.sub(r"(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])", REDACTED, text)
|
|
43
|
+
for pattern in _PATTERNS[:-1]:
|
|
44
|
+
text = pattern.sub(REDACTED, text)
|
|
45
|
+
return _PATTERNS[-1].sub(lambda m: m.group(1) + m.group(2) + REDACTED, text)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _clean(value):
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return redact(value)
|
|
51
|
+
if isinstance(value, list):
|
|
52
|
+
return [_clean(v) for v in value]
|
|
53
|
+
if isinstance(value, dict):
|
|
54
|
+
return {k: _clean(v) for k, v in value.items()}
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def text_parts(text):
|
|
59
|
+
return [{"kind": "text", "text": text}] if text else []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make(
|
|
63
|
+
kind,
|
|
64
|
+
sender,
|
|
65
|
+
text="",
|
|
66
|
+
task="",
|
|
67
|
+
context="",
|
|
68
|
+
to="",
|
|
69
|
+
src="board",
|
|
70
|
+
parts=None,
|
|
71
|
+
artifacts=None,
|
|
72
|
+
data=None,
|
|
73
|
+
message_id=None,
|
|
74
|
+
role="agent",
|
|
75
|
+
):
|
|
76
|
+
"""Build an A2A-shaped transcript record."""
|
|
77
|
+
record = {
|
|
78
|
+
"messageId": message_id or uuid.uuid4().hex,
|
|
79
|
+
"ts": clock.iso(),
|
|
80
|
+
"kind": kind,
|
|
81
|
+
"role": role,
|
|
82
|
+
"taskId": task,
|
|
83
|
+
"contextId": context,
|
|
84
|
+
"from": sender,
|
|
85
|
+
"to": to,
|
|
86
|
+
"parts": list(parts or []) + text_parts(text),
|
|
87
|
+
"artifacts": list(artifacts or []),
|
|
88
|
+
"src": src,
|
|
89
|
+
}
|
|
90
|
+
if data is not None:
|
|
91
|
+
record["parts"].append({"kind": "data", "data": data})
|
|
92
|
+
return record
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def artifact(name, text="", data=None, kind=None):
|
|
96
|
+
parts = text_parts(text) + ([{"kind": "data", "data": data}] if data is not None else [])
|
|
97
|
+
out = {"artifactId": uuid.uuid4().hex, "name": name, "parts": parts}
|
|
98
|
+
if kind:
|
|
99
|
+
out["metadata"] = {"kind": kind}
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
SEGMENT_BYTES = 10 * 1024 * 1024
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Transcript:
|
|
107
|
+
"""Append-only transcript in `.board/transcript/NNNNNN.jsonl` segments (rotated at SEGMENT_BYTES), indexed in the
|
|
108
|
+
global DB by sequence number, task, kind and agent. Callers hold the board lock when appending."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, board_dir, index, ws):
|
|
111
|
+
self.dir = os.path.join(board_dir, "transcript")
|
|
112
|
+
self.index = index
|
|
113
|
+
self.ws = ws
|
|
114
|
+
legacy = os.path.join(board_dir, "transcript.jsonl")
|
|
115
|
+
if os.path.exists(legacy) and not os.path.exists(self.dir):
|
|
116
|
+
os.makedirs(self.dir)
|
|
117
|
+
os.replace(legacy, self._segment_path(1))
|
|
118
|
+
if os.path.isdir(self.dir) and not index.transcript_count(ws) and self._segments():
|
|
119
|
+
self.reindex()
|
|
120
|
+
|
|
121
|
+
def _segment_path(self, n):
|
|
122
|
+
return os.path.join(self.dir, f"{n:06d}.jsonl")
|
|
123
|
+
|
|
124
|
+
def _segments(self):
|
|
125
|
+
if not os.path.isdir(self.dir):
|
|
126
|
+
return []
|
|
127
|
+
return sorted(int(f[:-6]) for f in os.listdir(self.dir) if f.endswith(".jsonl") and f[:-6].isdigit())
|
|
128
|
+
|
|
129
|
+
def _current(self, size):
|
|
130
|
+
segs = self._segments() or [1]
|
|
131
|
+
n = segs[-1]
|
|
132
|
+
path = self._segment_path(n)
|
|
133
|
+
if os.path.exists(path) and os.path.getsize(path) + size > SEGMENT_BYTES and os.path.getsize(path) > 0:
|
|
134
|
+
n += 1
|
|
135
|
+
return n
|
|
136
|
+
|
|
137
|
+
def append(self, record):
|
|
138
|
+
"""Append a record (redacted). Returns the stored record with its `seq`, or None for a duplicate messageId."""
|
|
139
|
+
if record.get("kind") not in KINDS:
|
|
140
|
+
raise ValueError(f"unknown transcript kind {record.get('kind')!r}")
|
|
141
|
+
record = _clean(dict(record))
|
|
142
|
+
record.setdefault("messageId", uuid.uuid4().hex)
|
|
143
|
+
record.setdefault("ts", clock.iso())
|
|
144
|
+
if self.index.has_message(self.ws, record["messageId"]):
|
|
145
|
+
return None
|
|
146
|
+
data = (json.dumps(record, sort_keys=True, ensure_ascii=False) + "\n").encode("utf-8")
|
|
147
|
+
os.makedirs(self.dir, exist_ok=True)
|
|
148
|
+
n = self._current(len(data))
|
|
149
|
+
with open(self._segment_path(n), "ab") as f:
|
|
150
|
+
offset = f.seek(0, os.SEEK_END)
|
|
151
|
+
f.write(data)
|
|
152
|
+
f.flush()
|
|
153
|
+
os.fsync(f.fileno())
|
|
154
|
+
seqs = self.index.add_transcript(self.ws, [_index_row(record, n, offset, len(data))])
|
|
155
|
+
return {**record, "seq": seqs[0]} if seqs else None
|
|
156
|
+
|
|
157
|
+
def read(self, task=None, kind=None, agent=None, limit=None, since=None):
|
|
158
|
+
"""Records in order. Without `since`: the newest `limit`. With `since` (a seq cursor): the next `limit` after
|
|
159
|
+
it.
|
|
160
|
+
Each record carries `seq`; pass the last one back as `since` to continue."""
|
|
161
|
+
rows = self.index.query_transcript(self.ws, task=task, kind=kind, agent=agent, since=since, limit=limit)
|
|
162
|
+
return self._load(rows)
|
|
163
|
+
|
|
164
|
+
def _load(self, rows):
|
|
165
|
+
out, handles = [], {}
|
|
166
|
+
with contextlib.ExitStack() as stack:
|
|
167
|
+
for r in rows:
|
|
168
|
+
f = handles.get(r["segment"])
|
|
169
|
+
if f is None:
|
|
170
|
+
f = handles[r["segment"]] = stack.enter_context(open(self._segment_path(r["segment"]), "rb"))
|
|
171
|
+
f.seek(r["offset"])
|
|
172
|
+
try:
|
|
173
|
+
out.append(_clean({**json.loads(f.read(r["length"]).decode("utf-8")), "seq": r["seq"]}))
|
|
174
|
+
except ValueError:
|
|
175
|
+
continue
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
def reindex(self):
|
|
179
|
+
"""Rebuild this workspace's index from the segment files."""
|
|
180
|
+
self.index.clear_transcript(self.ws)
|
|
181
|
+
rows = []
|
|
182
|
+
for n in self._segments():
|
|
183
|
+
offset = 0
|
|
184
|
+
with open(self._segment_path(n), "rb") as f:
|
|
185
|
+
for line in f:
|
|
186
|
+
try:
|
|
187
|
+
rec = json.loads(line.decode("utf-8"))
|
|
188
|
+
rows.append(_index_row(rec, n, offset, len(line)))
|
|
189
|
+
except ValueError:
|
|
190
|
+
pass
|
|
191
|
+
offset += len(line)
|
|
192
|
+
if rows:
|
|
193
|
+
self.index.add_transcript(self.ws, rows)
|
|
194
|
+
return len(rows)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _index_row(record, segment, offset, length):
|
|
198
|
+
return (
|
|
199
|
+
record.get("messageId", ""),
|
|
200
|
+
record.get("ts", ""),
|
|
201
|
+
record.get("kind", ""),
|
|
202
|
+
record.get("taskId", "") or "",
|
|
203
|
+
(record.get("from") or {}).get("agent", "") or "",
|
|
204
|
+
record.get("to", "") or "",
|
|
205
|
+
segment,
|
|
206
|
+
offset,
|
|
207
|
+
length,
|
|
208
|
+
)
|
raidlock/duel.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Duel: the same card run by two providers in isolated runner clones, compared on evidence, decided by the lead.
|
|
2
|
+
|
|
3
|
+
The card has one lease at a time, so the contestants run one after the other: each gets its own claim and fence,
|
|
4
|
+
and the lead parks the card between runs, which makes the earlier fence stale. Each accepted patch is committed in
|
|
5
|
+
a lead-owned clone and verified there, giving a signed receipt for that exact commit. The lead picks the winner
|
|
6
|
+
only from a contestant with a valid, passing receipt; the loser's patch is discarded. Both runs record their cost
|
|
7
|
+
on the card, so both count toward budgets."""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from .core import clock, receipts, repos, store
|
|
15
|
+
from .core import transcript as T
|
|
16
|
+
from .core.errors import Denied, Precondition, RaidlockError
|
|
17
|
+
from .core.identity import RUNTIME_SHORT, short_runtime
|
|
18
|
+
from .runners import policy
|
|
19
|
+
from .runners import workspace as W
|
|
20
|
+
from .runners.runner import ADAPTERS, MODEL_ID, delegate
|
|
21
|
+
|
|
22
|
+
LABELS = ("a", "b")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def duel_dir(board, card_id):
|
|
26
|
+
return os.path.join(board.home, "duels", board.name, card_id)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def record_path(board, card_id):
|
|
30
|
+
return os.path.join(duel_dir(board, card_id), "duel.json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load(board, card_id):
|
|
34
|
+
data = store.read_json(record_path(board, card_id), default=None)
|
|
35
|
+
return data if isinstance(data, dict) else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _save(board, record):
|
|
39
|
+
os.makedirs(duel_dir(board, record["card"]), mode=0o700, exist_ok=True)
|
|
40
|
+
store.write_json(record_path(board, record["card"]), record)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def contestant_name(label, runtime, card_id):
|
|
44
|
+
return f"duel-{label}-{RUNTIME_SHORT.get(ADAPTERS[runtime].runtime, runtime)}-{card_id}"[:40].rstrip("-")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sides(vs, models, cmds):
|
|
48
|
+
runtimes = [short_runtime(v) for v in (vs.split(",") if isinstance(vs, str) else vs) if v.strip()]
|
|
49
|
+
if len(runtimes) != 2:
|
|
50
|
+
raise Precondition("a duel needs exactly two contestants: --vs <runtime>,<runtime>")
|
|
51
|
+
for r in runtimes:
|
|
52
|
+
if r not in ADAPTERS:
|
|
53
|
+
raise Precondition(f"--vs: {r} is not one of {', '.join(ADAPTERS)}", runtime=r)
|
|
54
|
+
models = list(models or ["", ""])
|
|
55
|
+
cmds = list(cmds or [])
|
|
56
|
+
if len(models) != 2:
|
|
57
|
+
raise Precondition("--model takes one model per contestant: --model <a>,<b>")
|
|
58
|
+
for m in models:
|
|
59
|
+
if m and not MODEL_ID.match(m):
|
|
60
|
+
raise Precondition(f"invalid model id {m!r}")
|
|
61
|
+
shells = [i for i, r in enumerate(runtimes) if r == "shell"]
|
|
62
|
+
if len(cmds) != len(shells):
|
|
63
|
+
raise Precondition("give one --cmd per shell contestant")
|
|
64
|
+
cmd_for = dict(zip(shells, cmds, strict=True))
|
|
65
|
+
if runtimes[0] == runtimes[1] and models[0] == models[1] and cmd_for.get(0) == cmd_for.get(1):
|
|
66
|
+
raise Precondition("both contestants are identical; vary the runtime, the model or the command")
|
|
67
|
+
return [{"label": LABELS[i], "runtime": runtimes[i], "model": models[i], "cmd": cmd_for.get(i)} for i in range(2)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def passing(board, card_id, side):
|
|
71
|
+
"""True only when the side's receipt is on the card, signed by this board, passed, and names the commit its
|
|
72
|
+
lead-owned clone still has checked out."""
|
|
73
|
+
rec = side.get("receipt") or {}
|
|
74
|
+
clone = side.get("clone") or ""
|
|
75
|
+
if not rec.get("signature") or not clone or not os.path.isdir(clone):
|
|
76
|
+
return False
|
|
77
|
+
card = board.card(card_id)
|
|
78
|
+
found = next((v for v in card.get("verifications", []) if v.get("signature") == rec["signature"]), None)
|
|
79
|
+
if not found or not receipts.valid(board, card, found):
|
|
80
|
+
return False
|
|
81
|
+
if not found.get("passed") or found.get("exit") != 0 or found.get("sha") != side.get("sha"):
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
return W.head(clone) == found["sha"]
|
|
85
|
+
except RaidlockError:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _commit_clone(board, card_id, label, run_dir, base, target):
|
|
90
|
+
"""A lead-owned clone at BASE with the contestant's accepted patch committed; returns (path, sha)."""
|
|
91
|
+
dst = os.path.join(duel_dir(board, card_id), label, "repo")
|
|
92
|
+
W.clone(target["path"], dst)
|
|
93
|
+
origin = repos.remote_url(target["path"]) or os.path.realpath(target["path"])
|
|
94
|
+
W.git(dst, "remote", "add", "origin", origin)
|
|
95
|
+
W.git(dst, "checkout", "--quiet", "--detach", base)
|
|
96
|
+
patch = os.path.join(run_dir, "changes.patch")
|
|
97
|
+
if os.path.exists(patch):
|
|
98
|
+
W.apply_and_commit(dst, patch)
|
|
99
|
+
return dst, W.head(dst)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _patch_meta(run_dir):
|
|
103
|
+
info = store.read_json(os.path.join(run_dir, "run.json"), default={}) or {}
|
|
104
|
+
patch = info.get("patch") or {}
|
|
105
|
+
return info, {"files": patch.get("files", 0), "bytes": patch.get("bytes", 0), "rejected": patch.get("rejected")}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run_side(board, lead, card_id, side, target, reviewer, timeout, heartbeat):
|
|
109
|
+
started = time.monotonic()
|
|
110
|
+
out = {**side, "agent": contestant_name(side["label"], side["runtime"], card_id)}
|
|
111
|
+
try:
|
|
112
|
+
r = delegate(
|
|
113
|
+
board,
|
|
114
|
+
lead,
|
|
115
|
+
card_id,
|
|
116
|
+
side["runtime"],
|
|
117
|
+
model=side["model"] or None,
|
|
118
|
+
cmd=side["cmd"],
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
heartbeat=heartbeat,
|
|
121
|
+
agent=out["agent"],
|
|
122
|
+
)
|
|
123
|
+
except RaidlockError as e:
|
|
124
|
+
return {**out, "status": "refused", "reason": e.message, "lane": board.card(card_id)["lane"]}
|
|
125
|
+
info, meta = _patch_meta(r["run_dir"])
|
|
126
|
+
out.update(
|
|
127
|
+
status=r.get("status", ""),
|
|
128
|
+
lane=r.get("lane", ""),
|
|
129
|
+
reason=r.get("reason", ""),
|
|
130
|
+
run_dir=r["run_dir"],
|
|
131
|
+
duration_ms=info.get("duration_ms") or int((time.monotonic() - started) * 1000),
|
|
132
|
+
usd=r.get("usd"),
|
|
133
|
+
tokens=sum((r.get("usage") or {}).values()) if isinstance(r.get("usage"), dict) else 0,
|
|
134
|
+
model=r.get("model") or side["model"],
|
|
135
|
+
diff=meta,
|
|
136
|
+
base=info.get("base", ""),
|
|
137
|
+
)
|
|
138
|
+
card = board.card(card_id)
|
|
139
|
+
if not meta["rejected"] and r.get("status") != "lost" and out["base"] and card.get("verify_cmd"):
|
|
140
|
+
try:
|
|
141
|
+
clone, sha = _commit_clone(board, card_id, side["label"], r["run_dir"], out["base"], target)
|
|
142
|
+
board.trust_checkout(lead, card_id, clone, target["path"])
|
|
143
|
+
receipt = board.verify(card_id, lead, cwd=clone)
|
|
144
|
+
out["clone"], out["sha"] = clone, sha
|
|
145
|
+
out["receipt"] = {k: receipt.get(k) for k in ("ts", "sha", "exit", "passed", "signature", "isolation")}
|
|
146
|
+
except RaidlockError as e:
|
|
147
|
+
out["verify_error"] = e.message
|
|
148
|
+
elif not card.get("verify_cmd"):
|
|
149
|
+
out["verify_error"] = "the card has no verify command; a duel needs one for signed receipts"
|
|
150
|
+
if reviewer and board.card(card_id)["lane"] == "review":
|
|
151
|
+
try:
|
|
152
|
+
rv = delegate(board, lead, card_id, reviewer, role="reviewer", timeout=timeout, heartbeat=heartbeat)
|
|
153
|
+
out["review"] = {"by": rv.get("agent", ""), "verdict": rv.get("verdict") or rv.get("status", "")}
|
|
154
|
+
except RaidlockError as e:
|
|
155
|
+
out["review"] = {"by": "", "verdict": "", "error": e.message}
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _park(board, lead, card_id, lane, note, owners=()):
|
|
160
|
+
card = board.card(card_id)
|
|
161
|
+
current = card["lane"]
|
|
162
|
+
if owners and card.get("owner") and card["owner"] not in owners:
|
|
163
|
+
raise Denied(f"{card_id} is now held by {card['owner']}, not a duel contestant; the duel stops", card=card_id)
|
|
164
|
+
if current != lane and current != "done":
|
|
165
|
+
board.move(card_id, lane, lead, note=note)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def run(board, lead, card_id, vs, models=None, cmds=None, reviewer=None, timeout=3600.0, heartbeat=60.0):
|
|
169
|
+
"""Run both contestants, verify each accepted patch, record the comparison. Lead only."""
|
|
170
|
+
if not lead.is_lead:
|
|
171
|
+
raise Precondition("only the lead runs a duel")
|
|
172
|
+
sides = _sides(vs, models, cmds)
|
|
173
|
+
reviewer = short_runtime(reviewer) if reviewer else reviewer
|
|
174
|
+
if reviewer and reviewer not in ADAPTERS:
|
|
175
|
+
raise Precondition(f"--reviewer must be one of {', '.join(ADAPTERS)}")
|
|
176
|
+
card = board.card(card_id)
|
|
177
|
+
existing = load(board, card_id)
|
|
178
|
+
if existing and existing.get("status") == "decide":
|
|
179
|
+
raise Precondition(f"{card_id} has an undecided duel", hint=f"raidlock duel {card_id} --winner a|b")
|
|
180
|
+
if card["lane"] != "todo":
|
|
181
|
+
raise Precondition(f"{card_id} is in {card['lane']}; a duel starts from todo", card=card_id)
|
|
182
|
+
if not card.get("verify_cmd"):
|
|
183
|
+
raise Precondition(f"{card_id} has no verify command; a duel is decided on signed receipts", card=card_id)
|
|
184
|
+
target = policy.target(board.workspace, board.dir, card["locks"])
|
|
185
|
+
shutil.rmtree(duel_dir(board, card_id), ignore_errors=True)
|
|
186
|
+
record = {"card": card_id, "started": clock.iso(), "status": "running", "sides": [], "winner": ""}
|
|
187
|
+
_save(board, record)
|
|
188
|
+
for i, side in enumerate(sides):
|
|
189
|
+
result = _run_side(board, lead, card_id, side, target, reviewer, timeout, heartbeat)
|
|
190
|
+
record["sides"].append(result)
|
|
191
|
+
_save(board, record)
|
|
192
|
+
last = i == len(sides) - 1
|
|
193
|
+
if result["status"] == "refused":
|
|
194
|
+
break
|
|
195
|
+
_park(
|
|
196
|
+
board,
|
|
197
|
+
lead,
|
|
198
|
+
card_id,
|
|
199
|
+
"blocked" if last else "todo",
|
|
200
|
+
f"duel: contestant {side['label']} parked",
|
|
201
|
+
owners=(result["agent"],),
|
|
202
|
+
)
|
|
203
|
+
if len(record["sides"]) < 2 or record["sides"][-1]["status"] == "refused":
|
|
204
|
+
record["status"] = "incomplete"
|
|
205
|
+
if board.card(card_id)["lane"] != "todo":
|
|
206
|
+
_park(board, lead, card_id, "todo", "duel stopped before both contestants ran")
|
|
207
|
+
else:
|
|
208
|
+
record["status"] = "decide"
|
|
209
|
+
record["finished"] = clock.iso()
|
|
210
|
+
record["verified"] = [s["label"] for s in record["sides"] if passing(board, card_id, s)]
|
|
211
|
+
_save(board, record)
|
|
212
|
+
rows = compare(record)
|
|
213
|
+
with board.lock():
|
|
214
|
+
board._record(
|
|
215
|
+
"artifact",
|
|
216
|
+
lead.name,
|
|
217
|
+
f"duel on {card_id}: {record['status']}",
|
|
218
|
+
task=card_id,
|
|
219
|
+
artifacts=[T.artifact("duel", data={"rows": rows, "status": record["status"]}, kind="duel")],
|
|
220
|
+
)
|
|
221
|
+
return {**record, "rows": rows}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def compare(record):
|
|
225
|
+
rows = []
|
|
226
|
+
for s in record.get("sides", []):
|
|
227
|
+
receipt = s.get("receipt") or {}
|
|
228
|
+
rows.append(
|
|
229
|
+
{
|
|
230
|
+
"side": s["label"],
|
|
231
|
+
"agent": s.get("agent", ""),
|
|
232
|
+
"runtime": s["runtime"],
|
|
233
|
+
"model": s.get("model") or "default",
|
|
234
|
+
"lane": s.get("lane", ""),
|
|
235
|
+
"verify": "passed" if receipt.get("passed") else ("failed" if receipt else "none"),
|
|
236
|
+
"review": (s.get("review") or {}).get("verdict", "") or "-",
|
|
237
|
+
"files": (s.get("diff") or {}).get("files", 0),
|
|
238
|
+
"bytes": (s.get("diff") or {}).get("bytes", 0),
|
|
239
|
+
"seconds": round((s.get("duration_ms") or 0) / 1000, 1),
|
|
240
|
+
"usd": s.get("usd"),
|
|
241
|
+
"reason": s.get("reason", ""),
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
return rows
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def table(rows):
|
|
248
|
+
head = ("side", "runtime", "model", "verify", "review", "files", "bytes", "seconds", "usd")
|
|
249
|
+
lines = [" ".join(head)]
|
|
250
|
+
for r in rows:
|
|
251
|
+
usd = "-" if r["usd"] is None else f"{r['usd']:.4f}"
|
|
252
|
+
lines.append(
|
|
253
|
+
" ".join(
|
|
254
|
+
str(x)
|
|
255
|
+
for x in (
|
|
256
|
+
r["side"],
|
|
257
|
+
r["runtime"],
|
|
258
|
+
r["model"],
|
|
259
|
+
r["verify"],
|
|
260
|
+
r["review"],
|
|
261
|
+
r["files"],
|
|
262
|
+
r["bytes"],
|
|
263
|
+
r["seconds"],
|
|
264
|
+
usd,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return "\n".join(lines)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def pick(board, lead, card_id, winner):
|
|
272
|
+
"""Lead decision: hand the card to the winner in review, discard the loser's patch, verify the winner again."""
|
|
273
|
+
record = load(board, card_id)
|
|
274
|
+
sides = (record or {}).get("sides") or []
|
|
275
|
+
verified = [s for s in sides if passing(board, card_id, s)]
|
|
276
|
+
if not record or not verified:
|
|
277
|
+
raise Precondition(
|
|
278
|
+
f"no verified duel evidence for {card_id}: a winner needs a passing signed verification receipt",
|
|
279
|
+
card=card_id,
|
|
280
|
+
hint=f"raidlock duel {card_id} --vs <runtime>,<runtime>",
|
|
281
|
+
)
|
|
282
|
+
if record.get("status") != "decide":
|
|
283
|
+
raise Precondition(f"the duel on {card_id} is {record.get('status')}, not waiting for a decision")
|
|
284
|
+
side = next((s for s in sides if winner in (s["label"], s.get("agent"))), None)
|
|
285
|
+
if side is None:
|
|
286
|
+
raise Precondition(f"no contestant {winner}; use a, b or the agent name", card=card_id)
|
|
287
|
+
if side not in verified:
|
|
288
|
+
raise Precondition(
|
|
289
|
+
f"contestant {winner} has no passing signed verification receipt for its commit; the evidence decides",
|
|
290
|
+
card=card_id,
|
|
291
|
+
)
|
|
292
|
+
loser = next(s for s in sides if s is not side)
|
|
293
|
+
with board.lead_write(lead, f"pick duel winner on {card_id}"):
|
|
294
|
+
pass
|
|
295
|
+
_park(
|
|
296
|
+
board,
|
|
297
|
+
lead,
|
|
298
|
+
card_id,
|
|
299
|
+
"todo",
|
|
300
|
+
f"duel: verifying selection {side['label']}",
|
|
301
|
+
owners=tuple(s.get("agent") for s in sides),
|
|
302
|
+
)
|
|
303
|
+
grant = board.claim(card_id, lead, for_agent=side["agent"], checkout=side["clone"])
|
|
304
|
+
worker = board.caller(agent=side["agent"], cap=grant["cap"], fence=grant["fence"], env={})
|
|
305
|
+
board.move(card_id, "review", worker, note=f"duel candidate {side['label']} ({side['runtime']})")
|
|
306
|
+
receipt = board.verify(card_id, lead, cwd=side["clone"])
|
|
307
|
+
if not receipt["passed"]:
|
|
308
|
+
_park(board, lead, card_id, "blocked", "duel final verification failed", owners=(side["agent"],))
|
|
309
|
+
raise Precondition("duel final verification receipt failed", card=card_id)
|
|
310
|
+
if loser.get("run_dir"):
|
|
311
|
+
store.write_json(os.path.join(loser["run_dir"], "discarded.json"), {"discarded": True})
|
|
312
|
+
for name in ("changes.patch", "lead.git", "repo"):
|
|
313
|
+
path = os.path.join(loser.get("run_dir") or "", name)
|
|
314
|
+
if loser.get("run_dir") and os.path.lexists(path):
|
|
315
|
+
W._remove(path)
|
|
316
|
+
if loser.get("clone"):
|
|
317
|
+
shutil.rmtree(os.path.dirname(loser["clone"]), ignore_errors=True)
|
|
318
|
+
record.update(status="decided", winner=side["label"], decided=clock.iso(), by=lead.name)
|
|
319
|
+
loser["discarded"] = True
|
|
320
|
+
_save(board, record)
|
|
321
|
+
with board.lock():
|
|
322
|
+
board._record(
|
|
323
|
+
"status",
|
|
324
|
+
lead.name,
|
|
325
|
+
f"duel on {card_id} decided: {side['label']} ({side['runtime']}) wins",
|
|
326
|
+
task=card_id,
|
|
327
|
+
data={"event": "duel_decided", "winner": side["label"], "loser": loser["label"]},
|
|
328
|
+
)
|
|
329
|
+
return {
|
|
330
|
+
"card": card_id,
|
|
331
|
+
"winner": side["label"],
|
|
332
|
+
"agent": side["agent"],
|
|
333
|
+
"lane": board.card(card_id)["lane"],
|
|
334
|
+
"verify": {"passed": receipt["passed"], "sha": receipt["sha"]},
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _setup(p):
|
|
339
|
+
p.add_argument("card", help="card id")
|
|
340
|
+
p.add_argument("--vs", default=None, help="two runtimes, e.g. claude,codex")
|
|
341
|
+
p.add_argument("--model", default=None, help="one model per contestant, e.g. m1,m2 (empty for default)")
|
|
342
|
+
p.add_argument("--cmd", action="append", default=None, help="command for each shell contestant, in order")
|
|
343
|
+
p.add_argument("--reviewer", default=None, help="runtime that reviews each contestant's patch")
|
|
344
|
+
p.add_argument("--winner", default=None, help="pick the winner: a, b or the contestant's agent name")
|
|
345
|
+
p.add_argument("--timeout", type=float, default=3600.0, help=argparse.SUPPRESS)
|
|
346
|
+
p.add_argument("--heartbeat", type=float, default=60.0, help=argparse.SUPPRESS)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _handler(ctx, a):
|
|
350
|
+
board = ctx.board
|
|
351
|
+
if a.winner:
|
|
352
|
+
if a.vs:
|
|
353
|
+
raise Precondition("--winner and --vs do not combine")
|
|
354
|
+
r = pick(board, ctx.caller(), a.card, a.winner)
|
|
355
|
+
return r, f"{a.card}: {r['winner']} wins; in review as {r['agent']}.\nNext: raidlock done {a.card}"
|
|
356
|
+
if a.vs:
|
|
357
|
+
models = a.model.split(",") if a.model else None
|
|
358
|
+
r = run(board, ctx.caller(), a.card, a.vs, models, a.cmd, a.reviewer, a.timeout, a.heartbeat)
|
|
359
|
+
nxt = f"raidlock duel {a.card} --winner a|b" if r["status"] == "decide" else f"raidlock show {a.card}"
|
|
360
|
+
return r, f"duel on {a.card}: {r['status']}\n{table(r['rows'])}\nNext: {nxt}"
|
|
361
|
+
record = load(board, a.card)
|
|
362
|
+
if not record:
|
|
363
|
+
raise Precondition(f"no duel on {a.card}", hint=f"raidlock duel {a.card} --vs claude,codex")
|
|
364
|
+
rows = compare(record)
|
|
365
|
+
return {**record, "rows": rows}, f"duel on {a.card}: {record['status']}\n{table(rows)}"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def register_cli(add_command):
|
|
369
|
+
add_command(
|
|
370
|
+
"duel", "run a card with two providers and pick the winner on verified evidence", _setup, _handler, True
|
|
371
|
+
)
|