evalctl 0.3.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.
evalctl/cli.py ADDED
@@ -0,0 +1,2352 @@
1
+ from __future__ import annotations
2
+
3
+ import concurrent.futures
4
+ import fnmatch
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import shlex
11
+ import shutil
12
+ import signal
13
+ import socket
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path, PurePosixPath
23
+ from typing import Any
24
+
25
+ from . import __version__
26
+
27
+ CONTRACT_VERSION = 1
28
+ TOOL = "evalctl"
29
+ DEFAULT_COMMAND_SCORER_TIMEOUT_SECONDS = 30
30
+ DEFAULT_RESERVATION_TTL_SECONDS = 3600
31
+ SAFE_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
32
+ BUILTIN_SCORERS = ("contains", "regex", "exact", "json-schema", "numeric-threshold", "file-exists", "exit-code", "workspace-diff")
33
+
34
+ EXIT_CODES = {
35
+ 0: {"meaning": "success", "retryable": None},
36
+ 1: {"meaning": "user-input error", "retryable": False},
37
+ 2: {"meaning": "safety block", "retryable": False},
38
+ 3: {"meaning": "tool-environment error", "retryable": None},
39
+ 4: {"meaning": "transient failure", "retryable": True},
40
+ 5: {"meaning": "conflict", "retryable": False},
41
+ 6: {"meaning": "eval failure", "retryable": False},
42
+ }
43
+
44
+ CODE_REGISTRY = {
45
+ "E_CASE_INVALID": {"class": "user-input", "exit": 1, "where": ["validate", "run"], "retryable": False, "surface": "envelope"},
46
+ "E_SCHEMA_VIOLATION": {"class": "user-input", "exit": 1, "where": ["validate", "run"], "retryable": False, "surface": "envelope"},
47
+ "E_SUITE_NOT_FOUND": {"class": "user-input", "exit": 1, "where": ["run", "report", "validate"], "retryable": False, "surface": "envelope"},
48
+ "E_RUN_NOT_FOUND": {"class": "user-input", "exit": 1, "where": ["status", "report", "resume"], "retryable": False, "surface": "envelope"},
49
+ "E_RUN_CORRUPT": {"class": "user-input", "exit": 1, "where": ["resume"], "retryable": False, "surface": "envelope"},
50
+ "E_RUNNER_FAILED": {"class": "tool-env", "exit": 3, "where": ["run"], "retryable": None, "surface": "runner_json"},
51
+ "E_RUNNER_TIMEOUT": {"class": "tool-env", "exit": 3, "where": ["run"], "retryable": None, "surface": "runner_json"},
52
+ "E_SPOOLCTL_UNAVAILABLE": {"class": "tool-env", "exit": 3, "where": ["run", "resume"], "retryable": False, "surface": "envelope"},
53
+ "E_SPOOLCTL_INCOMPATIBLE": {"class": "tool-env", "exit": 3, "where": ["run", "resume"], "retryable": False, "surface": "envelope"},
54
+ "E_JOB_TRANSIENT": {"class": "transient", "exit": 4, "where": ["run", "resume"], "retryable": True, "surface": "envelope"},
55
+ "E_SCORER_FAILED": {"class": "tool-env", "exit": 3, "where": ["run", "report"], "retryable": None, "surface": "envelope"},
56
+ "E_SCORER_CASE_FAILED": {"class": "tool-env", "where": ["run", "replay"], "surface": "score_json"},
57
+ "E_RUN_BUSY": {"class": "transient", "exit": 4, "where": ["run", "resume"], "retryable": True, "surface": "envelope"},
58
+ "E_RUN_CONFLICT": {"class": "conflict", "exit": 5, "where": ["run", "init", "replay", "suite", "case", "scorer"], "retryable": False, "surface": "envelope"},
59
+ "W_UNSANDBOXED_RUNNER": {"class": "warning", "where": ["run", "replay"], "surface": "envelope"},
60
+ "W_REPLAY_CASE_ABSENT": {"class": "warning", "where": ["replay"], "surface": "envelope"},
61
+ "W_NOTHING_TO_REPLAY": {"class": "warning", "where": ["replay"], "surface": "envelope"},
62
+ "W_TEXT_DIFF_APPROXIMATED": {"class": "warning", "where": ["run"], "surface": "envelope"},
63
+ "W_OUTPUT_TRUNCATED": {"class": "warning", "where": ["run"], "surface": "envelope"},
64
+ "W_PATH_UNREADABLE": {"class": "warning", "where": ["run"], "surface": "envelope"},
65
+ "W_PARTIAL_RUN": {"class": "warning", "where": ["run", "report"], "surface": "envelope"},
66
+ "W_RESERVATION_RECLAIMED": {"class": "warning", "where": ["run", "resume"], "surface": "envelope"},
67
+ "W_RESUME_NOTHING_PENDING": {"class": "warning", "where": ["resume"], "surface": "envelope"},
68
+ }
69
+
70
+
71
+ class EvalctlError(Exception):
72
+ def __init__(self, code: str, message: str, hint: str, exit_code: int = 1, **ctx: Any) -> None:
73
+ super().__init__(message)
74
+ self.error = {"code": code, "message": message, "hint": hint, "exit_code": exit_code, **ctx}
75
+ self.exit_code = exit_code
76
+
77
+
78
+ def now_iso() -> str:
79
+ source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
80
+ if source_date_epoch:
81
+ try:
82
+ ts = int(source_date_epoch)
83
+ except ValueError:
84
+ raise EvalctlError("E_CASE_INVALID", f"SOURCE_DATE_EPOCH must be an integer Unix timestamp (got {source_date_epoch})", "unset SOURCE_DATE_EPOCH or set it to seconds since epoch", 1)
85
+ return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z")
86
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
87
+
88
+
89
+ def stable_json(value: Any) -> str:
90
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
91
+
92
+
93
+ def sha256_text(value: str) -> str:
94
+ return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
95
+
96
+
97
+ def sha256_bytes(value: bytes) -> str:
98
+ return "sha256:" + hashlib.sha256(value).hexdigest()
99
+
100
+
101
+ def envelope(data: Any, *, ok: bool = True, warnings: list[dict[str, Any]] | None = None,
102
+ commands: list[dict[str, Any]] | None = None, errors: list[dict[str, Any]] | None = None,
103
+ started: float | None = None) -> dict[str, Any]:
104
+ started = started or time.time()
105
+ payload = data if ok else None
106
+ meta: dict[str, Any] = {
107
+ "request_id": "req_" + uuid.uuid4().hex[:20],
108
+ "ts_iso": now_iso(),
109
+ "data_hash": sha256_text(stable_json(payload)) if ok else None,
110
+ "contract_version": CONTRACT_VERSION,
111
+ "elapsed_ms": int((time.time() - started) * 1000),
112
+ }
113
+ return {
114
+ "ok": ok,
115
+ "tool_version": __version__,
116
+ "data": payload,
117
+ "meta": meta,
118
+ "warnings": warnings or [],
119
+ "commands": commands or [],
120
+ "errors": errors or [],
121
+ }
122
+
123
+
124
+ def wants_json(argv: list[str]) -> bool:
125
+ return "--json" in argv or "--format" in argv and value_after(argv, "--format") == "json" or not sys.stdout.isatty()
126
+
127
+
128
+ def value_after(argv: list[str], flag: str, default: str | None = None) -> str | None:
129
+ if flag not in argv:
130
+ return default
131
+ idx = argv.index(flag)
132
+ if idx + 1 >= len(argv):
133
+ raise EvalctlError("E_CASE_INVALID", f"{flag} requires a value", f"try: {TOOL} --help", 1)
134
+ return argv[idx + 1]
135
+
136
+
137
+ def has_flag(argv: list[str], flag: str) -> bool:
138
+ return flag in argv
139
+
140
+
141
+ def strip_flags(argv: list[str], flags_with_values: set[str], bool_flags: set[str]) -> list[str]:
142
+ out: list[str] = []
143
+ i = 0
144
+ while i < len(argv):
145
+ item = argv[i]
146
+ if item in bool_flags:
147
+ i += 1
148
+ elif item in flags_with_values:
149
+ i += 2
150
+ else:
151
+ out.append(item)
152
+ i += 1
153
+ return out
154
+
155
+
156
+ def print_envelope(data: Any, *, json_mode: bool, human: str | None = None, warnings: list[dict[str, Any]] | None = None,
157
+ commands: list[dict[str, Any]] | None = None, started: float | None = None) -> int:
158
+ if json_mode:
159
+ print(stable_json(envelope(data, warnings=warnings, commands=commands, started=started)))
160
+ else:
161
+ print(human if human is not None else json.dumps(data, indent=2, sort_keys=True))
162
+ return 0
163
+
164
+
165
+ def print_error(err: EvalctlError, *, json_mode: bool, started: float | None = None) -> int:
166
+ print(err.error["message"], file=sys.stderr)
167
+ if json_mode:
168
+ print(stable_json(envelope(None, ok=False, errors=[err.error], started=started)))
169
+ return err.exit_code
170
+
171
+
172
+ def help_text() -> str:
173
+ return f"""evalctl Local-first evals for agents.
174
+
175
+ USAGE: evalctl <command> [flags]
176
+
177
+ COMMANDS:
178
+ capabilities --json Machine contract
179
+ schema <verb> --json Output schema for a verb
180
+ robot-docs guide Agent workflow handbook
181
+ init [--force] Scaffold evals/ with code-review suite
182
+ validate [suite] [--json] Validate suite files
183
+ run <suite> [--jobs N] [--timeout S] [--run-id ID] [--resume ID] [--queue spoolctl] [--slots N] [--reservation-ttl S] [--fail-on-fail] [--json]
184
+ jobs list|get|prune [--yes] [--json]
185
+ replay --failed <run-id|--run-dir PATH> [--suite S] [--run-id NEW] [--force] [--json]
186
+ suite add <name> [--runner-argv ARGV|--runner-command CMD --shell] [--json]
187
+ case add <suite> --task TEXT --workspace PATH [--id ID] [--diff PATH] [--expect-json JSON] [--json]
188
+ scorer add <suite> --name NAME [--required|--advisory] [--id ID] [--argv ARGV|--command CMD --shell] [--json]
189
+ status <run-id|--run-dir PATH> [--json]
190
+ report <run-id|--run-dir PATH> [--format markdown|json] [--json]
191
+
192
+ GLOBAL FLAGS:
193
+ --json Structured envelope output
194
+ --no-color Suppress ANSI
195
+ --version Print version
196
+ --help, -h Show help
197
+
198
+ EXIT CODES: 0 ok; 1 input; 2 safety; 3 environment; 4 transient; 5 conflict; 6 eval failed.
199
+ AGENT/AUTOMATION:
200
+ Machine contract: evalctl capabilities --json
201
+ Workflow guide: evalctl robot-docs guide
202
+ Schemas: evalctl schema <verb> --json
203
+ """
204
+
205
+
206
+ def capabilities_data() -> dict[str, Any]:
207
+ try:
208
+ spool = probe_spoolctl()
209
+ spoolctl_status = {"available": True, "planned": False, "minimum_version": "0.4.1", "version": spool.get("version") or spool.get("tool_version")}
210
+ except EvalctlError:
211
+ spoolctl_status = {"available": False, "planned": False, "minimum_version": "0.4.1"}
212
+ verbs = {
213
+ "capabilities": {"description": "Return the machine contract.", "json": True, "mutates": False, "flags": ["--json"], "exit_codes": [0]},
214
+ "schema": {"description": "Return output schemas.", "json": True, "mutates": False, "args": ["verb"], "flags": ["--json"], "exit_codes": [0, 1]},
215
+ "robot-docs": {"description": "Return agent workflow guide.", "json": False, "mutates": False, "args": ["guide"], "exit_codes": [0, 1]},
216
+ "init": {"description": "Scaffold evals/ tree with sample code-review suite.", "json": True, "mutates": True, "flags": ["--json", "--force"], "exit_codes": [0, 5]},
217
+ "validate": {"description": "Validate suite.json, cases.jsonl, fixtures, scorer refs, and runner config.", "json": True, "mutates": False, "args": ["suite"], "flags": ["--json"], "exit_codes": [0, 1]},
218
+ "run": {"description": "Run a suite and produce a portable, resumable run directory.", "json": True, "mutates": True, "args": ["suite"], "flags": ["--json", "--jobs", "--timeout", "--run-id", "--resume", "--queue", "--slots", "--reservation-ttl", "--fail-on-fail"], "exit_codes": [0, 1, 3, 4, 5, 6]},
219
+ "jobs": {"description": "Inspect and prune local run/reservation/queue state.", "json": True, "mutates": True, "args": ["list", "get", "prune"], "flags": ["--json", "--yes", "--force"], "exit_codes": [0, 1]},
220
+ "replay": {"description": "Re-execute failed/errored cases from a source run into a linked partial run.", "json": True, "mutates": True, "args": ["run-id"], "flags": ["--json", "--failed", "--run-dir", "--suite", "--run-id", "--force", "--jobs", "--timeout", "--fail-on-fail"], "exit_codes": [0, 1, 3, 4, 5, 6]},
221
+ "suite": {"description": "Author suites, including suite add.", "json": True, "mutates": True, "args": ["add", "name"], "flags": ["--json", "--runner-argv", "--runner-command", "--shell"], "exit_codes": [0, 1, 5]},
222
+ "case": {"description": "Author cases, including case add.", "json": True, "mutates": True, "args": ["add", "suite"], "flags": ["--json", "--task", "--workspace", "--id", "--diff", "--expect-json"], "exit_codes": [0, 1, 5]},
223
+ "scorer": {"description": "Author scorers, including built-in and command scorers.", "json": True, "mutates": True, "args": ["add", "suite"], "flags": ["--json", "--name", "--required", "--advisory", "--id", "--argv", "--command", "--shell", "--timeout"], "exit_codes": [0, 1, 5]},
224
+ "status": {"description": "Diagnose run state.", "json": True, "mutates": False, "args": ["run-id"], "flags": ["--json", "--run-dir"], "exit_codes": [0, 1]},
225
+ "report": {"description": "Generate markdown or JSON report from run artifacts.", "json": True, "mutates": False, "args": ["run-id"], "flags": ["--json", "--format", "--run-dir"], "exit_codes": [0, 1, 3]},
226
+ }
227
+ return {
228
+ "tool_name": TOOL,
229
+ "contract_version": CONTRACT_VERSION,
230
+ "features": ["universal_envelope", "deterministic_output", "artifact_replay", "workspace_diff", "authoring", "execution_replay", "command_scorer", "durable_runs", "resumable", "run_state_jobs", "queue_spoolctl"],
231
+ "verbs": verbs,
232
+ "global_flags": {"--json": "structured envelope", "--help": "help", "--version": "version", "--no-color": "suppress ANSI"},
233
+ "exit_codes": {str(k): v for k, v in EXIT_CODES.items()},
234
+ "error_codes": CODE_REGISTRY,
235
+ "env_vars": {
236
+ "EVALCTL_CASE_FILE": "materialized case JSON passed to runner",
237
+ "EVALCTL_WORKSPACE": "fresh per-case workspace",
238
+ "EVALCTL_OUTPUT_FILE": "runner response destination",
239
+ "EVALCTL_TASK_FILE": "task text file",
240
+ "EVALCTL_DIFF_FILE": "review diff file when present",
241
+ "SOURCE_DATE_EPOCH": "controls deterministic timestamps, including run created_ts",
242
+ },
243
+ "integrations": {
244
+ "spoolctl": spoolctl_status,
245
+ "inferctl": {"available": False, "planned": True},
246
+ },
247
+ "schemas_uri": "evalctl schema <verb> --json",
248
+ "robot_docs_uri": "evalctl robot-docs guide",
249
+ }
250
+
251
+
252
+ def schema_object(required: list[str], properties: dict[str, Any], *, additional: bool = True) -> dict[str, Any]:
253
+ return {"type": "object", "required": required, "properties": properties, "additionalProperties": additional}
254
+
255
+
256
+ RUN_SUMMARY_SCHEMA = schema_object(
257
+ ["ok", "case_count", "status_counts"],
258
+ {
259
+ "ok": {"type": "boolean"},
260
+ "case_count": {"type": "integer", "minimum": 0},
261
+ "status_counts": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}},
262
+ },
263
+ )
264
+
265
+ DATA_SCHEMAS = {
266
+ "capabilities": schema_object(
267
+ ["tool_name", "contract_version", "features", "verbs", "global_flags", "exit_codes", "error_codes", "env_vars", "integrations", "schemas_uri", "robot_docs_uri"],
268
+ {
269
+ "tool_name": {"type": "string"},
270
+ "contract_version": {"type": "integer"},
271
+ "features": {"type": "array", "items": {"type": "string"}},
272
+ "verbs": {"type": "object", "additionalProperties": {"type": "object"}},
273
+ "global_flags": {"type": "object", "additionalProperties": {"type": "string"}},
274
+ "exit_codes": {"type": "object", "additionalProperties": {"type": "object"}},
275
+ "error_codes": {"type": "object", "additionalProperties": schema_object(["class", "where", "surface"], {"class": {"type": "string"}, "exit": {"type": "integer"}, "where": {"type": "array", "items": {"type": "string"}}, "retryable": {"type": ["boolean", "null"]}, "surface": {"type": "string"}})},
276
+ "env_vars": {"type": "object", "additionalProperties": {"type": "string"}},
277
+ "integrations": {"type": "object", "additionalProperties": {"type": "object"}},
278
+ "schemas_uri": {"type": "string"},
279
+ "robot_docs_uri": {"type": "string"},
280
+ },
281
+ ),
282
+ "schema": schema_object(
283
+ ["envelope_schema", "schemas", "definitions"],
284
+ {
285
+ "envelope_schema": {"type": "object"},
286
+ "schemas": {"type": "object", "additionalProperties": {"type": "object"}},
287
+ "definitions": {"type": "object"},
288
+ },
289
+ ),
290
+ "init": schema_object(
291
+ ["created", "suite", "files"],
292
+ {"created": {"type": "string"}, "suite": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}},
293
+ ),
294
+ "validate": schema_object(
295
+ ["suite", "case_count", "valid"],
296
+ {"suite": {"type": "string"}, "case_count": {"type": "integer", "minimum": 0}, "valid": {"type": "boolean"}},
297
+ ),
298
+ "run": schema_object(
299
+ ["run_id", "run_dir", "run", "report_hash"],
300
+ {"run_id": {"type": "string"}, "run_dir": {"type": "string"}, "run": RUN_SUMMARY_SCHEMA, "report_hash": {"type": "string"}, "existing": {"type": "boolean"}, "queue": {"type": "object"}},
301
+ ),
302
+ "jobs": schema_object(
303
+ [],
304
+ {
305
+ "runs": {"type": "array", "items": {"type": "object"}},
306
+ "count": {"type": "integer", "minimum": 0},
307
+ "run_id": {"type": "string"},
308
+ "run_dir": {"type": "string"},
309
+ "state": {"type": "string"},
310
+ "reservation": {"type": "object"},
311
+ "cases": {"type": "object"},
312
+ "queue_jobs": {"type": "array", "items": {"type": "object"}},
313
+ "confirmed": {"type": "boolean"},
314
+ "candidates": {"type": "object"},
315
+ "removed": {"type": "object"},
316
+ "refused": {"type": "array", "items": {"type": "object"}},
317
+ },
318
+ ),
319
+ "replay": schema_object(
320
+ ["replayed_from", "cases_replayed"],
321
+ {
322
+ "replayed_from": {"type": "string"},
323
+ "cases_replayed": {"type": "integer", "minimum": 0},
324
+ "run_id": {"type": "string"},
325
+ "run_dir": {"type": "string"},
326
+ "run": RUN_SUMMARY_SCHEMA,
327
+ "report_hash": {"type": "string"},
328
+ },
329
+ ),
330
+ "suite": schema_object(
331
+ ["suite", "suite_dir", "created", "files"],
332
+ {
333
+ "suite": {"type": "string"},
334
+ "suite_dir": {"type": "string"},
335
+ "created": {"type": "boolean"},
336
+ "files": {"type": "array", "items": {"type": "string"}},
337
+ },
338
+ ),
339
+ "case": schema_object(
340
+ ["suite", "id", "created", "case"],
341
+ {
342
+ "suite": {"type": "string"},
343
+ "id": {"type": "string"},
344
+ "created": {"type": "boolean"},
345
+ "case": {"type": "object"},
346
+ },
347
+ ),
348
+ "scorer": schema_object(
349
+ ["suite", "scorer", "created"],
350
+ {
351
+ "suite": {"type": "string"},
352
+ "scorer": {"type": "string"},
353
+ "id": {"type": ["string", "null"]},
354
+ "created": {"type": "boolean"},
355
+ },
356
+ ),
357
+ "status": schema_object(
358
+ ["run_id", "run_dir", "run", "cases", "recommended_action"],
359
+ {
360
+ "run_id": {"type": "string"},
361
+ "run_dir": {"type": "string"},
362
+ "run": RUN_SUMMARY_SCHEMA,
363
+ "cases": {"type": "array", "items": {"type": "object"}},
364
+ "recommended_action": schema_object(["command", "rationale", "alternatives"], {"command": {"type": "string"}, "rationale": {"type": "string"}, "alternatives": {"type": "array", "items": {"type": "string"}}}),
365
+ },
366
+ ),
367
+ "report": schema_object(
368
+ ["run", "failures", "cases", "run_id", "report_hash"],
369
+ {
370
+ "run": schema_object(["ok", "suite", "case_count", "status_counts"], {"ok": {"type": "boolean"}, "suite": {"type": "string"}, "case_count": {"type": "integer", "minimum": 0}, "status_counts": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}}),
371
+ "failures": {"type": "array", "items": {"type": "object"}},
372
+ "cases": {"type": "array", "items": {"type": "object"}},
373
+ "run_id": {"type": "string"},
374
+ "report_hash": {"type": "string"},
375
+ },
376
+ ),
377
+ }
378
+
379
+
380
+ def schema_data(verb: str | None = None) -> dict[str, Any]:
381
+ envelope_schema = {
382
+ "type": "object",
383
+ "required": ["ok", "tool_version", "data", "meta", "warnings", "commands", "errors"],
384
+ "properties": {
385
+ "ok": {"type": "boolean"},
386
+ "tool_version": {"type": "string"},
387
+ "data": {},
388
+ "meta": {"type": "object"},
389
+ "warnings": {"type": "array"},
390
+ "commands": {"type": "array"},
391
+ "errors": {"type": "array"},
392
+ },
393
+ }
394
+ schemas = DATA_SCHEMAS
395
+ if verb:
396
+ if verb not in schemas:
397
+ raise EvalctlError("E_CASE_INVALID", f"unknown schema verb '{verb}'", "try: evalctl capabilities --json", 1)
398
+ schemas = {verb: schemas[verb]}
399
+ return {"envelope_schema": envelope_schema, "schemas": schemas, "definitions": {}}
400
+
401
+
402
+ def robot_docs() -> str:
403
+ return """# evalctl - Agent Workflow Guide
404
+
405
+ ## Quick reference
406
+
407
+ Capabilities: `evalctl capabilities --json`
408
+ Schemas: `evalctl schema run --json`
409
+ Initialize: `evalctl init --json`
410
+ Validate: `evalctl validate code-review --json`
411
+ Run: `evalctl run code-review --json`
412
+ Inspect: `evalctl status <run-id> --json`
413
+ Report: `evalctl report <run-id> --format json`
414
+ Artifact replay: `evalctl report --run-dir <copied-run-dir> --format json`
415
+
416
+ ## v0.3 workflow
417
+
418
+ 1. Scaffold `evals/suites/code-review/` with `evalctl init`.
419
+ 2. Or author a new suite without hand-editing JSON:
420
+ `evalctl suite add demo --runner-argv "python3 $EVALCTL_WORKSPACE/r.py" --json`;
421
+ create fixtures; `evalctl case add demo --task "..." --workspace fixtures/x --json`;
422
+ `evalctl scorer add demo --name exact --required --json`.
423
+ 3. Run `evalctl validate <suite> --json` before executing local code.
424
+ 4. Run `evalctl run <suite> --json`. The runner is arbitrary local code; evalctl is not a sandbox.
425
+ 5. If a run is interrupted, use `evalctl run --resume <run-id> --json`. Resume uses
426
+ `run.json`, terminal `cases/<id>/state.json` markers, and the original suite snapshot;
427
+ it skips terminal cases and re-runs only unfinished cases.
428
+ 6. Use `jobs list|get|prune --json` to inspect completed, running, stale, and orphaned
429
+ local run state. Reservations are TTL files with a background heartbeat; no daemon or
430
+ lock server is required.
431
+ 7. Optionally use `evalctl run <suite> --queue spoolctl --json` to delegate runner
432
+ execution to spoolctl. Spoolctl is optional and must be >= 0.4.1; absent or incompatible
433
+ spoolctl is a hard error only when `--queue spoolctl` is requested. The queue DB is
434
+ per-run `.spoolctl.db`, so externally managed cross-machine workers require a shared
435
+ filesystem and are not a general hosted-worker mode.
436
+ 8. Use `status` for run state and recommended next command.
437
+ 9. Use `report --format json` for a deterministic report envelope or `--format markdown` for a human report.
438
+ 10. Copy a completed run directory anywhere and run `report --run-dir <path> --format json`;
439
+ evalctl recomputes scores from report artifacts and does not require durability sidecars.
440
+ 11. After fixing a failed runner/fixture, run `evalctl replay --failed <run-id> --json`
441
+ to re-execute only failed/errored cases into a fresh partial run. `replay --run-id`
442
+ names the destination run, not the source.
443
+
444
+ ## Command scorers
445
+
446
+ `evalctl scorer add <suite> --name command --id judge1 --argv "python3 scorer.py"`
447
+ adds an external scorer. A command scorer receives `EVALCTL_CASE_FILE`,
448
+ `EVALCTL_OUTPUT_FILE`, and `EVALCTL_WORKSPACE`, emits one JSON verdict, and is executed
449
+ once at run time. Its normalized verdict is stored under
450
+ `cases/<case_id>/scorers/<id>.json`; reports and artifact replay read that artifact and
451
+ never re-execute the scorer binary. Command scorers execute arbitrary local code and are
452
+ covered by the same unsandboxed warning as runners.
453
+
454
+ ## Exit-code branching
455
+
456
+ `0` success, `1` input error, `2` safety block, `3` tool environment error, `4` retryable transient, `5` conflict, `6` eval failure from `run --fail-on-fail`.
457
+
458
+ ## Error-code surfaces
459
+
460
+ Codes with `surface:"envelope"` appear in `errors[]` or `warnings[]` and predict
461
+ the command's process-exit class. Codes with `surface:"runner_json"` appear as
462
+ per-case `runner.json.error_code` reason codes. Codes with `surface:"score_json"`
463
+ appear as per-case scorer verdict reason codes, for example
464
+ `E_SCORER_CASE_FAILED` in `cases/<id>/scorers/<scorer_id>.json` or `score.json`.
465
+ A runner timeout, runner spawn failure, or command-scorer failure is reportable
466
+ case data: `run`/`replay` exits 0 by default, exits 6 with `--fail-on-fail`, emits
467
+ `W_PARTIAL_RUN`, and does not put the per-case reason code in `errors[]`.
468
+
469
+ ## Durable runs
470
+
471
+ Fresh runs write `run.json` once before execution and terminal
472
+ `cases/<case_id>/state.json` markers after each case's report artifacts are complete.
473
+ `manifest.json` is finalized from that durable state. `SOURCE_DATE_EPOCH` controls
474
+ `created_ts`, which makes manifest parity tests deterministic. Durability sidecars are
475
+ operational state and are not part of `report_hash`.
476
+
477
+ ## Artifact writes
478
+
479
+ JSON artifacts are written by creating a temporary file in the target directory
480
+ and replacing the final path with `os.replace`. This guarantees atomic visibility:
481
+ readers never see a half-written final JSON file. It is not a full crash-durable
482
+ guarantee; v0.1.1 does not fsync files or directories.
483
+
484
+ ## Deferred
485
+
486
+ Compare, inferctl route capture, externally managed shared worker fleets, and
487
+ LLM-as-judge scoring are roadmap items, not v0.3 commands.
488
+ """
489
+
490
+
491
+ def resolve_suite(suite: str | None) -> Path:
492
+ suite = suite or "code-review"
493
+ direct = Path(suite)
494
+ if direct.exists():
495
+ return direct
496
+ candidate = Path("evals") / "suites" / suite
497
+ if candidate.exists():
498
+ return candidate
499
+ raise EvalctlError("E_SUITE_NOT_FOUND", f"suite not found: {suite}", "try: evalctl init && evalctl validate code-review --json", 1)
500
+
501
+
502
+ def read_json(path: Path) -> Any:
503
+ try:
504
+ return json.loads(path.read_text())
505
+ except FileNotFoundError:
506
+ raise EvalctlError("E_SUITE_NOT_FOUND", f"missing file: {path}", f"create {path} or run evalctl init", 1)
507
+ except json.JSONDecodeError as exc:
508
+ raise EvalctlError("E_SCHEMA_VIOLATION", f"invalid JSON in {path}: {exc.msg}", f"fix {path}:{exc.lineno}", 1)
509
+
510
+
511
+ def load_cases(cases_path: Path) -> list[dict[str, Any]]:
512
+ cases: list[dict[str, Any]] = []
513
+ seen: set[str] = set()
514
+ try:
515
+ lines = cases_path.read_text().splitlines()
516
+ except FileNotFoundError:
517
+ raise EvalctlError("E_SUITE_NOT_FOUND", f"missing file: {cases_path}", f"create {cases_path}", 1)
518
+ for line_no, line in enumerate(lines, 1):
519
+ if not line.strip():
520
+ continue
521
+ try:
522
+ case = json.loads(line)
523
+ except json.JSONDecodeError as exc:
524
+ raise EvalctlError("E_CASE_INVALID", f"invalid JSONL at {cases_path}:{line_no}: {exc.msg}", f"fix line {line_no}", 1)
525
+ case_id = case.get("id") or sha256_text(stable_json(case))[7:19]
526
+ if case_id in seen:
527
+ raise EvalctlError("E_CASE_INVALID", f"duplicate case id: {case_id}", "choose unique case ids", 1)
528
+ seen.add(case_id)
529
+ case["id"] = case_id
530
+ cases.append(case)
531
+ return cases
532
+
533
+
534
+ def validate_suite(suite_dir: Path) -> dict[str, Any]:
535
+ suite = read_json(suite_dir / "suite.json")
536
+ runner = suite.get("runner") or {}
537
+ shell = bool(runner.get("shell", False))
538
+ has_argv = isinstance(runner.get("argv"), list) and bool(runner.get("argv"))
539
+ has_cmd = isinstance(runner.get("command"), str) and bool(runner.get("command"))
540
+ if shell and not has_cmd or not shell and not has_argv:
541
+ raise EvalctlError("E_SCHEMA_VIOLATION", "runner must define argv for shell:false or command for shell:true", f"fix {suite_dir/'suite.json'} runner", 1)
542
+ if has_argv and has_cmd:
543
+ raise EvalctlError("E_SCHEMA_VIOLATION", "runner must not define both argv and command", f"fix {suite_dir/'suite.json'} runner", 1)
544
+ cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
545
+ for case in cases:
546
+ for key in ("task", "workspace"):
547
+ if key not in case:
548
+ raise EvalctlError("E_CASE_INVALID", f"case {case.get('id')} missing {key}", f"add {key} to case", 1)
549
+ workspace = suite_dir / case["workspace"]
550
+ if not workspace.exists():
551
+ raise EvalctlError("E_CASE_INVALID", f"case {case['id']} workspace missing: {case['workspace']}", "fix workspace path", 1)
552
+ if case.get("diff") and not (suite_dir / case["diff"]).exists():
553
+ raise EvalctlError("E_CASE_INVALID", f"case {case['id']} diff missing: {case['diff']}", "fix diff path", 1)
554
+ return {"suite": suite.get("name", suite_dir.name), "case_count": len(cases), "valid": True}
555
+
556
+
557
+ def init_project(force: bool = False) -> dict[str, Any]:
558
+ root = Path("evals")
559
+ if root.exists() and not force:
560
+ raise EvalctlError("E_RUN_CONFLICT", "evals/ already exists; refusing to overwrite", "try: evalctl validate code-review --json, or evalctl init --force to replace sample files", 5)
561
+ suite = root / "suites" / "code-review"
562
+ if force and suite.exists():
563
+ shutil.rmtree(suite)
564
+ (suite / "fixtures" / "cr-pass").mkdir(parents=True, exist_ok=True)
565
+ (suite / "fixtures" / "cr-fail").mkdir(parents=True, exist_ok=True)
566
+ (root / "runs").mkdir(parents=True, exist_ok=True)
567
+ suite_json = {
568
+ "name": "code-review",
569
+ "cases": "cases.jsonl",
570
+ "acknowledged_unsandboxed_runner": True,
571
+ "runner": {
572
+ "argv": ["python3", "$EVALCTL_WORKSPACE/runner.py"],
573
+ "shell": False,
574
+ "command": None,
575
+ "cwd": None,
576
+ "timeout_seconds": 30,
577
+ "max_output_bytes": 1048576,
578
+ "env_allowlist": ["PATH", "HOME"],
579
+ "redact_env_values": [],
580
+ "redact_patterns": ["(?i)secret[=:]\\s*\\S+", "(?i)token[=:]\\s*\\S+"],
581
+ },
582
+ "scorers": [
583
+ {"name": "contains", "required": True},
584
+ {"name": "exit-code", "required": True},
585
+ {"name": "file-exists", "required": True},
586
+ {"name": "workspace-diff", "required": True},
587
+ ],
588
+ }
589
+ cases = [
590
+ {"id": "cr-pass", "task": "Review this diff and report the null dereference.", "workspace": "fixtures/cr-pass", "diff": "fixtures/cr-pass/change.diff", "expect": {"text_contains": ["null dereference", "src/app.py:7"], "files_exist": ["review.md"], "files_changed": ["review.md"], "files_forbidden": ["src/secrets.py"], "exit_code": 0}},
591
+ {"id": "cr-fail", "task": "Review this diff and avoid touching secrets.", "workspace": "fixtures/cr-fail", "diff": "fixtures/cr-fail/change.diff", "expect": {"text_contains": ["bounds check"], "files_exist": ["review.md"], "files_changed": ["review.md"], "files_forbidden": ["src/secrets.py"], "exit_code": 0}},
592
+ ]
593
+ (suite / "suite.json").write_text(json.dumps(suite_json, indent=2, sort_keys=True) + "\n")
594
+ (suite / "cases.jsonl").write_text("\n".join(stable_json(c) for c in cases) + "\n")
595
+ for case_name, body, diff, runner in [
596
+ ("cr-pass", "value = payload.get('name')\nprint(value.upper())\n", "--- a/src/app.py\n+++ b/src/app.py\n@@ -4,4 +4,4 @@\n-print(value)\n+print(value.upper())\n", "from pathlib import Path\nimport os\nPath(os.environ['EVALCTL_OUTPUT_FILE']).write_text('Found null dereference at src/app.py:7\\n')\nPath('review.md').write_text('Found null dereference at src/app.py:7\\n')\n"),
597
+ ("cr-fail", "items = []\nprint(items[3])\n", "--- a/src/app.py\n+++ b/src/app.py\n@@ -1,2 +1,2 @@\n-print(items[0])\n+print(items[3])\n", "from pathlib import Path\nimport os\nPath(os.environ['EVALCTL_OUTPUT_FILE']).write_text('Looks fine\\n')\nPath('src').mkdir(exist_ok=True)\nPath('src/secrets.py').write_text('token=redacted\\n')\n"),
598
+ ]:
599
+ case_dir = suite / "fixtures" / case_name
600
+ (case_dir / "src").mkdir(exist_ok=True)
601
+ (case_dir / "src" / "app.py").write_text(body)
602
+ (case_dir / "change.diff").write_text(diff)
603
+ (case_dir / "runner.py").write_text(runner)
604
+ return {"created": str(root), "suite": "code-review", "files": ["evals/suites/code-review/suite.json", "evals/suites/code-review/cases.jsonl"]}
605
+
606
+
607
+ def normalize_rel(path: Path, root: Path) -> str:
608
+ rel = path.relative_to(root)
609
+ parts = rel.parts
610
+ posix = PurePosixPath(*parts).as_posix()
611
+ if posix in ("", ".") or ".." in parts:
612
+ raise ValueError("invalid relative path")
613
+ posix.encode("utf-8", "strict")
614
+ return posix
615
+
616
+
617
+ def display_path_name(path: Path) -> str:
618
+ return os.fsencode(path.name).decode("utf-8", "replace")
619
+
620
+
621
+ def manifest(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
622
+ warnings: list[dict[str, Any]] = []
623
+ entries: list[dict[str, Any]] = []
624
+ for path in sorted(root.rglob("*"), key=os.fsencode):
625
+ try:
626
+ rel = normalize_rel(path, root)
627
+ st = path.lstat()
628
+ if stat.S_ISDIR(st.st_mode):
629
+ entries.append({"path": rel, "kind": "directory"})
630
+ elif stat.S_ISLNK(st.st_mode):
631
+ target = os.readlink(path)
632
+ entries.append({"path": rel, "kind": "symlink", "target": target, "sha256": sha256_bytes(target.encode("utf-8")), "broken": not path.exists()})
633
+ elif stat.S_ISREG(st.st_mode):
634
+ entries.append({"path": rel, "kind": "file", "sha256": sha256_bytes(path.read_bytes()), "size": st.st_size})
635
+ else:
636
+ subtype = "fifo" if stat.S_ISFIFO(st.st_mode) else "socket" if stat.S_ISSOCK(st.st_mode) else "device" if stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode) else "unknown"
637
+ entries.append({"path": rel, "kind": "other", "subtype": subtype})
638
+ except Exception as exc:
639
+ warnings.append({"code": "W_PATH_UNREADABLE", "message": f"could not read path {display_path_name(path)}: {exc}"})
640
+ return {"root": ".", "entries": entries}, warnings
641
+
642
+
643
+ def diff_manifests(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
644
+ b = {e["path"]: e for e in before["entries"]}
645
+ a = {e["path"]: e for e in after["entries"]}
646
+ changed = []
647
+ for path in sorted(set(b) | set(a)):
648
+ if path not in b:
649
+ changed.append({"path": path, "status": "added", "kind": a[path]["kind"], "sha256_before": None, "sha256_after": a[path].get("sha256")})
650
+ elif path not in a:
651
+ changed.append({"path": path, "status": "deleted", "kind": b[path]["kind"], "sha256_before": b[path].get("sha256"), "sha256_after": None})
652
+ elif b[path].get("sha256") != a[path].get("sha256") or b[path].get("kind") != a[path].get("kind"):
653
+ changed.append({"path": path, "status": "modified", "kind": a[path]["kind"], "sha256_before": b[path].get("sha256"), "sha256_after": a[path].get("sha256")})
654
+ return {"changed_paths": changed}
655
+
656
+
657
+ def apply_redaction(text: str, patterns: list[str], values: list[str]) -> tuple[str, bool]:
658
+ changed = False
659
+ for value in values:
660
+ if value and value in text:
661
+ text = text.replace(value, "[REDACTED]")
662
+ changed = True
663
+ for pattern in patterns:
664
+ text2 = re.sub(pattern, "[REDACTED]", text)
665
+ changed = changed or text2 != text
666
+ text = text2
667
+ return text, changed
668
+
669
+
670
+ def _atomic_write(path: Path, text: str, *, _writer: Any | None = None) -> None:
671
+ writer = _writer or (lambda tmp_path, value: tmp_path.write_text(value))
672
+ tmp_name = None
673
+ with tempfile.NamedTemporaryFile("w", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False) as tmp:
674
+ tmp_name = tmp.name
675
+ tmp_path = Path(tmp_name)
676
+ try:
677
+ writer(tmp_path, text)
678
+ os.replace(tmp_path, path)
679
+ finally:
680
+ if tmp_path.exists():
681
+ tmp_path.unlink()
682
+
683
+
684
+ def write_json(path: Path, data: Any) -> None:
685
+ _atomic_write(path, json.dumps(data, indent=2, sort_keys=True) + "\n")
686
+
687
+
688
+ def parse_jobs(argv: list[str]) -> int:
689
+ raw = value_after(argv, "--jobs")
690
+ if raw is None:
691
+ return min(os.cpu_count() or 1, 4)
692
+ try:
693
+ jobs = int(raw)
694
+ except ValueError:
695
+ raise EvalctlError("E_CASE_INVALID", f"--jobs must be a positive integer (got {raw})", "try: evalctl run code-review --jobs 4 --json", 1)
696
+ if jobs < 1:
697
+ raise EvalctlError("E_CASE_INVALID", f"--jobs must be at least 1 (got {jobs})", "try: evalctl run code-review --jobs 1 --json", 1)
698
+ return jobs
699
+
700
+
701
+ def parse_positive_int_flag(argv: list[str], flag: str, default: int) -> int:
702
+ raw = value_after(argv, flag)
703
+ if raw is None:
704
+ return default
705
+ try:
706
+ value = int(raw)
707
+ except ValueError:
708
+ raise EvalctlError("E_CASE_INVALID", f"{flag} must be a positive integer (got {raw})", f"provide {flag} as a positive integer", 1)
709
+ if value < 1:
710
+ raise EvalctlError("E_CASE_INVALID", f"{flag} must be at least 1 (got {value})", f"provide {flag} as a positive integer", 1)
711
+ return value
712
+
713
+
714
+ def version_tuple(value: str) -> tuple[int, ...]:
715
+ parts = []
716
+ for item in value.split("."):
717
+ match = re.match(r"(\d+)", item)
718
+ parts.append(int(match.group(1)) if match else 0)
719
+ return tuple(parts)
720
+
721
+
722
+ def spoolctl_binary() -> str:
723
+ path = shutil.which("spoolctl")
724
+ if not path:
725
+ raise EvalctlError("E_SPOOLCTL_UNAVAILABLE", "spoolctl is not available on PATH", "install spoolctl >= 0.4.1 or drop --queue spoolctl", 3)
726
+ return path
727
+
728
+
729
+ def run_spoolctl_json(args: list[str], *, allow_exit_codes: set[int] | None = None) -> tuple[int, Any]:
730
+ allow_exit_codes = allow_exit_codes or {0}
731
+ try:
732
+ result = subprocess.run([spoolctl_binary(), *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
733
+ except OSError as exc:
734
+ raise EvalctlError("E_SPOOLCTL_UNAVAILABLE", f"could not run spoolctl: {exc}", "install spoolctl >= 0.4.1 or drop --queue spoolctl", 3)
735
+ if result.returncode == 4:
736
+ raise EvalctlError("E_JOB_TRANSIENT", "spoolctl reported a transient job-system failure", "retry the queued run or resume it later", 4)
737
+ if result.returncode not in allow_exit_codes:
738
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", f"spoolctl command failed: {result.stderr.strip() or result.stdout.strip()}", "upgrade spoolctl to >= 0.4.1 or drop --queue spoolctl", 3)
739
+ try:
740
+ payload = json.loads(result.stdout)
741
+ except json.JSONDecodeError as exc:
742
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", f"spoolctl returned invalid JSON: {exc.msg}", "upgrade spoolctl to >= 0.4.1 or drop --queue spoolctl", 3)
743
+ return result.returncode, payload
744
+
745
+
746
+ def spoolctl_json(args: list[str], *, allow_exit_codes: set[int] | None = None) -> dict[str, Any]:
747
+ returncode, payload = run_spoolctl_json(args, allow_exit_codes=allow_exit_codes)
748
+ if isinstance(payload, dict) and "ok" in payload:
749
+ if not payload.get("ok") and returncode != 6:
750
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl returned an error envelope", "inspect spoolctl output or drop --queue spoolctl", 3)
751
+ data = payload.get("data")
752
+ return data if isinstance(data, dict) else {"value": data}
753
+ if isinstance(payload, dict):
754
+ return payload
755
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl JSON output must be an object", "upgrade spoolctl to >= 0.4.1 or drop --queue spoolctl", 3)
756
+
757
+
758
+ def spoolctl_flag_names(flags: Any) -> set[str]:
759
+ names: set[str] = set()
760
+ if not isinstance(flags, list):
761
+ return names
762
+ for item in flags:
763
+ if isinstance(item, str):
764
+ names.add(item)
765
+ elif isinstance(item, dict) and isinstance(item.get("flag"), str):
766
+ names.add(item["flag"])
767
+ return names
768
+
769
+
770
+ def probe_spoolctl() -> dict[str, Any]:
771
+ _, payload = run_spoolctl_json(["capabilities", "--json"])
772
+ if not isinstance(payload, dict):
773
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl capabilities output must be a JSON object", "upgrade spoolctl to >= 0.4.1", 3)
774
+ is_envelope = "ok" in payload
775
+ if is_envelope:
776
+ if not payload.get("ok"):
777
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl capabilities returned an error envelope", "inspect spoolctl output or upgrade spoolctl", 3)
778
+ data = payload.get("data")
779
+ if not isinstance(data, dict):
780
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl capabilities data must be an object", "upgrade spoolctl to >= 0.4.1", 3)
781
+ else:
782
+ data = payload
783
+ envelope_version = str(payload.get("tool_version") or "") if is_envelope else ""
784
+ version = str(envelope_version or data.get("tool_version") or data.get("version") or "")
785
+ contract = str(data.get("contract_version") or "")
786
+ verbs = data.get("verbs", {})
787
+ add_flags = set()
788
+ if isinstance(verbs, dict):
789
+ add_info = verbs.get("add", {})
790
+ if isinstance(add_info, dict):
791
+ add_flags = spoolctl_flag_names(add_info.get("flags", []))
792
+ if version_tuple(version) < (0, 4, 1) or contract != "1" or not {"--cwd", "--env", "--max-crashes"} <= add_flags:
793
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl is missing required evalctl queue capabilities", "upgrade spoolctl to >= 0.4.1", 3)
794
+ return {**data, "version": version} if envelope_version else data
795
+
796
+
797
+ def render_runner_arg(arg: str, env: dict[str, str]) -> str:
798
+ for key, value in env.items():
799
+ arg = arg.replace(f"${key}", value)
800
+ return arg
801
+
802
+
803
+ def is_safe_id(value: str) -> bool:
804
+ return bool(value) and value not in {".", ".."} and ".." not in value and bool(SAFE_ID_RE.fullmatch(value))
805
+
806
+
807
+ def validate_suite_name(name: str) -> None:
808
+ if not is_safe_id(name) or "/" in name or "\\" in name:
809
+ raise EvalctlError("E_CASE_INVALID", f"invalid suite name: {name}", "use a simple name with letters, numbers, dot, underscore, or dash", 1)
810
+
811
+
812
+ def normalize_suite_rel(raw: str, *, field: str) -> str:
813
+ if not raw:
814
+ raise EvalctlError("E_CASE_INVALID", f"{field} must not be empty", f"provide {field}", 1)
815
+ if "\\" in raw:
816
+ raise EvalctlError("E_CASE_INVALID", f"{field} must use POSIX-style relative paths", "use forward slashes and stay under the suite directory", 1)
817
+ path = PurePosixPath(raw)
818
+ if path.is_absolute() or any(part == ".." for part in path.parts):
819
+ raise EvalctlError("E_CASE_INVALID", f"{field} must stay under the suite directory", "use a relative path without ..", 1)
820
+ normalized = path.as_posix()
821
+ if normalized in {"", "."}:
822
+ raise EvalctlError("E_CASE_INVALID", f"{field} must name a path", f"provide {field}", 1)
823
+ return normalized
824
+
825
+
826
+ def decode_subprocess_output(value: str | bytes | None) -> str:
827
+ if value is None:
828
+ return ""
829
+ if isinstance(value, str):
830
+ return value
831
+ return value.decode("utf-8", "replace")
832
+
833
+
834
+ def score_summary(score: dict[str, Any]) -> dict[str, Any]:
835
+ out = {"scorer": score["scorer"], "ok": score["ok"], "score": score["score"]}
836
+ if "id" in score:
837
+ out["id"] = score["id"]
838
+ return out
839
+
840
+
841
+ def case_manifest_entry(case: dict[str, Any], status: str, scores: list[dict[str, Any]]) -> dict[str, Any]:
842
+ return {
843
+ "id": case["id"],
844
+ "input_hash": sha256_text(stable_json(case)),
845
+ "status": status,
846
+ "scores": [score_summary(s) for s in scores],
847
+ "artifacts": {
848
+ "input": f"cases/{case['id']}/input.json",
849
+ "output": f"cases/{case['id']}/output.txt",
850
+ "runner": f"cases/{case['id']}/runner.json",
851
+ "workspace_before": f"cases/{case['id']}/workspace-before.json",
852
+ "workspace_after": f"cases/{case['id']}/workspace-after.json",
853
+ "diff_manifest": f"cases/{case['id']}/workspace-diff.json",
854
+ "diff": f"cases/{case['id']}/workspace.diff",
855
+ "score": f"cases/{case['id']}/score.json",
856
+ },
857
+ }
858
+
859
+
860
+ TERMINAL_CASE_STATUSES = {"pass", "fail", "error", "canceled"}
861
+
862
+
863
+ def write_terminal_marker(case_dir: Path, case_id: str, status: str) -> None:
864
+ write_json(case_dir / "state.json", {"id": case_id, "status": status, "completed_ts": now_iso()})
865
+
866
+
867
+ def is_terminal_marker(path: Path) -> bool:
868
+ try:
869
+ marker = read_json(path)
870
+ except Exception:
871
+ return False
872
+ return marker.get("status") in TERMINAL_CASE_STATUSES
873
+
874
+
875
+ def terminal_marker_count(run_dir: Path) -> int:
876
+ cases_dir = run_dir / "cases"
877
+ if not cases_dir.exists():
878
+ return 0
879
+ return sum(1 for marker in cases_dir.glob("*/state.json") if is_terminal_marker(marker))
880
+
881
+
882
+ def case_entry_from_artifacts(run_dir: Path, case_id: str) -> dict[str, Any]:
883
+ case_dir = run_dir / "cases" / case_id
884
+ case = read_json(case_dir / "input.json")
885
+ score_doc = read_json(case_dir / "score.json")
886
+ return case_manifest_entry(case, score_doc["status"], score_doc["scores"])
887
+
888
+
889
+ def reservation_path(run_dir: Path) -> Path:
890
+ return run_dir / ".reservation.json"
891
+
892
+
893
+ def parse_iso_timestamp(value: str) -> datetime:
894
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
895
+
896
+
897
+ def read_reservation(run_dir: Path) -> dict[str, Any] | None:
898
+ path = reservation_path(run_dir)
899
+ if not path.exists():
900
+ return None
901
+ try:
902
+ data = read_json(path)
903
+ except Exception:
904
+ return None
905
+ return data if isinstance(data, dict) else None
906
+
907
+
908
+ def reservation_is_live(record: dict[str, Any], *, now: datetime | None = None) -> bool:
909
+ try:
910
+ ttl_seconds = int(record["ttl_seconds"])
911
+ heartbeat_ts = parse_iso_timestamp(str(record["heartbeat_ts"]))
912
+ except Exception:
913
+ return False
914
+ now = now or datetime.now(timezone.utc)
915
+ return (now - heartbeat_ts).total_seconds() < ttl_seconds
916
+
917
+
918
+ def write_reservation(run_dir: Path, run_id: str, ttl_seconds: int) -> None:
919
+ write_json(reservation_path(run_dir), {
920
+ "run_id": run_id,
921
+ "pid": os.getpid(),
922
+ "host": socket.gethostname(),
923
+ "started_ts": now_iso(),
924
+ "heartbeat_ts": now_iso(),
925
+ "ttl_seconds": ttl_seconds,
926
+ })
927
+
928
+
929
+ def clear_reservation(run_dir: Path) -> None:
930
+ try:
931
+ reservation_path(run_dir).unlink()
932
+ except FileNotFoundError:
933
+ pass
934
+
935
+
936
+ class ReservationHeartbeat:
937
+ def __init__(self, run_dir: Path, run_id: str, ttl_seconds: int) -> None:
938
+ self.run_dir = run_dir
939
+ self.run_id = run_id
940
+ self.ttl_seconds = ttl_seconds
941
+ self.stop = threading.Event()
942
+ self.thread: threading.Thread | None = None
943
+
944
+ def __enter__(self) -> "ReservationHeartbeat":
945
+ write_reservation(self.run_dir, self.run_id, self.ttl_seconds)
946
+ interval = max(0.1, min(5.0, self.ttl_seconds / 4))
947
+ self.thread = threading.Thread(target=self._run, args=(interval,), daemon=True)
948
+ self.thread.start()
949
+ return self
950
+
951
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
952
+ self.stop.set()
953
+ if self.thread:
954
+ self.thread.join(timeout=1)
955
+
956
+ def _run(self, interval: float) -> None:
957
+ while not self.stop.wait(interval):
958
+ try:
959
+ record = read_reservation(self.run_dir) or {}
960
+ record.update({
961
+ "run_id": self.run_id,
962
+ "pid": os.getpid(),
963
+ "host": socket.gethostname(),
964
+ "heartbeat_ts": now_iso(),
965
+ "ttl_seconds": self.ttl_seconds,
966
+ })
967
+ record.setdefault("started_ts", now_iso())
968
+ write_json(reservation_path(self.run_dir), record)
969
+ except Exception:
970
+ pass
971
+
972
+
973
+ def split_completed_and_pending(run_dir: Path, cases: list[dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]:
974
+ completed: dict[str, dict[str, Any]] = {}
975
+ pending: list[dict[str, Any]] = []
976
+ for case in sorted(cases, key=lambda c: c["id"]):
977
+ marker_path = run_dir / "cases" / case["id"] / "state.json"
978
+ if is_terminal_marker(marker_path):
979
+ try:
980
+ completed[case["id"]] = case_entry_from_artifacts(run_dir, case["id"])
981
+ continue
982
+ except Exception:
983
+ pass
984
+ pending.append(case)
985
+ return completed, pending
986
+
987
+
988
+ def clean_pending_case_dirs(run_dir: Path, pending_cases: list[dict[str, Any]]) -> None:
989
+ for case in pending_cases:
990
+ shutil.rmtree(run_dir / "cases" / case["id"], ignore_errors=True)
991
+
992
+
993
+ def synthesize_case_error(suite_dir: Path, suite: dict[str, Any], case: dict[str, Any], run_dir: Path, exc: BaseException) -> tuple[dict[str, Any], list[dict[str, Any]]]:
994
+ warnings: list[dict[str, Any]] = []
995
+ case_dir = run_dir / "cases" / case["id"]
996
+ case_dir.mkdir(parents=True, exist_ok=True)
997
+ workspace = case_dir / "workspace"
998
+ if not workspace.exists():
999
+ shutil.copytree(suite_dir / case["workspace"], workspace)
1000
+ input_json = case_dir / "input.json"
1001
+ task_txt = case_dir / "task.txt"
1002
+ output_file = case_dir / "output.txt"
1003
+ write_json(input_json, case)
1004
+ task_txt.write_text(case["task"])
1005
+ if case.get("diff"):
1006
+ diff_src = suite_dir / case["diff"]
1007
+ diff_file = case_dir / "input.diff"
1008
+ if not diff_file.exists():
1009
+ shutil.copyfile(diff_src, diff_file)
1010
+ before, mw = manifest(workspace)
1011
+ warnings.extend(mw)
1012
+ after, mw = manifest(workspace)
1013
+ warnings.extend(mw)
1014
+ diff = diff_manifests(before, after)
1015
+ output_file.write_text("")
1016
+ (case_dir / "runner.stdout.txt").write_text("")
1017
+ (case_dir / "runner.stderr.txt").write_text(str(exc))
1018
+ runner_json = {"exit_code": None, "signal": None, "timed_out": False, "spawn_failed": True, "error_code": "E_RUNNER_FAILED", "duration_ms": 0,
1019
+ "stdout_truncated": False, "stderr_truncated": False, "output_truncated": False,
1020
+ "stdout_redacted": False, "stderr_redacted": False, "output_redacted": False}
1021
+ write_json(case_dir / "runner.json", runner_json)
1022
+ write_json(case_dir / "workspace-before.json", before)
1023
+ write_json(case_dir / "workspace-after.json", after)
1024
+ write_json(case_dir / "workspace-diff.json", diff)
1025
+ (case_dir / "workspace.diff").write_text(render_text_diff(diff))
1026
+ scores = score_case(case, "", runner_json, after, diff, suite.get("scorers", []), case_dir=case_dir, execute=False, suite=suite)
1027
+ score_doc = {"case_id": case["id"], "status": "error", "ok": False, "scores": scores}
1028
+ write_json(case_dir / "score.json", score_doc)
1029
+ write_terminal_marker(case_dir, case["id"], "error")
1030
+ return case_manifest_entry(case, "error", scores), warnings
1031
+
1032
+
1033
+ def prepare_case_workspace(suite_dir: Path, suite: dict[str, Any], case: dict[str, Any], run_dir: Path,
1034
+ timeout_override: int | None) -> dict[str, Any]:
1035
+ warnings: list[dict[str, Any]] = []
1036
+ case_dir = run_dir / "cases" / case["id"]
1037
+ case_dir.mkdir(parents=True, exist_ok=True)
1038
+ workspace = case_dir / "workspace"
1039
+ shutil.copytree(suite_dir / case["workspace"], workspace)
1040
+ input_json = case_dir / "input.json"
1041
+ task_txt = case_dir / "task.txt"
1042
+ output_file = case_dir / "output.txt"
1043
+ write_json(input_json, case)
1044
+ task_txt.write_text(case["task"])
1045
+ diff_src = suite_dir / case["diff"] if case.get("diff") else None
1046
+ diff_file = case_dir / "input.diff"
1047
+ if diff_src:
1048
+ shutil.copyfile(diff_src, diff_file)
1049
+ before, mw = manifest(workspace)
1050
+ warnings.extend(mw)
1051
+ write_json(case_dir / "workspace-before.json", before)
1052
+ runner = suite["runner"]
1053
+ timeout = int(timeout_override or runner.get("timeout_seconds") or 300)
1054
+ max_bytes = int(runner.get("max_output_bytes") or 5 * 1024 * 1024)
1055
+ env = {k: os.environ[k] for k in runner.get("env_allowlist", []) if k in os.environ}
1056
+ eval_env = {
1057
+ "EVALCTL_CASE_FILE": str(input_json.resolve()),
1058
+ "EVALCTL_WORKSPACE": str(workspace.resolve()),
1059
+ "EVALCTL_OUTPUT_FILE": str(output_file.resolve()),
1060
+ "EVALCTL_TASK_FILE": str(task_txt.resolve()),
1061
+ "EVALCTL_DIFF_FILE": str(diff_file.resolve() if diff_src else ""),
1062
+ }
1063
+ env.update(eval_env)
1064
+ env_values = [os.environ.get(k, "") for k in runner.get("redact_env_values", [])]
1065
+ patterns = runner.get("redact_patterns", [])
1066
+ cwd = Path(render_runner_arg(runner.get("cwd") or str(workspace), eval_env))
1067
+ return {
1068
+ "suite": suite,
1069
+ "case": case,
1070
+ "case_dir": case_dir,
1071
+ "workspace": workspace,
1072
+ "output_file": output_file,
1073
+ "task_txt": task_txt,
1074
+ "diff_file": diff_file,
1075
+ "diff_src": diff_src,
1076
+ "before": before,
1077
+ "runner": runner,
1078
+ "timeout": timeout,
1079
+ "max_bytes": max_bytes,
1080
+ "env": env,
1081
+ "eval_env": eval_env,
1082
+ "env_values": env_values,
1083
+ "patterns": patterns,
1084
+ "cwd": cwd,
1085
+ "warnings": warnings,
1086
+ }
1087
+
1088
+
1089
+ def execute_runner_in_process(prepared: dict[str, Any]) -> dict[str, Any]:
1090
+ runner = prepared["runner"]
1091
+ case = prepared["case"]
1092
+ eval_env = prepared["eval_env"]
1093
+ started = time.time()
1094
+ timed_out = False
1095
+ spawn_failed = False
1096
+ exit_code: int | None = None
1097
+ signal_value: int | None = None
1098
+ stdout = ""
1099
+ stderr = ""
1100
+ proc: subprocess.Popen[str] | None = None
1101
+ try:
1102
+ stdin = subprocess.PIPE if runner.get("stdin") == "task" else None
1103
+ input_text = case["task"] if runner.get("stdin") == "task" else None
1104
+ if runner.get("shell", False):
1105
+ cmd: str | list[str] = render_runner_arg(runner["command"], eval_env)
1106
+ proc = subprocess.Popen(cmd, shell=True, cwd=prepared["cwd"], env=prepared["env"], text=True, stdin=stdin,
1107
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
1108
+ else:
1109
+ argv = [render_runner_arg(str(a), eval_env) for a in runner["argv"]]
1110
+ proc = subprocess.Popen(argv, shell=False, cwd=prepared["cwd"], env=prepared["env"], text=True, stdin=stdin,
1111
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
1112
+ stdout, stderr = proc.communicate(input=input_text, timeout=prepared["timeout"])
1113
+ exit_code = proc.returncode
1114
+ if proc.returncode < 0:
1115
+ signal_value = -proc.returncode
1116
+ except subprocess.TimeoutExpired as exc:
1117
+ timed_out = True
1118
+ exit_code = None
1119
+ if proc is not None:
1120
+ try:
1121
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
1122
+ except ProcessLookupError:
1123
+ pass
1124
+ drained_stdout, drained_stderr = proc.communicate()
1125
+ else:
1126
+ drained_stdout, drained_stderr = "", ""
1127
+ stdout = decode_subprocess_output(exc.stdout) + decode_subprocess_output(drained_stdout)
1128
+ stderr = decode_subprocess_output(exc.stderr) + decode_subprocess_output(drained_stderr)
1129
+ except OSError as exc:
1130
+ spawn_failed = True
1131
+ exit_code = None
1132
+ stderr = str(exc)
1133
+ duration_ms = int((time.time() - started) * 1000)
1134
+ return {
1135
+ "stdout": stdout,
1136
+ "stderr": stderr,
1137
+ "timed_out": timed_out,
1138
+ "spawn_failed": spawn_failed,
1139
+ "exit_code": exit_code,
1140
+ "signal": signal_value,
1141
+ "duration_ms": duration_ms,
1142
+ }
1143
+
1144
+
1145
+ def normalize_runner_artifacts(prepared: dict[str, Any], runner_result: dict[str, Any]) -> tuple[str, dict[str, Any], list[dict[str, Any]]]:
1146
+ warnings: list[dict[str, Any]] = []
1147
+ case_dir = prepared["case_dir"]
1148
+ output_file = prepared["output_file"]
1149
+ max_bytes = prepared["max_bytes"]
1150
+ stdout = runner_result["stdout"]
1151
+ stderr = runner_result["stderr"]
1152
+ trunc_stdout = len(stdout.encode()) > max_bytes
1153
+ trunc_stderr = len(stderr.encode()) > max_bytes
1154
+ stdout = stdout.encode()[:max_bytes].decode("utf-8", "replace")
1155
+ stderr = stderr.encode()[:max_bytes].decode("utf-8", "replace")
1156
+ stdout, red_stdout = apply_redaction(stdout, prepared["patterns"], prepared["env_values"])
1157
+ stderr, red_stderr = apply_redaction(stderr, prepared["patterns"], prepared["env_values"])
1158
+ output_truncated = False
1159
+ if output_file.exists():
1160
+ output_bytes = output_file.read_bytes()
1161
+ output_truncated = len(output_bytes) > max_bytes
1162
+ output_text = output_bytes[:max_bytes].decode("utf-8", "replace")
1163
+ else:
1164
+ output_text = stdout
1165
+ output_text, red_output = apply_redaction(output_text, prepared["patterns"], prepared["env_values"])
1166
+ output_file.write_text(output_text)
1167
+ (case_dir / "runner.stdout.txt").write_text(stdout)
1168
+ (case_dir / "runner.stderr.txt").write_text(stderr)
1169
+ error_code = "E_RUNNER_TIMEOUT" if runner_result["timed_out"] else "E_RUNNER_FAILED" if runner_result["spawn_failed"] else None
1170
+ runner_json = {"exit_code": runner_result["exit_code"], "signal": runner_result["signal"], "timed_out": runner_result["timed_out"], "spawn_failed": runner_result["spawn_failed"], "error_code": error_code, "duration_ms": runner_result["duration_ms"],
1171
+ "stdout_truncated": trunc_stdout, "stderr_truncated": trunc_stderr, "output_truncated": output_truncated,
1172
+ "stdout_redacted": red_stdout, "stderr_redacted": red_stderr, "output_redacted": red_output}
1173
+ if trunc_stdout or trunc_stderr or output_truncated:
1174
+ warnings.append({"code": "W_OUTPUT_TRUNCATED", "message": "runner output exceeded max_output_bytes"})
1175
+ write_json(case_dir / "runner.json", runner_json)
1176
+ return output_text, runner_json, warnings
1177
+
1178
+
1179
+ def capture_workspace_after_and_score(prepared: dict[str, Any], output_text: str, runner_json: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
1180
+ warnings: list[dict[str, Any]] = []
1181
+ case = prepared["case"]
1182
+ suite = prepared["suite"]
1183
+ case_dir = prepared["case_dir"]
1184
+ after, mw = manifest(prepared["workspace"])
1185
+ warnings.extend(mw)
1186
+ diff = diff_manifests(prepared["before"], after)
1187
+ write_json(case_dir / "workspace-after.json", after)
1188
+ write_json(case_dir / "workspace-diff.json", diff)
1189
+ (case_dir / "workspace.diff").write_text(render_text_diff(diff))
1190
+ status = "error" if runner_json["timed_out"] or runner_json["spawn_failed"] else "pass"
1191
+ scores = score_case(case, output_text, runner_json, after, diff, suite.get("scorers", []), case_dir=case_dir, execute=True, suite=suite, eval_env=prepared["eval_env"])
1192
+ if any(s.get("error") for s in scores):
1193
+ status = "error"
1194
+ elif status != "error" and not all(s["ok"] for s in scores if s.get("required", True)):
1195
+ status = "fail"
1196
+ score_doc = {"case_id": case["id"], "status": status, "ok": status == "pass", "scores": scores}
1197
+ write_json(case_dir / "score.json", score_doc)
1198
+ return case_manifest_entry(case, status, scores), warnings
1199
+
1200
+
1201
+ def spoolctl_runner_command(prepared: dict[str, Any]) -> list[str]:
1202
+ runner = prepared["runner"]
1203
+ eval_env = prepared["eval_env"]
1204
+ if runner.get("shell", False):
1205
+ command = render_runner_arg(runner["command"], eval_env)
1206
+ if runner.get("stdin") == "task":
1207
+ command = f"exec {command} < \"$EVALCTL_TASK_FILE\""
1208
+ return ["sh", "-c", command]
1209
+ argv = [render_runner_arg(str(a), eval_env) for a in runner["argv"]]
1210
+ if runner.get("stdin") != "task":
1211
+ return argv
1212
+ wrapper = (
1213
+ "import os, subprocess, sys\n"
1214
+ "with open(os.environ['EVALCTL_TASK_FILE']) as stdin:\n"
1215
+ " raise SystemExit(subprocess.call(sys.argv[1:], stdin=stdin))\n"
1216
+ )
1217
+ return [sys.executable, "-c", wrapper, *argv]
1218
+
1219
+
1220
+ def spoolctl_add_case(db_path: Path, run_id: str, prepared: dict[str, Any]) -> str:
1221
+ case_id = prepared["case"]["id"]
1222
+ args = [
1223
+ "add", "--db", str(db_path), "--json", "--queue", "evalctl",
1224
+ "--key", f"{run_id}:{case_id}",
1225
+ "--tag", f"evalctl_run={run_id}",
1226
+ "--tag", f"evalctl_case={case_id}",
1227
+ "--cwd", str(prepared["cwd"]),
1228
+ "--timeout", str(prepared["timeout"]),
1229
+ "--max-retries", "0",
1230
+ "--max-crashes", "0",
1231
+ ]
1232
+ for key, value in sorted(prepared["eval_env"].items()):
1233
+ args.extend(["--env", f"{key}={value}"])
1234
+ args.append("--")
1235
+ args.extend(spoolctl_runner_command(prepared))
1236
+ data = spoolctl_json(args)
1237
+ job_id = str(data.get("job_id") or data.get("id") or "")
1238
+ if not job_id:
1239
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl add did not return data.job_id", "upgrade spoolctl to >= 0.4.1", 3)
1240
+ write_json(prepared["case_dir"] / "job.json", {"job_id": job_id, "state": data.get("state", "queued")})
1241
+ return job_id
1242
+
1243
+
1244
+ def latest_terminal_attempt(job_detail: dict[str, Any]) -> dict[str, Any]:
1245
+ attempts = job_detail.get("attempts")
1246
+ if not isinstance(attempts, list) or not attempts:
1247
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl show did not include attempts", "upgrade spoolctl to >= 0.4.1", 3)
1248
+ return attempts[-1]
1249
+
1250
+
1251
+ def runner_result_from_spoolctl_attempt(attempt: dict[str, Any], max_bytes: int) -> dict[str, Any]:
1252
+ stdout_path = attempt.get("stdout_path")
1253
+ stderr_path = attempt.get("stderr_path")
1254
+ stdout = Path(stdout_path).read_text(errors="replace") if stdout_path else ""
1255
+ stderr = Path(stderr_path).read_text(errors="replace") if stderr_path else str(attempt.get("error") or "")
1256
+ state = attempt.get("state")
1257
+ exit_code = attempt.get("exit_code")
1258
+ timed_out = state == "timed_out" and exit_code is None
1259
+ spawn_failed = exit_code is None and (state in {"failed", "abandoned", "canceled"} or str(attempt.get("error") or "").startswith("spawn failed:"))
1260
+ return {
1261
+ "stdout": stdout,
1262
+ "stderr": stderr,
1263
+ "timed_out": timed_out,
1264
+ "spawn_failed": spawn_failed,
1265
+ "exit_code": exit_code,
1266
+ "signal": None,
1267
+ "duration_ms": int(attempt.get("duration_ms") or 0),
1268
+ }
1269
+
1270
+
1271
+ def execute_spoolctl_pending_cases(suite_dir: Path, suite: dict[str, Any], all_cases: list[dict[str, Any]], pending_cases: list[dict[str, Any]],
1272
+ completed_entries: dict[str, dict[str, Any]], run_dir: Path, run_id: str,
1273
+ jobs: int, timeout_override: int | None, slots: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
1274
+ db_path = run_dir / ".spoolctl.db"
1275
+ clean_pending_case_dirs(run_dir, pending_cases)
1276
+ prepared_by_case: dict[str, dict[str, Any]] = {}
1277
+ job_ids: list[str] = []
1278
+ for case in sorted(pending_cases, key=lambda c: c["id"]):
1279
+ prepared = prepare_case_workspace(suite_dir, suite, case, run_dir, timeout_override)
1280
+ prepared_by_case[case["id"]] = prepared
1281
+ job_ids.append(spoolctl_add_case(db_path, run_id, prepared))
1282
+ worker = subprocess.Popen([spoolctl_binary(), "work", "--db", str(db_path), "--queue", "evalctl", "--slots", str(slots), "--drain"],
1283
+ text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1284
+ wait_data = spoolctl_json(["wait", "--db", str(db_path), "--json", *job_ids], allow_exit_codes={0, 6})
1285
+ worker_stdout, worker_stderr = worker.communicate(timeout=60)
1286
+ if worker.returncode == 4:
1287
+ raise EvalctlError("E_JOB_TRANSIENT", "spoolctl worker reported a transient failure", "retry the queued run or resume it later", 4)
1288
+ if worker.returncode not in {0, None}:
1289
+ raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", f"spoolctl worker failed: {worker_stderr or worker_stdout}", "inspect spoolctl worker output", 3)
1290
+ warnings: list[dict[str, Any]] = []
1291
+ entries_by_id = dict(completed_entries)
1292
+ for case in sorted(pending_cases, key=lambda c: c["id"]):
1293
+ prepared = prepared_by_case[case["id"]]
1294
+ job_doc = read_json(prepared["case_dir"] / "job.json")
1295
+ detail = spoolctl_json(["show", "--db", str(db_path), "--json", job_doc["job_id"]])
1296
+ attempt = latest_terminal_attempt(detail)
1297
+ runner_result = runner_result_from_spoolctl_attempt(attempt, prepared["max_bytes"])
1298
+ output_text, runner_json, normalize_warnings = normalize_runner_artifacts(prepared, runner_result)
1299
+ warnings.extend(prepared["warnings"])
1300
+ warnings.extend(normalize_warnings)
1301
+ entry, score_warnings = capture_workspace_after_and_score(prepared, output_text, runner_json)
1302
+ warnings.extend(score_warnings)
1303
+ write_terminal_marker(prepared["case_dir"], case["id"], entry["status"])
1304
+ write_json(prepared["case_dir"] / "job.json", {"job_id": job_doc["job_id"], "state": detail.get("state") or attempt.get("state")})
1305
+ entries_by_id[case["id"]] = entry
1306
+ case_entries = [entries_by_id[case["id"]] for case in sorted(all_cases, key=lambda c: c["id"])]
1307
+ return case_entries, warnings
1308
+
1309
+
1310
+ def run_case(suite_dir: Path, suite: dict[str, Any], case: dict[str, Any], run_dir: Path, timeout_override: int | None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
1311
+ prepared = prepare_case_workspace(suite_dir, suite, case, run_dir, timeout_override)
1312
+ all_warnings = list(prepared["warnings"])
1313
+ runner_result = execute_runner_in_process(prepared)
1314
+ output_text, runner_json, warnings = normalize_runner_artifacts(prepared, runner_result)
1315
+ all_warnings.extend(warnings)
1316
+ entry, warnings = capture_workspace_after_and_score(prepared, output_text, runner_json)
1317
+ all_warnings.extend(warnings)
1318
+ write_terminal_marker(prepared["case_dir"], case["id"], entry["status"])
1319
+ return entry, all_warnings
1320
+
1321
+
1322
+ def render_text_diff(diff: dict[str, Any]) -> str:
1323
+ return "\n".join(f"{p['status']}\t{p['path']}" for p in diff["changed_paths"]) + ("\n" if diff["changed_paths"] else "")
1324
+
1325
+
1326
+ def score_case(case: dict[str, Any], output_text: str, runner_json: dict[str, Any], after: dict[str, Any], diff: dict[str, Any],
1327
+ scorers: list[dict[str, Any]], *, case_dir: Path | None = None, execute: bool = False,
1328
+ suite: dict[str, Any] | None = None, eval_env: dict[str, str] | None = None) -> list[dict[str, Any]]:
1329
+ expect = case.get("expect", {})
1330
+ configured = scorers or [{"name": n, "required": True} for n in BUILTIN_SCORERS]
1331
+ out: list[dict[str, Any]] = []
1332
+ for scorer in configured:
1333
+ name = scorer["name"]
1334
+ required = bool(scorer.get("required", True))
1335
+ try:
1336
+ result = run_scorer(scorer, expect, output_text, runner_json, after, diff, required=required,
1337
+ case_dir=case_dir, execute=execute, suite=suite or {}, eval_env=eval_env or {})
1338
+ if result is None:
1339
+ continue
1340
+ result["required"] = required
1341
+ out.append(result)
1342
+ except Exception as exc:
1343
+ result = {"scorer": name, "ok": False, "score": 0.0, "label": "error", "required": required, "findings": [{"why": str(exc)}], "error": True}
1344
+ if scorer.get("id"):
1345
+ result["id"] = scorer["id"]
1346
+ if name == "command":
1347
+ result["error_code"] = "E_SCORER_CASE_FAILED"
1348
+ out.append(result)
1349
+ return out
1350
+
1351
+
1352
+ def scorer_failure(scorer: dict[str, Any], required: bool, why: str) -> dict[str, Any]:
1353
+ result = {
1354
+ "scorer": scorer.get("name", "command"),
1355
+ "ok": False,
1356
+ "score": 0.0,
1357
+ "label": "error",
1358
+ "required": required,
1359
+ "findings": [{"why": why}],
1360
+ "error": True,
1361
+ "error_code": "E_SCORER_CASE_FAILED",
1362
+ }
1363
+ if scorer.get("id"):
1364
+ result["id"] = scorer["id"]
1365
+ return result
1366
+
1367
+
1368
+ def normalize_command_verdict(raw: Any, scorer: dict[str, Any], required: bool) -> dict[str, Any]:
1369
+ if not isinstance(raw, dict):
1370
+ return scorer_failure(scorer, required, "command scorer stdout must be one JSON object")
1371
+ if not isinstance(raw.get("ok"), bool):
1372
+ return scorer_failure(scorer, required, "command scorer verdict requires boolean ok")
1373
+ score = raw.get("score")
1374
+ if not isinstance(score, (int, float)) or isinstance(score, bool) or not math.isfinite(float(score)) or not 0.0 <= float(score) <= 1.0:
1375
+ return scorer_failure(scorer, required, "command scorer verdict requires finite numeric score in 0.0..1.0")
1376
+ label = raw.get("label")
1377
+ if label is None or label == "":
1378
+ label = "pass" if raw["ok"] else "fail"
1379
+ if not isinstance(label, str):
1380
+ return scorer_failure(scorer, required, "command scorer verdict label must be a string")
1381
+ findings_raw = raw.get("findings")
1382
+ if not isinstance(findings_raw, list):
1383
+ return scorer_failure(scorer, required, "command scorer verdict requires findings list")
1384
+ findings: list[dict[str, Any]] = []
1385
+ for item in findings_raw:
1386
+ if isinstance(item, str):
1387
+ findings.append({"why": item})
1388
+ elif isinstance(item, dict):
1389
+ findings.append(item)
1390
+ else:
1391
+ return scorer_failure(scorer, required, "command scorer findings must be objects or strings")
1392
+ result = {
1393
+ "scorer": "command",
1394
+ "id": scorer["id"],
1395
+ "ok": raw["ok"],
1396
+ "score": float(score),
1397
+ "label": label,
1398
+ "required": required,
1399
+ "findings": findings,
1400
+ }
1401
+ if bool(raw.get("error")):
1402
+ result.update({"ok": False, "score": 0.0, "label": "error", "error": True, "error_code": "E_SCORER_CASE_FAILED"})
1403
+ return result
1404
+
1405
+
1406
+ def run_command_scorer(scorer: dict[str, Any], required: bool, case_dir: Path | None, execute: bool,
1407
+ suite: dict[str, Any], eval_env: dict[str, str]) -> dict[str, Any]:
1408
+ scorer_id = str(scorer.get("id", ""))
1409
+ if not is_safe_id(scorer_id):
1410
+ return scorer_failure(scorer, required, "command scorer id must be path-safe")
1411
+ if case_dir is None:
1412
+ return scorer_failure(scorer, required, "command scorer requires case_dir")
1413
+ scorers_dir = case_dir / "scorers"
1414
+ verdict_path = scorers_dir / f"{scorer_id}.json"
1415
+ if not execute:
1416
+ verdict = read_json(verdict_path)
1417
+ if not isinstance(verdict, dict):
1418
+ return scorer_failure(scorer, required, "command scorer verdict artifact must be an object")
1419
+ return verdict
1420
+ scorers_dir.mkdir(parents=True, exist_ok=True)
1421
+ timeout = int(scorer.get("timeout_seconds") or DEFAULT_COMMAND_SCORER_TIMEOUT_SECONDS)
1422
+ runner = suite.get("runner", {})
1423
+ max_bytes = int(scorer.get("max_output_bytes") or runner.get("max_output_bytes") or 5 * 1024 * 1024)
1424
+ env = {k: os.environ[k] for k in runner.get("env_allowlist", []) if k in os.environ}
1425
+ env.update(eval_env)
1426
+ env_values = [os.environ.get(k, "") for k in runner.get("redact_env_values", [])]
1427
+ patterns = runner.get("redact_patterns", [])
1428
+ stdout = ""
1429
+ stderr = ""
1430
+ proc: subprocess.Popen[str] | None = None
1431
+ try:
1432
+ if scorer.get("shell", False):
1433
+ command = scorer.get("command")
1434
+ if not isinstance(command, str) or not command:
1435
+ result = scorer_failure(scorer, required, "command scorer shell mode requires command")
1436
+ write_json(verdict_path, result)
1437
+ return result
1438
+ cmd: str | list[str] = render_runner_arg(command, eval_env)
1439
+ proc = subprocess.Popen(cmd, shell=True, cwd=case_dir / "workspace", env=env, text=True,
1440
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
1441
+ else:
1442
+ argv_raw = scorer.get("argv")
1443
+ if not isinstance(argv_raw, list) or not argv_raw:
1444
+ result = scorer_failure(scorer, required, "command scorer requires argv when shell:false")
1445
+ write_json(verdict_path, result)
1446
+ return result
1447
+ cmd = [render_runner_arg(str(a), eval_env) for a in argv_raw]
1448
+ proc = subprocess.Popen(cmd, shell=False, cwd=case_dir / "workspace", env=env, text=True,
1449
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
1450
+ stdout, stderr = proc.communicate(timeout=timeout)
1451
+ if proc.returncode != 0:
1452
+ result = scorer_failure(scorer, required, f"command scorer exited {proc.returncode}")
1453
+ else:
1454
+ try:
1455
+ raw = json.loads(stdout.strip())
1456
+ except json.JSONDecodeError as exc:
1457
+ result = scorer_failure(scorer, required, f"invalid command scorer JSON: {exc.msg}")
1458
+ else:
1459
+ result = normalize_command_verdict(raw, scorer, required)
1460
+ except subprocess.TimeoutExpired as exc:
1461
+ if proc is not None:
1462
+ try:
1463
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
1464
+ except ProcessLookupError:
1465
+ pass
1466
+ drained_stdout, drained_stderr = proc.communicate()
1467
+ stdout = decode_subprocess_output(exc.stdout) + decode_subprocess_output(drained_stdout)
1468
+ stderr = decode_subprocess_output(exc.stderr) + decode_subprocess_output(drained_stderr)
1469
+ result = scorer_failure(scorer, required, f"command scorer timed out after {timeout}s")
1470
+ except OSError as exc:
1471
+ result = scorer_failure(scorer, required, f"command scorer spawn failed: {exc}")
1472
+ stdout = stdout.encode()[:max_bytes].decode("utf-8", "replace")
1473
+ stderr = stderr.encode()[:max_bytes].decode("utf-8", "replace")
1474
+ stdout, _ = apply_redaction(stdout, patterns, env_values)
1475
+ stderr, _ = apply_redaction(stderr, patterns, env_values)
1476
+ stdout = stdout.encode()[:max_bytes].decode("utf-8", "replace")
1477
+ stderr = stderr.encode()[:max_bytes].decode("utf-8", "replace")
1478
+ (scorers_dir / f"{scorer_id}.stdout.txt").write_text(stdout)
1479
+ (scorers_dir / f"{scorer_id}.stderr.txt").write_text(stderr)
1480
+ write_json(verdict_path, result)
1481
+ return result
1482
+
1483
+
1484
+ def run_scorer(scorer: dict[str, Any], expect: dict[str, Any], output_text: str, runner_json: dict[str, Any],
1485
+ after: dict[str, Any], diff: dict[str, Any], *, required: bool, case_dir: Path | None,
1486
+ execute: bool, suite: dict[str, Any], eval_env: dict[str, str]) -> dict[str, Any] | None:
1487
+ name = scorer["name"]
1488
+ findings: list[dict[str, Any]] = []
1489
+ if name == "command":
1490
+ return run_command_scorer(scorer, required, case_dir, execute, suite, eval_env)
1491
+ elif name == "exact":
1492
+ if "exact" not in expect:
1493
+ return None
1494
+ ok = output_text == expect["exact"]
1495
+ elif name == "contains":
1496
+ values = expect.get("text_contains", [])
1497
+ if not values:
1498
+ return None
1499
+ missing = [v for v in values if v not in output_text]
1500
+ findings = [{"why": "missing text", "value": v} for v in missing]
1501
+ ok = not missing
1502
+ elif name == "regex":
1503
+ values = expect.get("text_regex", [])
1504
+ if not values:
1505
+ return None
1506
+ missing = [v for v in values if not re.search(v, output_text, re.MULTILINE)]
1507
+ findings = [{"why": "regex did not match", "value": v} for v in missing]
1508
+ ok = not missing
1509
+ elif name == "json-schema":
1510
+ if "json_schema" not in expect:
1511
+ return None
1512
+ try:
1513
+ json.loads(output_text)
1514
+ ok = True
1515
+ except json.JSONDecodeError as exc:
1516
+ ok = False
1517
+ findings = [{"why": f"output is not JSON: {exc.msg}"}]
1518
+ elif name == "numeric-threshold":
1519
+ threshold = expect.get("numeric_threshold")
1520
+ if not threshold:
1521
+ return None
1522
+ data = json.loads(output_text)
1523
+ value = data
1524
+ for part in threshold["path"].split("."):
1525
+ value = value[part]
1526
+ if "gte" in threshold:
1527
+ ok = value >= threshold["gte"]
1528
+ elif "lte" in threshold:
1529
+ ok = value <= threshold["lte"]
1530
+ else:
1531
+ ok = True
1532
+ elif name == "file-exists":
1533
+ values = expect.get("files_exist", [])
1534
+ if not values:
1535
+ return None
1536
+ paths = {e["path"] for e in after["entries"]}
1537
+ missing = [v for v in values if v not in paths]
1538
+ findings = [{"path": v, "why": "expected file missing"} for v in missing]
1539
+ ok = not missing
1540
+ elif name == "exit-code":
1541
+ if "exit_code" not in expect:
1542
+ return None
1543
+ ok = runner_json.get("exit_code") == expect["exit_code"]
1544
+ if not ok:
1545
+ findings = [{"why": "exit code mismatch", "expected": expect["exit_code"], "actual": runner_json.get("exit_code")}]
1546
+ elif name == "workspace-diff":
1547
+ changed = [p["path"] for p in diff["changed_paths"]]
1548
+ missing_changed = [p for p in expect.get("files_changed", []) if not any(fnmatch.fnmatch(c, p) for c in changed)]
1549
+ forbidden = [p for p in expect.get("files_forbidden", []) for c in changed if fnmatch.fnmatch(c, p)]
1550
+ findings = [{"path": p, "why": "expected change missing"} for p in missing_changed] + [{"path": p, "why": "forbidden path modified"} for p in forbidden]
1551
+ ok = not findings
1552
+ else:
1553
+ raise ValueError(f"unknown scorer {name}")
1554
+ return {"scorer": name, "ok": ok, "score": 1.0 if ok else 0.0, "label": "pass" if ok else "fail", "findings": findings}
1555
+
1556
+
1557
+ def command_init(argv: list[str], json_mode: bool, started: float) -> int:
1558
+ data = init_project(force=has_flag(argv, "--force"))
1559
+ return print_envelope(data, json_mode=json_mode, human=f"Created {data['created']} with suite {data['suite']}", started=started)
1560
+
1561
+
1562
+ def command_validate(argv: list[str], json_mode: bool, started: float) -> int:
1563
+ args = strip_flags(argv, set(), {"--json", "--no-color"})
1564
+ suite_arg = args[1] if len(args) > 1 else "code-review"
1565
+ data = validate_suite(resolve_suite(suite_arg))
1566
+ return print_envelope(data, json_mode=json_mode, human=f"{data['suite']}: {data['case_count']} cases valid", started=started)
1567
+
1568
+
1569
+ def runner_from_authoring_flags(argv: list[str], *, prefix: str = "--runner") -> dict[str, Any]:
1570
+ argv_value = value_after(argv, f"{prefix}-argv")
1571
+ command_value = value_after(argv, f"{prefix}-command")
1572
+ shell = has_flag(argv, "--shell")
1573
+ if bool(argv_value) == bool(command_value):
1574
+ raise EvalctlError("E_CASE_INVALID", f"{prefix}-argv and {prefix}-command are mutually exclusive", f"try: {TOOL} suite add demo {prefix}-argv \"python3 $EVALCTL_WORKSPACE/r.py\"", 1)
1575
+ if argv_value and shell:
1576
+ raise EvalctlError("E_CASE_INVALID", "--shell requires --runner-command, not --runner-argv", "drop --shell or use --runner-command", 1)
1577
+ if command_value and not shell:
1578
+ raise EvalctlError("E_CASE_INVALID", "--runner-command requires --shell", "use --runner-argv for shell:false runners", 1)
1579
+ runner = {
1580
+ "argv": shlex.split(argv_value) if argv_value else None,
1581
+ "shell": shell,
1582
+ "command": command_value if command_value else None,
1583
+ "cwd": None,
1584
+ "timeout_seconds": 30,
1585
+ "max_output_bytes": 1048576,
1586
+ "env_allowlist": ["PATH", "HOME"],
1587
+ "redact_env_values": [],
1588
+ "redact_patterns": [],
1589
+ }
1590
+ if argv_value and not runner["argv"]:
1591
+ raise EvalctlError("E_CASE_INVALID", "--runner-argv must not be empty", "provide at least one argv token", 1)
1592
+ return runner
1593
+
1594
+
1595
+ def suite_add_data(name: str, runner: dict[str, Any], *, _validator: Any = validate_suite) -> dict[str, Any]:
1596
+ validate_suite_name(name)
1597
+ root = Path("evals") / "suites"
1598
+ dest = root / name
1599
+ suite_json = {
1600
+ "name": name,
1601
+ "cases": "cases.jsonl",
1602
+ "acknowledged_unsandboxed_runner": True,
1603
+ "runner": runner,
1604
+ "scorers": [],
1605
+ }
1606
+ suite_text = json.dumps(suite_json, indent=2, sort_keys=True) + "\n"
1607
+ if dest.exists():
1608
+ existing_suite = dest / "suite.json"
1609
+ if existing_suite.exists() and existing_suite.read_text() == suite_text:
1610
+ return {"suite": name, "suite_dir": str(dest), "created": False, "files": [str(dest / "suite.json"), str(dest / "cases.jsonl")]}
1611
+ raise EvalctlError("E_RUN_CONFLICT", f"suite already exists with different content: {name}", "choose a new suite name or edit the existing suite explicitly", 5)
1612
+ root.mkdir(parents=True, exist_ok=True)
1613
+ tmp_path = Path(tempfile.mkdtemp(prefix=f".{name}.", dir=root))
1614
+ try:
1615
+ (tmp_path / "fixtures").mkdir()
1616
+ (tmp_path / "suite.json").write_text(suite_text)
1617
+ (tmp_path / "cases.jsonl").write_text("")
1618
+ _validator(tmp_path)
1619
+ os.replace(tmp_path, dest)
1620
+ except Exception:
1621
+ shutil.rmtree(tmp_path, ignore_errors=True)
1622
+ raise
1623
+ return {"suite": name, "suite_dir": str(dest), "created": True, "files": [str(dest / "suite.json"), str(dest / "cases.jsonl"), str(dest / "fixtures")]}
1624
+
1625
+
1626
+ def command_suite_add(argv: list[str], json_mode: bool, started: float) -> int:
1627
+ args = strip_flags(argv, {"--runner-argv", "--runner-command"}, {"--json", "--no-color", "--shell"})
1628
+ if len(args) != 3 or args[1] != "add":
1629
+ raise EvalctlError("E_CASE_INVALID", "suite command requires: suite add <name>", "try: evalctl suite add demo --runner-argv \"python3 $EVALCTL_WORKSPACE/r.py\" --json", 1)
1630
+ data = suite_add_data(args[2], runner_from_authoring_flags(argv))
1631
+ return print_envelope(data, json_mode=json_mode, human=f"{'Created' if data['created'] else 'Exists'} suite {data['suite']}", started=started)
1632
+
1633
+
1634
+ def case_add_data(suite_name: str, task: str, workspace_raw: str, *, case_id: str | None = None,
1635
+ diff_raw: str | None = None, expect_raw: str | None = None) -> dict[str, Any]:
1636
+ suite_dir = resolve_suite(suite_name)
1637
+ suite = read_json(suite_dir / "suite.json")
1638
+ workspace = normalize_suite_rel(workspace_raw, field="--workspace")
1639
+ if not (suite_dir / workspace).exists():
1640
+ raise EvalctlError("E_CASE_INVALID", f"workspace missing: {workspace}", "create the fixture before adding the case", 1)
1641
+ case: dict[str, Any] = {"task": task, "workspace": workspace}
1642
+ if diff_raw:
1643
+ diff = normalize_suite_rel(diff_raw, field="--diff")
1644
+ if not (suite_dir / diff).exists():
1645
+ raise EvalctlError("E_CASE_INVALID", f"diff missing: {diff}", "create the diff file before adding the case", 1)
1646
+ case["diff"] = diff
1647
+ if expect_raw:
1648
+ try:
1649
+ expect = json.loads(expect_raw)
1650
+ except json.JSONDecodeError as exc:
1651
+ raise EvalctlError("E_CASE_INVALID", f"--expect-json is invalid JSON: {exc.msg}", "provide a JSON object such as '{\"exact\":\"ok\"}'", 1)
1652
+ if not isinstance(expect, dict):
1653
+ raise EvalctlError("E_CASE_INVALID", "--expect-json must be a JSON object", "provide scorer expectations as an object", 1)
1654
+ case["expect"] = expect
1655
+ final_id = case_id or sha256_text(stable_json(case))[7:19]
1656
+ if not is_safe_id(final_id):
1657
+ raise EvalctlError("E_CASE_INVALID", f"invalid case id: {final_id}", "use a path-safe id", 1)
1658
+ case["id"] = final_id
1659
+ cases_path = suite_dir / suite.get("cases", "cases.jsonl")
1660
+ existing_cases = load_cases(cases_path)
1661
+ for existing in existing_cases:
1662
+ if existing["id"] == final_id:
1663
+ if stable_json(existing) == stable_json(case):
1664
+ return {"suite": suite.get("name", suite_dir.name), "id": final_id, "created": False, "case": case}
1665
+ raise EvalctlError("E_RUN_CONFLICT", f"case id already exists with different content: {final_id}", "choose a new --id or edit cases.jsonl explicitly", 5)
1666
+ old_text = cases_path.read_text()
1667
+ new_text = old_text
1668
+ if new_text and not new_text.endswith("\n"):
1669
+ new_text += "\n"
1670
+ new_text += stable_json(case) + "\n"
1671
+ _atomic_write(cases_path, new_text)
1672
+ try:
1673
+ validate_suite(suite_dir)
1674
+ except Exception:
1675
+ _atomic_write(cases_path, old_text)
1676
+ raise
1677
+ return {"suite": suite.get("name", suite_dir.name), "id": final_id, "created": True, "case": case}
1678
+
1679
+
1680
+ def command_case_add(argv: list[str], json_mode: bool, started: float) -> int:
1681
+ args = strip_flags(argv, {"--task", "--workspace", "--id", "--diff", "--expect-json"}, {"--json", "--no-color"})
1682
+ if len(args) != 3 or args[1] != "add":
1683
+ raise EvalctlError("E_CASE_INVALID", "case command requires: case add <suite>", "try: evalctl case add demo --task \"do X\" --workspace fixtures/x --json", 1)
1684
+ task = value_after(argv, "--task")
1685
+ workspace = value_after(argv, "--workspace")
1686
+ if not task:
1687
+ raise EvalctlError("E_CASE_INVALID", "case add requires --task", "provide --task text", 1)
1688
+ if not workspace:
1689
+ raise EvalctlError("E_CASE_INVALID", "case add requires --workspace", "provide --workspace fixtures/name", 1)
1690
+ data = case_add_data(args[2], task, workspace, case_id=value_after(argv, "--id"), diff_raw=value_after(argv, "--diff"), expect_raw=value_after(argv, "--expect-json"))
1691
+ return print_envelope(data, json_mode=json_mode, human=f"{'Added' if data['created'] else 'Exists'} case {data['id']}", started=started)
1692
+
1693
+
1694
+ def command_scorer_config(argv: list[str]) -> dict[str, Any]:
1695
+ name = value_after(argv, "--name")
1696
+ if not name:
1697
+ raise EvalctlError("E_CASE_INVALID", "scorer add requires --name", "provide --name exact or --name command", 1)
1698
+ if name not in set(BUILTIN_SCORERS) | {"command"}:
1699
+ raise EvalctlError("E_CASE_INVALID", f"unknown scorer name: {name}", f"known scorers: {', '.join(BUILTIN_SCORERS)}, command", 1)
1700
+ if has_flag(argv, "--required") and has_flag(argv, "--advisory"):
1701
+ raise EvalctlError("E_CASE_INVALID", "--required and --advisory are mutually exclusive", "choose one scorer requirement mode", 1)
1702
+ required = not has_flag(argv, "--advisory")
1703
+ config: dict[str, Any] = {"name": name, "required": required}
1704
+ if name != "command":
1705
+ if any(value_after(argv, flag) for flag in ("--argv", "--command", "--timeout")) or has_flag(argv, "--shell"):
1706
+ raise EvalctlError("E_CASE_INVALID", "built-in scorers do not accept command runner flags", "use --name command for external scorers", 1)
1707
+ if value_after(argv, "--id"):
1708
+ config["id"] = value_after(argv, "--id")
1709
+ return config
1710
+ scorer_id = value_after(argv, "--id")
1711
+ if not scorer_id or not is_safe_id(scorer_id):
1712
+ raise EvalctlError("E_CASE_INVALID", "command scorer requires a path-safe --id", "use letters, numbers, dot, underscore, or dash", 1)
1713
+ argv_value = value_after(argv, "--argv")
1714
+ command_value = value_after(argv, "--command")
1715
+ if bool(argv_value) == bool(command_value):
1716
+ raise EvalctlError("E_CASE_INVALID", "--argv and --command are mutually exclusive for command scorers", "provide exactly one command form", 1)
1717
+ shell = has_flag(argv, "--shell")
1718
+ if argv_value and shell:
1719
+ raise EvalctlError("E_CASE_INVALID", "--shell requires --command, not --argv", "drop --shell or use --command", 1)
1720
+ if command_value and not shell:
1721
+ raise EvalctlError("E_CASE_INVALID", "--command requires --shell", "use --argv for shell:false scorers", 1)
1722
+ config["id"] = scorer_id
1723
+ config["shell"] = shell
1724
+ if argv_value:
1725
+ parsed = shlex.split(argv_value)
1726
+ if not parsed:
1727
+ raise EvalctlError("E_CASE_INVALID", "--argv must not be empty", "provide at least one argv token", 1)
1728
+ config["argv"] = parsed
1729
+ else:
1730
+ config["command"] = command_value
1731
+ timeout = value_after(argv, "--timeout")
1732
+ if timeout is not None:
1733
+ try:
1734
+ parsed_timeout = int(timeout)
1735
+ except ValueError:
1736
+ raise EvalctlError("E_CASE_INVALID", f"--timeout must be an integer (got {timeout})", "provide seconds as a positive integer", 1)
1737
+ if parsed_timeout < 1:
1738
+ raise EvalctlError("E_CASE_INVALID", f"--timeout must be at least 1 (got {parsed_timeout})", "provide a positive timeout", 1)
1739
+ config["timeout_seconds"] = parsed_timeout
1740
+ return config
1741
+
1742
+
1743
+ def scorer_key(config: dict[str, Any]) -> tuple[str, str]:
1744
+ if config["name"] == "command":
1745
+ return ("command", str(config.get("id", "")))
1746
+ return ("builtin", config["name"])
1747
+
1748
+
1749
+ def scorer_add_data(suite_name: str, config: dict[str, Any]) -> dict[str, Any]:
1750
+ suite_dir = resolve_suite(suite_name)
1751
+ suite_path = suite_dir / "suite.json"
1752
+ suite = read_json(suite_path)
1753
+ old_text = suite_path.read_text()
1754
+ scorers = list(suite.get("scorers", []))
1755
+ new_key = scorer_key(config)
1756
+ for existing in scorers:
1757
+ if scorer_key(existing) == new_key:
1758
+ if stable_json(existing) == stable_json(config):
1759
+ return {"suite": suite.get("name", suite_dir.name), "scorer": config["name"], "id": config.get("id"), "created": False}
1760
+ raise EvalctlError("E_RUN_CONFLICT", f"scorer already exists with different config: {new_key[1]}", "choose a new id/name or edit suite.json explicitly", 5)
1761
+ suite["scorers"] = scorers + [config]
1762
+ write_json(suite_path, suite)
1763
+ try:
1764
+ validate_suite(suite_dir)
1765
+ except Exception:
1766
+ _atomic_write(suite_path, old_text)
1767
+ raise
1768
+ return {"suite": suite.get("name", suite_dir.name), "scorer": config["name"], "id": config.get("id"), "created": True}
1769
+
1770
+
1771
+ def command_scorer_add(argv: list[str], json_mode: bool, started: float) -> int:
1772
+ args = strip_flags(argv, {"--name", "--id", "--argv", "--command", "--timeout"}, {"--json", "--no-color", "--required", "--advisory", "--shell"})
1773
+ if len(args) != 3 or args[1] != "add":
1774
+ raise EvalctlError("E_CASE_INVALID", "scorer command requires: scorer add <suite>", "try: evalctl scorer add demo --name exact --required --json", 1)
1775
+ data = scorer_add_data(args[2], command_scorer_config(argv))
1776
+ label = data["id"] or data["scorer"]
1777
+ return print_envelope(data, json_mode=json_mode, human=f"{'Added' if data['created'] else 'Exists'} scorer {label}", started=started)
1778
+
1779
+
1780
+ def timeout_seconds_for_run(suite: dict[str, Any], timeout_override: int | None) -> int:
1781
+ return int(timeout_override or suite["runner"].get("timeout_seconds", 300))
1782
+
1783
+
1784
+ def build_run_metadata(suite: dict[str, Any], suite_dir: Path, cases: list[dict[str, Any]], run_id: str,
1785
+ jobs: int, timeout_override: int | None, replayed_from: str | None,
1786
+ queue: dict[str, Any] | None = None, mode: str = "synchronous") -> dict[str, Any]:
1787
+ return {
1788
+ "schema_version": 1,
1789
+ "run_id": run_id,
1790
+ "created_ts": now_iso(),
1791
+ "suite_identity": run_identity(suite, suite_dir, cases),
1792
+ "execution": {"mode": mode, "jobs": jobs, "timeout_seconds": timeout_seconds_for_run(suite, timeout_override)},
1793
+ "replayed_from": replayed_from,
1794
+ "queue": queue,
1795
+ }
1796
+
1797
+
1798
+ def write_run_metadata_once(run_dir: Path, metadata: dict[str, Any]) -> None:
1799
+ path = run_dir / "run.json"
1800
+ if path.exists():
1801
+ raise EvalctlError("E_RUN_BUSY", f"run metadata already exists for {metadata['run_id']}", "use evalctl run --resume to continue an incomplete run", 4)
1802
+ write_json(path, metadata)
1803
+
1804
+
1805
+ def manifest_from_run_metadata(metadata: dict[str, Any], case_entries: list[dict[str, Any]]) -> dict[str, Any]:
1806
+ identity = metadata["suite_identity"]
1807
+ manifest_doc: dict[str, Any] = {
1808
+ "schema_version": 1,
1809
+ "run_id": metadata["run_id"],
1810
+ "suite": {"name": identity["suite_name"], "hash": identity["suite_hash"], "case_count": len(identity["cases"])},
1811
+ "created_ts": metadata["created_ts"],
1812
+ "execution": metadata["execution"],
1813
+ "replayed_from": metadata.get("replayed_from"),
1814
+ "cases": case_entries,
1815
+ }
1816
+ if metadata.get("queue") is not None:
1817
+ manifest_doc["queue"] = metadata["queue"]
1818
+ return manifest_doc
1819
+
1820
+
1821
+ def read_run_metadata(run_dir: Path) -> dict[str, Any]:
1822
+ path = run_dir / "run.json"
1823
+ if not path.exists():
1824
+ raise EvalctlError("E_RUN_NOT_FOUND", f"run metadata not found: {path}", "resume requires a run created with durable metadata", 1)
1825
+ try:
1826
+ metadata = json.loads(path.read_text())
1827
+ if not isinstance(metadata, dict):
1828
+ raise ValueError("run.json must be an object")
1829
+ for key in ("schema_version", "run_id", "created_ts", "suite_identity", "execution"):
1830
+ if key not in metadata:
1831
+ raise ValueError(f"run.json missing {key}")
1832
+ identity = metadata["suite_identity"]
1833
+ if not isinstance(identity, dict) or not isinstance(identity.get("cases"), list):
1834
+ raise ValueError("run.json suite_identity is malformed")
1835
+ execution = metadata["execution"]
1836
+ if not isinstance(execution, dict) or not {"mode", "jobs", "timeout_seconds"} <= set(execution):
1837
+ raise ValueError("run.json execution is malformed")
1838
+ return metadata
1839
+ except EvalctlError:
1840
+ raise
1841
+ except Exception as exc:
1842
+ raise EvalctlError("E_RUN_CORRUPT", f"run metadata is corrupt for {run_dir}: {exc}", "inspect or remove run.json, then retry with a valid run", 1)
1843
+
1844
+
1845
+ def finalize_run(run_dir: Path, metadata: dict[str, Any], case_entries: list[dict[str, Any]]) -> tuple[dict[str, Any], bool]:
1846
+ manifest_doc = manifest_from_run_metadata(metadata, case_entries)
1847
+ write_json(run_dir / "manifest.json", manifest_doc)
1848
+ report = report_data(run_dir)
1849
+ (run_dir / "report.md").write_text(markdown_report(report))
1850
+ run_ok = all(c["status"] == "pass" for c in case_entries)
1851
+ data = {
1852
+ "run_id": metadata["run_id"],
1853
+ "run_dir": str(run_dir),
1854
+ "run": {"ok": run_ok, "case_count": len(case_entries), "status_counts": status_counts(case_entries)},
1855
+ "report_hash": report["report_hash"],
1856
+ }
1857
+ if metadata.get("replayed_from") is not None:
1858
+ data["replayed_from"] = metadata["replayed_from"]
1859
+ data["cases_replayed"] = len(case_entries)
1860
+ return data, run_ok
1861
+
1862
+
1863
+ def execute_pending_cases(suite_dir: Path, suite: dict[str, Any], all_cases: list[dict[str, Any]], pending_cases: list[dict[str, Any]],
1864
+ completed_entries: dict[str, dict[str, Any]], run_dir: Path, jobs: int,
1865
+ timeout_override: int | None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
1866
+ all_warnings: list[dict[str, Any]] = []
1867
+ case_results: dict[str, tuple[dict[str, Any], list[dict[str, Any]]]] = {}
1868
+ clean_pending_case_dirs(run_dir, pending_cases)
1869
+ with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor:
1870
+ future_to_case = {executor.submit(run_case, suite_dir, suite, case, run_dir, timeout_override): case for case in sorted(pending_cases, key=lambda c: c["id"])}
1871
+ for future in concurrent.futures.as_completed(future_to_case):
1872
+ case = future_to_case[future]
1873
+ try:
1874
+ entry, warnings = future.result()
1875
+ except Exception as exc:
1876
+ try:
1877
+ entry, warnings = synthesize_case_error(suite_dir, suite, case, run_dir, exc)
1878
+ except Exception as synth_exc:
1879
+ if not (run_dir / "manifest.json").exists() and terminal_marker_count(run_dir) == 0:
1880
+ shutil.rmtree(run_dir, ignore_errors=True)
1881
+ raise EvalctlError("E_SCORER_FAILED", f"case {case['id']} failed before replayable artifacts could be written: {synth_exc}", "retry evalctl run with a fresh --run-id", 3)
1882
+ case_results[case["id"]] = (entry, warnings)
1883
+ case_entries = []
1884
+ for case in sorted(all_cases, key=lambda c: c["id"]):
1885
+ if case["id"] in completed_entries:
1886
+ entry = completed_entries[case["id"]]
1887
+ warnings: list[dict[str, Any]] = []
1888
+ else:
1889
+ entry, warnings = case_results[case["id"]]
1890
+ all_warnings.extend(warnings)
1891
+ case_entries.append(entry)
1892
+ return case_entries, all_warnings
1893
+
1894
+
1895
+ def execute_cases(suite_dir: Path, suite: dict[str, Any], cases: list[dict[str, Any]], run_dir: Path, run_id: str,
1896
+ jobs: int, timeout_override: int | None, replayed_from: str | None,
1897
+ reservation_ttl: int = DEFAULT_RESERVATION_TTL_SECONDS,
1898
+ queue_backend: str | None = None, slots: int | None = None) -> tuple[dict[str, Any], list[dict[str, Any]], bool]:
1899
+ run_dir.mkdir(parents=True)
1900
+ shutil.copytree(suite_dir, run_dir / "suite-snapshot")
1901
+ queue = {"backend": "spoolctl", "db": ".spoolctl.db", "jobs": {}} if queue_backend == "spoolctl" else None
1902
+ metadata = build_run_metadata(suite, suite_dir, cases, run_id, slots or jobs, timeout_override, replayed_from, queue=queue, mode="queued" if queue_backend == "spoolctl" else "synchronous")
1903
+ write_run_metadata_once(run_dir, metadata)
1904
+ all_warnings = [{"code": "W_UNSANDBOXED_RUNNER", "message": "runner commands execute arbitrary local code; evalctl is not a sandbox"}]
1905
+ with ReservationHeartbeat(run_dir, run_id, reservation_ttl):
1906
+ if queue_backend == "spoolctl":
1907
+ case_entries, run_warnings = execute_spoolctl_pending_cases(suite_dir, suite, cases, cases, {}, run_dir, run_id, jobs, timeout_override, slots or jobs)
1908
+ else:
1909
+ case_entries, run_warnings = execute_pending_cases(suite_dir, suite, cases, cases, {}, run_dir, jobs, timeout_override)
1910
+ all_warnings.extend(run_warnings)
1911
+ if any(c["status"] == "error" for c in case_entries):
1912
+ all_warnings.append({"code": "W_PARTIAL_RUN", "message": "some cases errored; report remains generable"})
1913
+ data, run_ok = finalize_run(run_dir, metadata, case_entries)
1914
+ clear_reservation(run_dir)
1915
+ return data, all_warnings, run_ok
1916
+
1917
+
1918
+ def command_run(argv: list[str], json_mode: bool, started: float) -> int:
1919
+ resume_id = value_after(argv, "--resume")
1920
+ if resume_id is not None:
1921
+ return command_run_resume(argv, resume_id, json_mode, started)
1922
+ args = strip_flags(argv, {"--jobs", "--timeout", "--run-id", "--reservation-ttl", "--queue", "--slots"}, {"--json", "--no-color", "--fail-on-fail"})
1923
+ if len(args) < 2:
1924
+ raise EvalctlError("E_SUITE_NOT_FOUND", "run requires a suite name", "try: evalctl run code-review --json", 1)
1925
+ queue_backend = value_after(argv, "--queue")
1926
+ slots_raw = value_after(argv, "--slots")
1927
+ if slots_raw is not None and queue_backend is None:
1928
+ raise EvalctlError("E_CASE_INVALID", "--slots requires --queue spoolctl", "try: evalctl run code-review --queue spoolctl --slots 4 --json", 1)
1929
+ if queue_backend is not None and queue_backend != "spoolctl":
1930
+ raise EvalctlError("E_CASE_INVALID", f"unsupported queue backend: {queue_backend}", "supported value: --queue spoolctl", 1)
1931
+ if queue_backend == "spoolctl":
1932
+ probe_spoolctl()
1933
+ suite_dir = resolve_suite(args[1])
1934
+ validate_suite(suite_dir)
1935
+ suite = read_json(suite_dir / "suite.json")
1936
+ cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
1937
+ jobs = parse_jobs(argv)
1938
+ slots = parse_positive_int_flag(argv, "--slots", jobs) if queue_backend == "spoolctl" else None
1939
+ reservation_ttl = parse_positive_int_flag(argv, "--reservation-ttl", DEFAULT_RESERVATION_TTL_SECONDS)
1940
+ unsandboxed_warning = {"code": "W_UNSANDBOXED_RUNNER", "message": "runner commands execute arbitrary local code; evalctl is not a sandbox"}
1941
+ if not suite.get("acknowledged_unsandboxed_runner") and sys.stderr.isatty():
1942
+ print(unsandboxed_warning["message"], file=sys.stderr)
1943
+ run_id = value_after(argv, "--run-id") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ-") + suite.get("name", suite_dir.name)
1944
+ run_dir = Path("evals") / "runs" / run_id
1945
+ if run_dir.exists():
1946
+ manifest_path = run_dir / "manifest.json"
1947
+ if manifest_path.exists():
1948
+ manifest_doc = read_json(manifest_path)
1949
+ if run_identity(suite, suite_dir, cases) != manifest_identity(manifest_doc):
1950
+ existing_count = manifest_doc["suite"]["case_count"]
1951
+ existing_suite = manifest_doc["suite"]["name"]
1952
+ raise EvalctlError("E_RUN_CONFLICT", f"run-id `{run_id}` already completed for suite `{existing_suite}`/{existing_count} cases; refusing to reuse for a different suite/case set", "use a fresh --run-id", 5)
1953
+ data = report_data(run_dir)
1954
+ run_summary = {"ok": data["run"]["ok"], "case_count": data["run"]["case_count"], "status_counts": data["run"]["status_counts"]}
1955
+ existing = {"run_id": run_id, "run_dir": str(run_dir), "existing": True, "run": run_summary, "report_hash": data["report_hash"]}
1956
+ return print_envelope(existing, json_mode=json_mode, warnings=[unsandboxed_warning], started=started)
1957
+ reservation = read_reservation(run_dir)
1958
+ if reservation and reservation_is_live(reservation):
1959
+ raise EvalctlError("E_RUN_BUSY", f"run reservation is live for {run_id}", "wait and retry evalctl run with a new --run-id", 4)
1960
+ raise EvalctlError("E_RUN_BUSY", f"run {run_id} is incomplete and may be resumable", f"retry with: evalctl run --resume {run_id} --json", 4)
1961
+ timeout_override = int(value_after(argv, "--timeout")) if value_after(argv, "--timeout") else None
1962
+ data, all_warnings, run_ok = execute_cases(suite_dir, suite, cases, run_dir, run_id, jobs, timeout_override, None, reservation_ttl, queue_backend, slots)
1963
+ commands = [{"command": f"evalctl report {run_id} --format json", "rationale": "regenerate deterministic JSON report"}]
1964
+ print_envelope(data, json_mode=json_mode, human=f"Run {run_id}: {'pass' if run_ok else 'fail'}", warnings=all_warnings, commands=commands, started=started)
1965
+ return 6 if has_flag(argv, "--fail-on-fail") and not run_ok else 0
1966
+
1967
+
1968
+ def command_run_resume(argv: list[str], run_id: str, json_mode: bool, started: float) -> int:
1969
+ if not is_safe_id(run_id) or "/" in run_id or "\\" in run_id:
1970
+ raise EvalctlError("E_CASE_INVALID", f"invalid run id: {run_id}", "use a simple run id with letters, numbers, dot, underscore, or dash", 1)
1971
+ run_dir = Path("evals") / "runs" / run_id
1972
+ if not run_dir.exists():
1973
+ raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {run_id}", "resume an existing incomplete run id", 1)
1974
+ nothing_pending_warning = {"code": "W_RESUME_NOTHING_PENDING", "message": "run has no pending cases to resume"}
1975
+ if (run_dir / "manifest.json").exists():
1976
+ data = report_data(run_dir)
1977
+ run_summary = {"ok": data["run"]["ok"], "case_count": data["run"]["case_count"], "status_counts": data["run"]["status_counts"]}
1978
+ existing = {"run_id": run_id, "run_dir": str(run_dir), "existing": True, "run": run_summary, "report_hash": data["report_hash"]}
1979
+ return print_envelope(existing, json_mode=json_mode, warnings=[nothing_pending_warning], started=started)
1980
+ suite_dir = run_dir / "suite-snapshot"
1981
+ if not suite_dir.exists():
1982
+ raise EvalctlError("E_RUN_NOT_FOUND", f"suite snapshot not found for {run_id}", "resume requires the original suite-snapshot directory", 1)
1983
+ metadata = read_run_metadata(run_dir)
1984
+ suite = read_json(suite_dir / "suite.json")
1985
+ cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
1986
+ if stable_json(metadata["suite_identity"]) != stable_json(run_identity(suite, suite_dir, cases)):
1987
+ raise EvalctlError("E_RUN_CONFLICT", f"run metadata does not match the suite snapshot for {run_id}", "inspect run.json and suite-snapshot before retrying", 5)
1988
+ reservation = read_reservation(run_dir)
1989
+ warnings: list[dict[str, Any]] = []
1990
+ if reservation and reservation_is_live(reservation):
1991
+ raise EvalctlError("E_RUN_BUSY", f"run reservation is live for {run_id}", "wait for the run to finish or retry after its reservation TTL", 4)
1992
+ if reservation is not None:
1993
+ warnings.append({"code": "W_RESERVATION_RECLAIMED", "message": "reclaimed stale run reservation"})
1994
+ completed_entries, pending_cases = split_completed_and_pending(run_dir, cases)
1995
+ reservation_ttl = parse_positive_int_flag(argv, "--reservation-ttl", DEFAULT_RESERVATION_TTL_SECONDS)
1996
+ jobs = int(metadata["execution"]["jobs"])
1997
+ timeout_override = int(metadata["execution"]["timeout_seconds"])
1998
+ queued = metadata["execution"].get("mode") == "queued" and metadata.get("queue", {}).get("backend") == "spoolctl"
1999
+ if queued:
2000
+ probe_spoolctl()
2001
+ with ReservationHeartbeat(run_dir, run_id, reservation_ttl):
2002
+ if pending_cases:
2003
+ if queued:
2004
+ case_entries, run_warnings = execute_spoolctl_pending_cases(suite_dir, suite, cases, pending_cases, completed_entries, run_dir, run_id, jobs, timeout_override, jobs)
2005
+ else:
2006
+ case_entries, run_warnings = execute_pending_cases(suite_dir, suite, cases, pending_cases, completed_entries, run_dir, jobs, timeout_override)
2007
+ warnings.extend(run_warnings)
2008
+ else:
2009
+ case_entries = [completed_entries[case["id"]] for case in sorted(cases, key=lambda c: c["id"])]
2010
+ warnings.append(nothing_pending_warning)
2011
+ if any(c["status"] == "error" for c in case_entries):
2012
+ warnings.append({"code": "W_PARTIAL_RUN", "message": "some cases errored; report remains generable"})
2013
+ data, run_ok = finalize_run(run_dir, metadata, case_entries)
2014
+ clear_reservation(run_dir)
2015
+ commands = [{"command": f"evalctl report {run_id} --format json", "rationale": "regenerate deterministic JSON report"}]
2016
+ print_envelope(data, json_mode=json_mode, human=f"Resume {run_id}: {'pass' if run_ok else 'fail'}", warnings=warnings, commands=commands, started=started)
2017
+ return 6 if has_flag(argv, "--fail-on-fail") and not run_ok else 0
2018
+
2019
+
2020
+ def runs_root() -> Path:
2021
+ return Path("evals") / "runs"
2022
+
2023
+
2024
+ def stored_queue_jobs(run_dir: Path) -> list[dict[str, Any]]:
2025
+ jobs: list[dict[str, Any]] = []
2026
+ for path in sorted((run_dir / "cases").glob("*/job.json")):
2027
+ try:
2028
+ data = read_json(path)
2029
+ except Exception:
2030
+ continue
2031
+ case_id = path.parent.name
2032
+ job_id = data.get("job_id") if isinstance(data, dict) else None
2033
+ if job_id is not None:
2034
+ jobs.append({"case_id": case_id, "job_id": job_id, "state": data.get("state"), "spoolctl_available": shutil.which("spoolctl") is not None})
2035
+ return jobs
2036
+
2037
+
2038
+ def run_case_counts(run_dir: Path) -> dict[str, int]:
2039
+ case_count = 0
2040
+ suite_dir = run_dir / "suite-snapshot"
2041
+ if suite_dir.exists():
2042
+ try:
2043
+ suite = read_json(suite_dir / "suite.json")
2044
+ case_count = len(load_cases(suite_dir / suite.get("cases", "cases.jsonl")))
2045
+ except Exception:
2046
+ case_count = 0
2047
+ marker_count = terminal_marker_count(run_dir)
2048
+ if case_count == 0:
2049
+ case_count = len(list((run_dir / "cases").glob("*")))
2050
+ return {"case_count": case_count, "terminal": marker_count, "pending": max(case_count - marker_count, 0)}
2051
+
2052
+
2053
+ def classify_run_dir(run_dir: Path) -> dict[str, Any]:
2054
+ manifest_path = run_dir / "manifest.json"
2055
+ reservation = read_reservation(run_dir)
2056
+ reservation_live = bool(reservation and reservation_is_live(reservation))
2057
+ if manifest_path.exists():
2058
+ state = "completed"
2059
+ elif reservation_live:
2060
+ state = "running"
2061
+ elif reservation is not None:
2062
+ state = "stale"
2063
+ else:
2064
+ state = "orphaned"
2065
+ data: dict[str, Any] = {
2066
+ "run_id": run_dir.name,
2067
+ "run_dir": str(run_dir),
2068
+ "state": state,
2069
+ "reservation": {"present": reservation is not None, "live": reservation_live},
2070
+ "cases": run_case_counts(run_dir),
2071
+ "queue_jobs": stored_queue_jobs(run_dir),
2072
+ }
2073
+ if manifest_path.exists():
2074
+ try:
2075
+ report = report_data(run_dir)
2076
+ data["run"] = report["run"]
2077
+ data["report_hash"] = report["report_hash"]
2078
+ except Exception:
2079
+ pass
2080
+ return data
2081
+
2082
+
2083
+ def command_jobs(argv: list[str], json_mode: bool, started: float) -> int:
2084
+ args = strip_flags(argv, set(), {"--json", "--no-color", "--yes", "--force"})
2085
+ if len(args) < 2:
2086
+ raise EvalctlError("E_CASE_INVALID", "jobs requires list, get, or prune", "try: evalctl jobs list --json", 1)
2087
+ subcommand = args[1]
2088
+ root = runs_root()
2089
+ run_dirs = sorted([p for p in root.iterdir() if p.is_dir()], key=lambda p: p.name) if root.exists() else []
2090
+ if subcommand == "list":
2091
+ runs = [classify_run_dir(path) for path in run_dirs]
2092
+ return print_envelope({"runs": runs, "count": len(runs)}, json_mode=json_mode, human="\n".join(f"{r['run_id']}\t{r['state']}" for r in runs), started=started)
2093
+ if subcommand == "get":
2094
+ if len(args) != 3:
2095
+ raise EvalctlError("E_CASE_INVALID", "jobs get requires a run id", "try: evalctl jobs get <run-id> --json", 1)
2096
+ run_dir = root / args[2]
2097
+ if not run_dir.exists():
2098
+ raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {args[2]}", "try: evalctl jobs list --json", 1)
2099
+ data = classify_run_dir(run_dir)
2100
+ return print_envelope(data, json_mode=json_mode, human=f"{data['run_id']}: {data['state']}", started=started)
2101
+ if subcommand == "prune":
2102
+ confirmed = has_flag(argv, "--yes") or has_flag(argv, "--force")
2103
+ stale = [classify_run_dir(path) for path in run_dirs if classify_run_dir(path)["state"] == "stale"]
2104
+ orphaned = [classify_run_dir(path) for path in run_dirs if classify_run_dir(path)["state"] == "orphaned"]
2105
+ refused = [classify_run_dir(path) for path in run_dirs if classify_run_dir(path)["state"] in {"completed", "running"}]
2106
+ removed_reservations: list[str] = []
2107
+ removed_runs: list[str] = []
2108
+ if confirmed:
2109
+ for item in stale:
2110
+ clear_reservation(Path(item["run_dir"]))
2111
+ removed_reservations.append(item["run_id"])
2112
+ for item in orphaned:
2113
+ shutil.rmtree(item["run_dir"], ignore_errors=True)
2114
+ removed_runs.append(item["run_id"])
2115
+ data = {
2116
+ "confirmed": confirmed,
2117
+ "candidates": {"stale_reservations": [item["run_id"] for item in stale], "orphaned_runs": [item["run_id"] for item in orphaned]},
2118
+ "removed": {"reservations": removed_reservations, "runs": removed_runs},
2119
+ "refused": [{"run_id": item["run_id"], "state": item["state"]} for item in refused],
2120
+ }
2121
+ return print_envelope(data, json_mode=json_mode, human=json.dumps(data, indent=2, sort_keys=True), started=started)
2122
+ raise EvalctlError("E_CASE_INVALID", f"unknown jobs subcommand '{subcommand}'", "try: evalctl jobs list --json", 1)
2123
+
2124
+
2125
+ def parse_replay_source(argv: list[str]) -> Path:
2126
+ if not has_flag(argv, "--failed"):
2127
+ raise EvalctlError("E_CASE_INVALID", "replay requires --failed in v0.2", "try: evalctl replay --failed <run-id> --json", 1)
2128
+ run_dir = value_after(argv, "--run-dir")
2129
+ args = strip_flags(argv, {"--run-dir", "--run-id", "--suite", "--jobs", "--timeout"}, {"--json", "--no-color", "--failed", "--force", "--fail-on-fail"})
2130
+ positional = args[1:] if args and args[0] == "replay" else args
2131
+ if run_dir and positional:
2132
+ raise EvalctlError("E_CASE_INVALID", "replay source must be either a run id or --run-dir, not both", "try: evalctl replay --failed <run-id> --json", 1)
2133
+ if not run_dir and len(positional) != 1:
2134
+ raise EvalctlError("E_CASE_INVALID", "replay requires a source run id or --run-dir", "try: evalctl replay --failed <run-id> --json", 1)
2135
+ path = Path(run_dir) if run_dir else Path("evals") / "runs" / positional[0]
2136
+ if not (path / "manifest.json").exists():
2137
+ raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {path}", "try: evalctl run code-review --json", 1)
2138
+ return path
2139
+
2140
+
2141
+ def command_replay(argv: list[str], json_mode: bool, started: float) -> int:
2142
+ source_run = parse_replay_source(argv)
2143
+ source_manifest = read_json(source_run / "manifest.json")
2144
+ source_report = report_data(source_run)
2145
+ failed_ids = [case["id"] for case in source_report["cases"] if case["status"] != "pass"]
2146
+ if not failed_ids:
2147
+ warnings = [{"code": "W_NOTHING_TO_REPLAY", "message": "source run has no failed or errored cases"}]
2148
+ data = {"replayed_from": source_manifest["run_id"], "cases_replayed": 0}
2149
+ return print_envelope(data, json_mode=json_mode, human=f"{source_manifest['run_id']}: nothing to replay", warnings=warnings, started=started)
2150
+
2151
+ suite_arg = value_after(argv, "--suite") or source_manifest["suite"]["name"]
2152
+ try:
2153
+ suite_dir = resolve_suite(suite_arg)
2154
+ except EvalctlError as exc:
2155
+ if exc.error["code"] == "E_SUITE_NOT_FOUND":
2156
+ raise EvalctlError("E_SUITE_NOT_FOUND", exc.error["message"], "pass the current suite with --suite <suite-or-path>", 1)
2157
+ raise
2158
+ validate_suite(suite_dir)
2159
+ suite = read_json(suite_dir / "suite.json")
2160
+ current_cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
2161
+ current_by_id = {case["id"]: case for case in current_cases}
2162
+ warnings: list[dict[str, Any]] = []
2163
+ target_cases = []
2164
+ for case_id in failed_ids:
2165
+ case = current_by_id.get(case_id)
2166
+ if case is None:
2167
+ warnings.append({"code": "W_REPLAY_CASE_ABSENT", "message": f"case {case_id} is absent from the current suite"})
2168
+ else:
2169
+ target_cases.append(case)
2170
+ if not target_cases:
2171
+ warnings.append({"code": "W_NOTHING_TO_REPLAY", "message": "no selected failed cases exist in the current suite"})
2172
+ data = {"replayed_from": source_manifest["run_id"], "cases_replayed": 0}
2173
+ return print_envelope(data, json_mode=json_mode, human=f"{source_manifest['run_id']}: nothing to replay", warnings=warnings, started=started)
2174
+
2175
+ jobs = parse_jobs(argv)
2176
+ timeout_override = int(value_after(argv, "--timeout")) if value_after(argv, "--timeout") else None
2177
+ run_id = value_after(argv, "--run-id") or f"{source_manifest['run_id']}-replay-{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H%M%SZ')}"
2178
+ run_dir = Path("evals") / "runs" / run_id
2179
+ if run_dir.resolve() == source_run.resolve():
2180
+ raise EvalctlError("E_RUN_CONFLICT", "replay destination must not be the source run", "use a fresh --run-id", 5)
2181
+ if run_dir.exists():
2182
+ if (run_dir / "manifest.json").exists():
2183
+ if not has_flag(argv, "--force"):
2184
+ raise EvalctlError("E_RUN_CONFLICT", f"run-id `{run_id}` already completed; refusing to overwrite without --force", "use a fresh --run-id or pass --force", 5)
2185
+ shutil.rmtree(run_dir)
2186
+ else:
2187
+ raise EvalctlError("E_RUN_BUSY", f"run reservation exists for {run_id}", "wait and retry evalctl replay with a new --run-id", 4)
2188
+ if not suite.get("acknowledged_unsandboxed_runner") and sys.stderr.isatty():
2189
+ print("runner commands execute arbitrary local code; evalctl is not a sandbox", file=sys.stderr)
2190
+ data, run_warnings, run_ok = execute_cases(suite_dir, suite, target_cases, run_dir, run_id, jobs, timeout_override, source_manifest["run_id"])
2191
+ all_warnings = run_warnings + warnings
2192
+ commands = [{"command": f"evalctl report {run_id} --format json", "rationale": "inspect replay report"}]
2193
+ print_envelope(data, json_mode=json_mode, human=f"Replay {run_id}: {'pass' if run_ok else 'fail'}", warnings=all_warnings, commands=commands, started=started)
2194
+ return 6 if has_flag(argv, "--fail-on-fail") and not run_ok else 0
2195
+
2196
+
2197
+ def status_counts(cases: list[dict[str, Any]]) -> dict[str, int]:
2198
+ counts = {"pass": 0, "fail": 0, "error": 0}
2199
+ for case in cases:
2200
+ counts[case["status"]] = counts.get(case["status"], 0) + 1
2201
+ return counts
2202
+
2203
+
2204
+ def run_identity(suite: dict[str, Any], suite_dir: Path, cases: list[dict[str, Any]]) -> dict[str, Any]:
2205
+ return {
2206
+ "suite_name": suite.get("name", suite_dir.name),
2207
+ "suite_hash": sha256_text(stable_json(suite)),
2208
+ "cases": sorted((case["id"], sha256_text(stable_json(case))) for case in cases),
2209
+ }
2210
+
2211
+
2212
+ def manifest_identity(manifest_doc: dict[str, Any]) -> dict[str, Any]:
2213
+ return {
2214
+ "suite_name": manifest_doc["suite"]["name"],
2215
+ "suite_hash": manifest_doc["suite"]["hash"],
2216
+ "cases": sorted((case["id"], case["input_hash"]) for case in manifest_doc["cases"]),
2217
+ }
2218
+
2219
+
2220
+ def resolve_run(argv: list[str]) -> Path:
2221
+ run_dir = value_after(argv, "--run-dir")
2222
+ if run_dir:
2223
+ path = Path(run_dir)
2224
+ else:
2225
+ args = strip_flags(argv, {"--run-dir", "--format"}, {"--json", "--no-color"})
2226
+ if len(args) < 2:
2227
+ raise EvalctlError("E_RUN_NOT_FOUND", "run id or --run-dir is required", "try: evalctl status <run-id> --json", 1)
2228
+ path = Path("evals") / "runs" / args[1]
2229
+ if not (path / "manifest.json").exists():
2230
+ raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {path}", "try: evalctl run code-review --json", 1)
2231
+ return path
2232
+
2233
+
2234
+ def recompute_case_score(run_dir: Path, case_entry: dict[str, Any], suite: dict[str, Any]) -> dict[str, Any]:
2235
+ case_dir = run_dir / "cases" / case_entry["id"]
2236
+ case = read_json(case_dir / "input.json")
2237
+ output = (case_dir / "output.txt").read_text(errors="replace")
2238
+ runner_json = read_json(case_dir / "runner.json")
2239
+ before = read_json(case_dir / "workspace-before.json")
2240
+ after = read_json(case_dir / "workspace-after.json")
2241
+ diff = diff_manifests(before, after)
2242
+ scores = score_case(case, output, runner_json, after, diff, suite.get("scorers", []), case_dir=case_dir, execute=False, suite=suite)
2243
+ status = "error" if runner_json.get("timed_out") or runner_json.get("spawn_failed") or any(s.get("error") for s in scores) else "pass"
2244
+ if status != "error" and not all(s["ok"] for s in scores if s.get("required", True)):
2245
+ status = "fail"
2246
+ return {"case_id": case["id"], "status": status, "ok": status == "pass", "scores": scores}
2247
+
2248
+
2249
+ def report_data(run_dir: Path) -> dict[str, Any]:
2250
+ manifest_doc = read_json(run_dir / "manifest.json")
2251
+ suite = read_json(run_dir / "suite-snapshot" / "suite.json")
2252
+ cases = []
2253
+ for entry in sorted(manifest_doc["cases"], key=lambda c: c["id"]):
2254
+ score = recompute_case_score(run_dir, entry, suite)
2255
+ aggregate = sum(s["score"] for s in score["scores"]) / max(len(score["scores"]), 1)
2256
+ cases.append({"id": score["case_id"], "status": score["status"], "ok": score["ok"], "aggregate_score": aggregate, "scores": score["scores"], "artifacts": entry["artifacts"]})
2257
+ failures = [c for c in cases if c["status"] != "pass"]
2258
+ failures.sort(key=lambda c: (0 if c["status"] == "error" else 1, c["aggregate_score"], c["id"]))
2259
+ def report_score(score: dict[str, Any]) -> dict[str, Any]:
2260
+ out = {"scorer": score["scorer"], "ok": score["ok"], "label": score["label"], "findings": score["findings"]}
2261
+ if "id" in score:
2262
+ out["id"] = score["id"]
2263
+ return out
2264
+
2265
+ normalized = {"run": {"ok": not failures, "suite": manifest_doc["suite"]["name"], "case_count": len(cases), "status_counts": status_counts(cases)}, "failures": [{"id": f["id"], "status": f["status"], "scores": [report_score(s) for s in f["scores"]]} for f in failures], "cases": [{"id": c["id"], "status": c["status"], "ok": c["ok"]} for c in cases]}
2266
+ return {**normalized, "run_id": manifest_doc["run_id"], "report_hash": sha256_text(stable_json(normalized))}
2267
+
2268
+
2269
+ def markdown_report(data: dict[str, Any]) -> str:
2270
+ lines = [f"# evalctl report: {data['run_id']}", "", f"Status: {'pass' if data['run']['ok'] else 'fail'}", f"Report hash: `{data['report_hash']}`", "", "## Failures"]
2271
+ if not data["failures"]:
2272
+ lines.append("None.")
2273
+ for failure in data["failures"]:
2274
+ lines.append(f"- `{failure['id']}` {failure['status']}")
2275
+ for score in failure["scores"]:
2276
+ if not score["ok"]:
2277
+ lines.append(f" - {score['scorer']}: {score['label']} {score['findings']}")
2278
+ return "\n".join(lines) + "\n"
2279
+
2280
+
2281
+ def command_status(argv: list[str], json_mode: bool, started: float) -> int:
2282
+ run_dir = resolve_run(argv)
2283
+ manifest_doc = read_json(run_dir / "manifest.json")
2284
+ report = report_data(run_dir)
2285
+ data = {"run_id": manifest_doc["run_id"], "run_dir": str(run_dir), "run": report["run"], "cases": report["cases"], "recommended_action": {"command": f"evalctl report --run-dir {run_dir} --format json", "rationale": "inspect deterministic report and ranked failures", "alternatives": []}}
2286
+ return print_envelope(data, json_mode=json_mode, human=f"{manifest_doc['run_id']}: {'pass' if report['run']['ok'] else 'fail'}", started=started)
2287
+
2288
+
2289
+ def command_report(argv: list[str], json_mode: bool, started: float) -> int:
2290
+ run_dir = resolve_run(argv)
2291
+ data = report_data(run_dir)
2292
+ fmt = value_after(argv, "--format", "json" if json_mode else "markdown")
2293
+ if fmt == "markdown" and not has_flag(argv, "--json"):
2294
+ print(markdown_report(data), end="")
2295
+ return 0
2296
+ if fmt not in {"json", "markdown"}:
2297
+ raise EvalctlError("E_CASE_INVALID", f"--format must be markdown or json (got {fmt})", "try: evalctl report <run-id> --format json", 1)
2298
+ commands = [{"command": f"evalctl report --run-dir {run_dir} --format json", "rationale": "regenerate this report"}]
2299
+ return print_envelope(data, json_mode=True, commands=commands, started=started)
2300
+
2301
+
2302
+ def main(argv: list[str] | None = None) -> int:
2303
+ started = time.time()
2304
+ argv = list(sys.argv[1:] if argv is None else argv)
2305
+ json_mode = wants_json(argv)
2306
+ try:
2307
+ if not argv or argv[0] in {"--help", "-h"}:
2308
+ print(help_text())
2309
+ return 0
2310
+ if argv[0] == "--version":
2311
+ print(__version__)
2312
+ return 0
2313
+ cmd = argv[0]
2314
+ if cmd == "capabilities":
2315
+ return print_envelope(capabilities_data(), json_mode=True, started=started)
2316
+ if cmd == "schema":
2317
+ args = strip_flags(argv, set(), {"--json", "--no-color"})
2318
+ return print_envelope(schema_data(args[1] if len(args) > 1 else None), json_mode=True, started=started)
2319
+ if cmd == "robot-docs" and len(argv) > 1 and argv[1] == "guide":
2320
+ print(robot_docs(), end="")
2321
+ return 0
2322
+ if cmd == "init":
2323
+ return command_init(argv, json_mode, started)
2324
+ if cmd == "validate":
2325
+ return command_validate(argv, json_mode, started)
2326
+ if cmd == "suite" and len(argv) > 1 and argv[1] == "add":
2327
+ return command_suite_add(argv, json_mode, started)
2328
+ if cmd == "case" and len(argv) > 1 and argv[1] == "add":
2329
+ return command_case_add(argv, json_mode, started)
2330
+ if cmd == "scorer" and len(argv) > 1 and argv[1] == "add":
2331
+ return command_scorer_add(argv, json_mode, started)
2332
+ if cmd == "run":
2333
+ return command_run(argv, json_mode, started)
2334
+ if cmd == "jobs":
2335
+ return command_jobs(argv, json_mode, started)
2336
+ if cmd == "replay":
2337
+ return command_replay(argv, json_mode, started)
2338
+ if cmd == "status":
2339
+ return command_status(argv, json_mode, started)
2340
+ if cmd == "report":
2341
+ return command_report(argv, json_mode, started)
2342
+ raise EvalctlError("E_CASE_INVALID", f"unknown command '{cmd}'", "try: evalctl capabilities --json", 1)
2343
+ except EvalctlError as exc:
2344
+ return print_error(exc, json_mode=json_mode, started=started)
2345
+ except KeyboardInterrupt:
2346
+ return print_error(EvalctlError("E_RUNNER_FAILED", "interrupted", "retry the command", 3), json_mode=json_mode, started=started)
2347
+ except Exception as exc:
2348
+ return print_error(EvalctlError("E_RUNNER_FAILED", f"internal error: {exc}", "run with --json and inspect errors[0]", 3), json_mode=json_mode, started=started)
2349
+
2350
+
2351
+ if __name__ == "__main__":
2352
+ raise SystemExit(main())