marcus-toolkit 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.
- marcus_toolkit/__init__.py +27 -0
- marcus_toolkit/agent.py +451 -0
- marcus_toolkit/candidates.py +489 -0
- marcus_toolkit/client.py +319 -0
- marcus_toolkit/funnel.py +154 -0
- marcus_toolkit/gate.py +150 -0
- marcus_toolkit/integrity.py +251 -0
- marcus_toolkit/livecheck.py +223 -0
- marcus_toolkit/measure.py +197 -0
- marcus_toolkit/pricing.py +96 -0
- marcus_toolkit/record.py +84 -0
- marcus_toolkit/sampler.py +169 -0
- marcus_toolkit/seed.py +189 -0
- marcus_toolkit/server.py +348 -0
- marcus_toolkit/tender.py +369 -0
- marcus_toolkit/tending.py +946 -0
- marcus_toolkit/usage.py +222 -0
- marcus_toolkit/views.py +150 -0
- marcus_toolkit/watchdog.py +264 -0
- marcus_toolkit-0.1.0.dist-info/METADATA +98 -0
- marcus_toolkit-0.1.0.dist-info/RECORD +25 -0
- marcus_toolkit-0.1.0.dist-info/WHEEL +5 -0
- marcus_toolkit-0.1.0.dist-info/entry_points.txt +5 -0
- marcus_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- marcus_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run guards: prove a run executed the code its record names.
|
|
3
|
+
|
|
4
|
+
* :func:`tree_hash` hashes the files that execute, the construction the
|
|
5
|
+
Minecraft image guard uses (sorted path, length, bytes); a missing file is
|
|
6
|
+
a refusal, never a quietly shorter hash.
|
|
7
|
+
* :func:`committed_hash` hashes the same files as committed in a git
|
|
8
|
+
repository, so uncommitted edits cannot satisfy the guard.
|
|
9
|
+
* :func:`git_version` is the ``marcus-version.txt`` record: checkout,
|
|
10
|
+
branch, commit, dirty file count.
|
|
11
|
+
|
|
12
|
+
Lifted from the Minecraft plumbing's ``plumbing_hash.py``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import subprocess # nosec B404 - git on a path we own, fixed argv
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tree_hash(root: Path, files: Sequence[str]) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Hash the named files under ``root``.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
root : Path
|
|
31
|
+
The directory the run executes from.
|
|
32
|
+
files : Sequence[str]
|
|
33
|
+
The files that execute, relative to ``root``.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
str
|
|
38
|
+
A sha256 hex digest over each file's relative path, byte length and
|
|
39
|
+
bytes, in sorted order.
|
|
40
|
+
|
|
41
|
+
Raises
|
|
42
|
+
------
|
|
43
|
+
FileNotFoundError
|
|
44
|
+
If ``root`` is not a directory or any named file is missing.
|
|
45
|
+
"""
|
|
46
|
+
if not root.is_dir():
|
|
47
|
+
raise FileNotFoundError(f"not a directory: {root}")
|
|
48
|
+
digest = hashlib.sha256()
|
|
49
|
+
for name in sorted(files):
|
|
50
|
+
path = root / name
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
raise FileNotFoundError(f"missing from {root}: {name}")
|
|
53
|
+
content = path.read_bytes()
|
|
54
|
+
digest.update(name.encode())
|
|
55
|
+
digest.update(b"\0")
|
|
56
|
+
digest.update(str(len(content)).encode())
|
|
57
|
+
digest.update(b"\0")
|
|
58
|
+
digest.update(content)
|
|
59
|
+
return digest.hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def committed_hash(
|
|
63
|
+
repo: Path, files: Sequence[str], subdir: str = "", ref: str = "HEAD"
|
|
64
|
+
) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Hash the same files as they are committed in ``repo`` at ``ref``.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
repo : Path
|
|
71
|
+
The repository root.
|
|
72
|
+
files : Sequence[str]
|
|
73
|
+
The files, relative to ``subdir``.
|
|
74
|
+
subdir : str
|
|
75
|
+
Where they live inside the repository (``""`` for the root).
|
|
76
|
+
ref : str
|
|
77
|
+
The commit to read.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
str
|
|
82
|
+
The same construction as :func:`tree_hash`, over the committed bytes.
|
|
83
|
+
|
|
84
|
+
Raises
|
|
85
|
+
------
|
|
86
|
+
FileNotFoundError
|
|
87
|
+
If git cannot be run, the ref does not exist, or a file is not in that commit.
|
|
88
|
+
"""
|
|
89
|
+
digest = hashlib.sha256()
|
|
90
|
+
prefix = f"{subdir.rstrip('/')}/" if subdir else ""
|
|
91
|
+
for name in sorted(files):
|
|
92
|
+
try:
|
|
93
|
+
content = subprocess.run( # nosec B603 B607
|
|
94
|
+
["git", "-C", str(repo), "show", f"{ref}:{prefix}{name}"],
|
|
95
|
+
capture_output=True,
|
|
96
|
+
check=True,
|
|
97
|
+
).stdout
|
|
98
|
+
except (OSError, subprocess.CalledProcessError) as error:
|
|
99
|
+
raise FileNotFoundError(
|
|
100
|
+
f"not in {repo} at {ref}: {prefix}{name} ({error})"
|
|
101
|
+
) from error
|
|
102
|
+
digest.update(name.encode())
|
|
103
|
+
digest.update(b"\0")
|
|
104
|
+
digest.update(str(len(content)).encode())
|
|
105
|
+
digest.update(b"\0")
|
|
106
|
+
digest.update(content)
|
|
107
|
+
return digest.hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def uncommitted(repo: Path, files: Sequence[str], subdir: str = "") -> list[str]:
|
|
111
|
+
"""
|
|
112
|
+
Which of the files have uncommitted changes in ``repo``.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
repo : Path
|
|
117
|
+
The repository root.
|
|
118
|
+
files : Sequence[str]
|
|
119
|
+
The files, relative to ``subdir``.
|
|
120
|
+
subdir : str
|
|
121
|
+
Where they live inside the repository.
|
|
122
|
+
|
|
123
|
+
Returns
|
|
124
|
+
-------
|
|
125
|
+
List[str]
|
|
126
|
+
Paths git reports as changed; empty when git cannot answer.
|
|
127
|
+
"""
|
|
128
|
+
prefix = f"{subdir.rstrip('/')}/" if subdir else ""
|
|
129
|
+
try:
|
|
130
|
+
out = subprocess.run( # nosec B603 B607
|
|
131
|
+
[
|
|
132
|
+
"git",
|
|
133
|
+
"-C",
|
|
134
|
+
str(repo),
|
|
135
|
+
"status",
|
|
136
|
+
"--porcelain",
|
|
137
|
+
"--",
|
|
138
|
+
*(f"{prefix}{n}" for n in files),
|
|
139
|
+
],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
check=True,
|
|
143
|
+
).stdout
|
|
144
|
+
except (OSError, subprocess.CalledProcessError):
|
|
145
|
+
return []
|
|
146
|
+
return [line[3:].strip() for line in out.splitlines() if line.strip()]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def git_version(checkout: Path) -> dict[str, str]:
|
|
150
|
+
"""
|
|
151
|
+
Read the version record of a checkout: what ``marcus-version.txt`` holds.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
checkout : Path
|
|
156
|
+
A git working directory.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
Dict[str, str]
|
|
161
|
+
``checkout``, ``branch``, ``commit`` and ``dirty`` (a count of changed
|
|
162
|
+
files, as text).
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def git(*args: str) -> str:
|
|
166
|
+
try:
|
|
167
|
+
return subprocess.run(
|
|
168
|
+
["git", "-C", str(checkout), *args],
|
|
169
|
+
capture_output=True,
|
|
170
|
+
text=True,
|
|
171
|
+
check=True,
|
|
172
|
+
).stdout.strip() # nosec B603 B607
|
|
173
|
+
except (OSError, subprocess.CalledProcessError):
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
dirty = git("status", "--porcelain")
|
|
177
|
+
return {
|
|
178
|
+
"checkout": str(checkout),
|
|
179
|
+
"branch": git("rev-parse", "--abbrev-ref", "HEAD"),
|
|
180
|
+
"commit": git("rev-parse", "HEAD"),
|
|
181
|
+
"dirty": str(len([line for line in dirty.splitlines() if line.strip()])),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def format_version(version: dict[str, str]) -> str:
|
|
186
|
+
"""Render :func:`git_version` the way the Minecraft run records print it."""
|
|
187
|
+
return (
|
|
188
|
+
f"checkout: {version['checkout']}\nbranch: {version['branch']}\n"
|
|
189
|
+
f"commit: {version['commit']}\ndirty: {version['dirty']} files\n"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def contains_commit(checkout: Path, sha: str) -> bool:
|
|
194
|
+
"""
|
|
195
|
+
Whether ``checkout``'s HEAD descends from ``sha``.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
checkout : Path
|
|
200
|
+
A git working directory.
|
|
201
|
+
sha : str
|
|
202
|
+
A commit, full or abbreviated.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
bool
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
subprocess.run(
|
|
210
|
+
["git", "-C", str(checkout), "merge-base", "--is-ancestor", sha, "HEAD"],
|
|
211
|
+
capture_output=True,
|
|
212
|
+
check=True,
|
|
213
|
+
) # nosec B603 B607
|
|
214
|
+
return True
|
|
215
|
+
except (OSError, subprocess.CalledProcessError):
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main(argv: list[str] | None = None) -> int:
|
|
220
|
+
"""Print the hash of a directory or of a repository's committed tree."""
|
|
221
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
222
|
+
if len(args) >= 2 and args[0] != "--committed":
|
|
223
|
+
try:
|
|
224
|
+
print(tree_hash(Path(args[0]), args[1].split(",")))
|
|
225
|
+
except FileNotFoundError as error:
|
|
226
|
+
print(f"integrity: {error}", file=sys.stderr)
|
|
227
|
+
return 2
|
|
228
|
+
return 0
|
|
229
|
+
if len(args) >= 3 and args[0] == "--committed":
|
|
230
|
+
try:
|
|
231
|
+
print(
|
|
232
|
+
committed_hash(
|
|
233
|
+
Path(args[1]),
|
|
234
|
+
args[2].split(","),
|
|
235
|
+
subdir=args[3] if len(args) > 3 else "",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
except FileNotFoundError as error:
|
|
239
|
+
print(f"integrity: {error}", file=sys.stderr)
|
|
240
|
+
return 2
|
|
241
|
+
return 0
|
|
242
|
+
print(
|
|
243
|
+
"usage: marcus-toolkit-hash <dir> <file,file,...>"
|
|
244
|
+
" | --committed <repo> <file,file,...> [subdir]",
|
|
245
|
+
file=sys.stderr,
|
|
246
|
+
)
|
|
247
|
+
return 2
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
sys.exit(main())
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The toolbox's own live check: a real server, a scripted agent, every reply read back.
|
|
3
|
+
|
|
4
|
+
What it proves, the way every extension's free rehearsal proves it: a server
|
|
5
|
+
under test starts with no key and its model off; a project is seeded with
|
|
6
|
+
two tasks, the second depending on the first; one scripted agent registers,
|
|
7
|
+
pulls the first, reports with evidence, pulls the second (now claimable),
|
|
8
|
+
closes it, and hears the drain; the sampler read the board from outside
|
|
9
|
+
before and after; the server log carries no model-provider host.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
python -m marcus_toolkit.livecheck --checkout ~/dev/marcus --out livecheck-runs
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from . import integrity, measure, record, sampler, seed, server
|
|
25
|
+
from .agent import BoardAgent, jsonl_logger
|
|
26
|
+
from .client import MarcusMCPClient
|
|
27
|
+
|
|
28
|
+
TASKS: list[dict[str, Any]] = [
|
|
29
|
+
{
|
|
30
|
+
"name": "Say where you are",
|
|
31
|
+
"description": "Report your position on the board; nothing else to do.",
|
|
32
|
+
"labels": ["livecheck", "seeded"],
|
|
33
|
+
"completion_criteria": ["The completion report says where the agent is"],
|
|
34
|
+
"estimated_hours": 0.1,
|
|
35
|
+
"priority": "high",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"name": "Say it again, now that the first report is in",
|
|
39
|
+
"description": "Only claimable once the first task is done.",
|
|
40
|
+
"labels": ["livecheck", "seeded"],
|
|
41
|
+
"depends_on": ["Say where you are"],
|
|
42
|
+
"completion_criteria": ["The completion report repeats the position"],
|
|
43
|
+
"estimated_hours": 0.1,
|
|
44
|
+
"priority": "medium",
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(checkout: Path, out: Path, python: str = sys.executable) -> dict[str, Any]:
|
|
50
|
+
"""
|
|
51
|
+
Run the live check and return its verdict.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
checkout : Path
|
|
56
|
+
A Marcus checkout to serve from.
|
|
57
|
+
out : Path
|
|
58
|
+
The ``runs`` root; a ``<stamp>/`` directory is created under it.
|
|
59
|
+
python : str
|
|
60
|
+
The interpreter that runs the server.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
Dict[str, Any]
|
|
65
|
+
``{"PASS": bool, "checks": {...}, "record": str}``.
|
|
66
|
+
"""
|
|
67
|
+
rec = record.RunRecord(out)
|
|
68
|
+
data_dir = rec.dir / "data"
|
|
69
|
+
port = server.free_port()
|
|
70
|
+
config_path = rec.dir / "config_marcus.json"
|
|
71
|
+
server.write_config(
|
|
72
|
+
config_path,
|
|
73
|
+
port=port,
|
|
74
|
+
project_name="runner-livecheck",
|
|
75
|
+
sqlite_db_path=data_dir / "kanban.db",
|
|
76
|
+
)
|
|
77
|
+
version = integrity.git_version(checkout)
|
|
78
|
+
rec.write("marcus-version.txt", integrity.format_version(version))
|
|
79
|
+
for key, value in [
|
|
80
|
+
("run_at", rec.stamp),
|
|
81
|
+
("kind", "marcus-toolkit live check"),
|
|
82
|
+
("port", port),
|
|
83
|
+
("checkout", f"{checkout} @ {version['commit']} ({version['dirty']} dirty)"),
|
|
84
|
+
("ai", "off, keys stripped"),
|
|
85
|
+
]:
|
|
86
|
+
rec.config_line(key, value)
|
|
87
|
+
|
|
88
|
+
srv = server.ServerUnderTest(
|
|
89
|
+
checkout, data_dir, config_path, port, rec.dir / "server.log", python=python
|
|
90
|
+
)
|
|
91
|
+
checks: dict[str, bool] = {}
|
|
92
|
+
detail: dict[str, Any] = {}
|
|
93
|
+
try:
|
|
94
|
+
srv.start(wait_seconds=120)
|
|
95
|
+
client = MarcusMCPClient(srv.url, timeout=120)
|
|
96
|
+
if not client.connect():
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"could not connect to the server under test: {client.last_error}"
|
|
99
|
+
)
|
|
100
|
+
seeded = seed.seed_project(
|
|
101
|
+
client,
|
|
102
|
+
name="runner-livecheck",
|
|
103
|
+
description="the toolbox's own live check",
|
|
104
|
+
tasks=TASKS,
|
|
105
|
+
project_root=rec.dir / "project-root",
|
|
106
|
+
objective="Say where you are; the board is the only channel.",
|
|
107
|
+
)
|
|
108
|
+
rec.config_line("project_id", seeded.project_id)
|
|
109
|
+
board_db = data_dir / "kanban.db"
|
|
110
|
+
before = sampler.sample(board_db, seeded.project_id)
|
|
111
|
+
|
|
112
|
+
state_dir = rec.dir / "state"
|
|
113
|
+
agent = BoardAgent(
|
|
114
|
+
client,
|
|
115
|
+
"probe-1",
|
|
116
|
+
seeded.project_id,
|
|
117
|
+
role="probe",
|
|
118
|
+
skills=["probe"],
|
|
119
|
+
log=jsonl_logger(state_dir / "probe-1" / "board-calls.jsonl"),
|
|
120
|
+
hold_path=state_dir / "probe-1" / "board-hold.json",
|
|
121
|
+
)
|
|
122
|
+
first = agent.pull()
|
|
123
|
+
detail["first_pull"] = first.get("status")
|
|
124
|
+
if first.get("status") == "assigned":
|
|
125
|
+
agent.report(
|
|
126
|
+
"in_progress",
|
|
127
|
+
"at the start line",
|
|
128
|
+
progress=30,
|
|
129
|
+
subject="position.start",
|
|
130
|
+
)
|
|
131
|
+
agent.decide(
|
|
132
|
+
"stayed at the start line",
|
|
133
|
+
"the task asks only for a report",
|
|
134
|
+
subject="position.start",
|
|
135
|
+
)
|
|
136
|
+
agent.opinion(
|
|
137
|
+
"position.start",
|
|
138
|
+
"the start line is where the board says it is",
|
|
139
|
+
"I looked",
|
|
140
|
+
)
|
|
141
|
+
r1 = agent.report(
|
|
142
|
+
"completed",
|
|
143
|
+
"I am at the start line",
|
|
144
|
+
evidence={"position": "start line", "task": first["task"].get("name")},
|
|
145
|
+
)
|
|
146
|
+
detail["first_close"] = r1.get("success")
|
|
147
|
+
second = agent.pull()
|
|
148
|
+
detail["second_pull"] = second.get("status")
|
|
149
|
+
if second.get("status") == "assigned":
|
|
150
|
+
r2 = agent.report(
|
|
151
|
+
"completed",
|
|
152
|
+
"Still at the start line",
|
|
153
|
+
evidence={"position": "start line", "task": second["task"].get("name")},
|
|
154
|
+
)
|
|
155
|
+
detail["second_close"] = r2.get("success")
|
|
156
|
+
third = agent.pull()
|
|
157
|
+
detail["third_pull"] = third.get("status")
|
|
158
|
+
after = sampler.sample(board_db, seeded.project_id)
|
|
159
|
+
|
|
160
|
+
calls = measure.read_calls(state_dir)
|
|
161
|
+
pulls = measure.pulls(calls)
|
|
162
|
+
board = measure.board_from_db(board_db, seeded.project_id)
|
|
163
|
+
model_calls = srv.model_calls()
|
|
164
|
+
measurements = {
|
|
165
|
+
"project_id": seeded.project_id,
|
|
166
|
+
"board": board,
|
|
167
|
+
"board_calls": measure.board_calls(calls),
|
|
168
|
+
"pulls": pulls,
|
|
169
|
+
"sampler_before": before,
|
|
170
|
+
"sampler_after": after,
|
|
171
|
+
"model_calls_in_server_log": model_calls,
|
|
172
|
+
"detail": detail,
|
|
173
|
+
}
|
|
174
|
+
rec.write("measurements.json", json.dumps(measurements, indent=2) + "\n")
|
|
175
|
+
checks = {
|
|
176
|
+
"board holds 3 cards (2 seeded + About)": board["total_tasks"] == 3,
|
|
177
|
+
"the sampler saw 1 claimable and 1 unclaimable before": before[
|
|
178
|
+
"tasks_claimable"
|
|
179
|
+
]
|
|
180
|
+
== 1
|
|
181
|
+
and before["tasks_unclaimable"] == 1,
|
|
182
|
+
"first pull assigned the first task": first.get("status") == "assigned"
|
|
183
|
+
and (first.get("task") or {}).get("name") == TASKS[0]["name"],
|
|
184
|
+
"the first close was accepted": detail.get("first_close") is True,
|
|
185
|
+
"second pull assigned the dependent task": second.get("status")
|
|
186
|
+
== "assigned"
|
|
187
|
+
and (second.get("task") or {}).get("name") == TASKS[1]["name"],
|
|
188
|
+
"the second close was accepted": detail.get("second_close") is True,
|
|
189
|
+
"the third pull heard the drain": third.get("status") == "drained",
|
|
190
|
+
"both seeded cards are done on the board": board["completed"] == 3,
|
|
191
|
+
"the sampler saw nothing claimable after": after["tasks_claimable"] == 0,
|
|
192
|
+
"every board call was read back": bool(calls)
|
|
193
|
+
and all("reply" in c for c in calls),
|
|
194
|
+
"the server made zero model calls": model_calls == 0,
|
|
195
|
+
}
|
|
196
|
+
finally:
|
|
197
|
+
srv.stop()
|
|
198
|
+
rec.copy_in(data_dir / "kanban.db", "kanban.db")
|
|
199
|
+
verdict = {
|
|
200
|
+
"PASS": all(checks.values()) and bool(checks),
|
|
201
|
+
"checks": checks,
|
|
202
|
+
"record": str(rec.dir),
|
|
203
|
+
}
|
|
204
|
+
rec.write("VERDICT.json", json.dumps(verdict, indent=2) + "\n")
|
|
205
|
+
return verdict
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv: list[str] | None = None) -> int:
|
|
209
|
+
"""CLI entry point; exit 0 on PASS."""
|
|
210
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--checkout", required=True, help="a Marcus checkout to serve from"
|
|
213
|
+
)
|
|
214
|
+
parser.add_argument("--out", default="livecheck-runs", help="where the record goes")
|
|
215
|
+
parser.add_argument("--python", default=sys.executable)
|
|
216
|
+
args = parser.parse_args(argv)
|
|
217
|
+
verdict = run(Path(args.checkout).resolve(), Path(args.out), args.python)
|
|
218
|
+
print(json.dumps(verdict, indent=2))
|
|
219
|
+
return 0 if verdict["PASS"] else 1
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
sys.exit(main())
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The measures every board-mediated run reports, read from the record.
|
|
3
|
+
|
|
4
|
+
* pulls: how many ``request_next_task`` calls were assignments, retries or
|
|
5
|
+
the drain verdict, and the idle-poll ratio (test18's 88% idle polls is the
|
|
6
|
+
number this exists to show);
|
|
7
|
+
* the ready frontier as the kernel reported it on non-assignment replies, and
|
|
8
|
+
as the sampler read it from outside;
|
|
9
|
+
* the board's own counts from a copy of ``kanban.db`` when the server is gone
|
|
10
|
+
(a watchdog halt kills the server before measurement runs).
|
|
11
|
+
|
|
12
|
+
Lifted from the Minecraft plumbing's ``measure.py``; the candidate funnel and
|
|
13
|
+
the domain blocks stay with the extension that owns them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sqlite3
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
27
|
+
"""Every parseable row of a JSONL file; a missing file is an empty list."""
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
return []
|
|
30
|
+
rows: list[dict[str, Any]] = []
|
|
31
|
+
for line in path.read_text(errors="replace").splitlines():
|
|
32
|
+
try:
|
|
33
|
+
row = json.loads(line)
|
|
34
|
+
except json.JSONDecodeError:
|
|
35
|
+
continue
|
|
36
|
+
if isinstance(row, dict):
|
|
37
|
+
rows.append(row)
|
|
38
|
+
return rows
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_calls(state_dir: Path) -> list[dict[str, Any]]:
|
|
42
|
+
"""
|
|
43
|
+
Every board call every agent recorded, oldest first.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
state_dir : Path
|
|
48
|
+
The run's state directory; ``board-calls.jsonl`` is read from
|
|
49
|
+
``<agent>/`` and from the doubled ``<agent>/<agent>/`` layout.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
List[Dict[str, Any]]
|
|
54
|
+
Rows with ``at``, ``agent``, ``tool``, ``args``, ``outcome``, ``ms``, ``reply``.
|
|
55
|
+
"""
|
|
56
|
+
files = sorted(
|
|
57
|
+
set(state_dir.glob("*/board-calls.jsonl"))
|
|
58
|
+
| set(state_dir.glob("*/*/board-calls.jsonl"))
|
|
59
|
+
)
|
|
60
|
+
calls: list[dict[str, Any]] = []
|
|
61
|
+
for file in files:
|
|
62
|
+
calls.extend(read_jsonl(file))
|
|
63
|
+
calls.sort(key=lambda c: str(c.get("at", "")))
|
|
64
|
+
return calls
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pulls(calls: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
|
68
|
+
"""
|
|
69
|
+
Count the pulls and compute the idle-poll ratio.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
calls : Iterable[Dict[str, Any]]
|
|
74
|
+
Board calls.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
Dict[str, Any]
|
|
79
|
+
``total``, ``assigned``, ``retry``, ``drained`` and ``idle_poll_ratio``
|
|
80
|
+
(retries over total, ``None`` with no pulls).
|
|
81
|
+
"""
|
|
82
|
+
outcomes = Counter(
|
|
83
|
+
str(c.get("outcome")) for c in calls if c.get("tool") == "request_next_task"
|
|
84
|
+
)
|
|
85
|
+
total = sum(outcomes.values())
|
|
86
|
+
return {
|
|
87
|
+
"total": total,
|
|
88
|
+
"assigned": outcomes.get("assigned", 0),
|
|
89
|
+
"retry": outcomes.get("retry", 0),
|
|
90
|
+
"drained": outcomes.get("drained", 0),
|
|
91
|
+
"idle_poll_ratio": (
|
|
92
|
+
round(outcomes.get("retry", 0) / total, 3) if total else None
|
|
93
|
+
),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def frontier_series(calls: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
|
|
98
|
+
"""Extract the frontier width the kernel reported on each non-assignment pull.
|
|
99
|
+
|
|
100
|
+
Returns ``(agent, width)`` pairs.
|
|
101
|
+
"""
|
|
102
|
+
series: list[tuple[str, int]] = []
|
|
103
|
+
for call in calls:
|
|
104
|
+
if call.get("tool") != "request_next_task":
|
|
105
|
+
continue
|
|
106
|
+
reply = call.get("reply") or {}
|
|
107
|
+
drain = reply.get("drain") or reply.get("frontier") or {}
|
|
108
|
+
width = drain.get("tasks_claimable") if isinstance(drain, dict) else None
|
|
109
|
+
if isinstance(width, int):
|
|
110
|
+
series.append((str(call.get("agent")), width))
|
|
111
|
+
return series
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def summarize(values: list[int]) -> dict[str, Any]:
|
|
115
|
+
"""Summarise a series: samples, min, max and mean; ``None`` values when empty."""
|
|
116
|
+
if not values:
|
|
117
|
+
return {"samples": 0, "min": None, "max": None, "mean": None}
|
|
118
|
+
return {
|
|
119
|
+
"samples": len(values),
|
|
120
|
+
"min": min(values),
|
|
121
|
+
"max": max(values),
|
|
122
|
+
"mean": round(sum(values) / len(values), 3),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def board_calls(calls: Iterable[dict[str, Any]]) -> dict[str, int]:
|
|
127
|
+
"""How many calls of each tool the agents made."""
|
|
128
|
+
return dict(Counter(str(c.get("tool")) for c in calls))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def board_from_db(kanban_db: Path, project_id: str) -> dict[str, Any]:
|
|
132
|
+
"""
|
|
133
|
+
Read the board's counts from a copy of ``kanban.db``.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
kanban_db : Path
|
|
138
|
+
The board file.
|
|
139
|
+
project_id : str
|
|
140
|
+
The project to count.
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
Dict[str, Any]
|
|
145
|
+
``total_tasks``, ``completed``, ``in_progress``, ``blocked``, ``todo``,
|
|
146
|
+
``declined``.
|
|
147
|
+
"""
|
|
148
|
+
con = sqlite3.connect(f"file:{kanban_db}?mode=ro", uri=True, timeout=5)
|
|
149
|
+
try:
|
|
150
|
+
rows = con.execute(
|
|
151
|
+
"select status, count(*) from tasks where project_id = ? group by status",
|
|
152
|
+
(project_id,),
|
|
153
|
+
).fetchall()
|
|
154
|
+
finally:
|
|
155
|
+
con.close()
|
|
156
|
+
by_status = {str(status): int(count) for status, count in rows}
|
|
157
|
+
return {
|
|
158
|
+
"total_tasks": sum(by_status.values()),
|
|
159
|
+
"completed": by_status.get("done", 0) + by_status.get("completed", 0),
|
|
160
|
+
"in_progress": by_status.get("in_progress", 0),
|
|
161
|
+
"blocked": by_status.get("blocked", 0),
|
|
162
|
+
"declined": by_status.get("declined", 0),
|
|
163
|
+
"todo": by_status.get("todo", 0),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def sampled_frontier(samples: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
|
168
|
+
"""Summarise the sampler's frontier series, keeping the series."""
|
|
169
|
+
widths = [
|
|
170
|
+
int(s["tasks_claimable"])
|
|
171
|
+
for s in samples
|
|
172
|
+
if isinstance(s.get("tasks_claimable"), int)
|
|
173
|
+
]
|
|
174
|
+
out = summarize(widths)
|
|
175
|
+
out["series"] = widths
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def utilization(
|
|
180
|
+
samples: Iterable[dict[str, Any]], agents: int | None = None
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
"""How many agents held a task at each sample, and the mean fraction busy."""
|
|
183
|
+
holding = [
|
|
184
|
+
len(s.get("holders") or {})
|
|
185
|
+
for s in samples
|
|
186
|
+
if isinstance(s.get("holders"), dict)
|
|
187
|
+
]
|
|
188
|
+
out = {
|
|
189
|
+
"samples": len(holding),
|
|
190
|
+
"mean_agents_holding_a_task": (
|
|
191
|
+
round(sum(holding) / len(holding), 3) if holding else None
|
|
192
|
+
),
|
|
193
|
+
"series": holding,
|
|
194
|
+
}
|
|
195
|
+
if agents and holding:
|
|
196
|
+
out["mean_fraction_busy"] = round(sum(holding) / len(holding) / agents, 3)
|
|
197
|
+
return out
|