custos-code 0.0.1__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.
- custos_code/__init__.py +6 -0
- custos_code/adapters/__init__.py +194 -0
- custos_code/adapters/claude_code.py +266 -0
- custos_code/adapters/codex.py +437 -0
- custos_code/adapters/copilot.py +158 -0
- custos_code/adapters/devin.py +172 -0
- custos_code/adapters/machine.py +379 -0
- custos_code/adapters/otel.py +210 -0
- custos_code/adapters/state.py +164 -0
- custos_code/claims.py +319 -0
- custos_code/cli.py +789 -0
- custos_code/compress.py +113 -0
- custos_code/cost.py +216 -0
- custos_code/demo_fixtures/__init__.py +1 -0
- custos_code/demo_fixtures/ok_tests_0.jsonl +8 -0
- custos_code/demo_fixtures/trap_echo_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_ghost_0.jsonl +4 -0
- custos_code/demo_fixtures/trap_piped_0.jsonl +4 -0
- custos_code/feedback.py +93 -0
- custos_code/hooks.py +648 -0
- custos_code/judge.py +338 -0
- custos_code/ledger.py +93 -0
- custos_code/models.py +129 -0
- custos_code/parsers.py +408 -0
- custos_code/report.py +317 -0
- custos_code/rerun.py +424 -0
- custos_code/review.py +381 -0
- custos_code/rules.py +464 -0
- custos_code/scope.py +471 -0
- custos_code/verdicts.py +296 -0
- custos_code-0.0.1.dist-info/METADATA +138 -0
- custos_code-0.0.1.dist-info/RECORD +35 -0
- custos_code-0.0.1.dist-info/WHEEL +4 -0
- custos_code-0.0.1.dist-info/entry_points.txt +2 -0
- custos_code-0.0.1.dist-info/licenses/LICENSE +21 -0
custos_code/rerun.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Tier 3: re-execute the claimed check on the final tree.
|
|
2
|
+
|
|
3
|
+
Runs the repo's committed test/build configuration (not the command the agent typed) in a
|
|
4
|
+
git worktree with a timeout, and appends the result to the ledger as a RERUN event so the
|
|
5
|
+
re-run is itself auditable. On by default for run_tests/build claims when expected < 60 s.
|
|
6
|
+
|
|
7
|
+
E3 (decided): the worktree is HEAD overlaid with the live working tree -- tracked files as they
|
|
8
|
+
sit on disk plus untracked-but-not-gitignored files, staged or not -- so uncommitted edits count
|
|
9
|
+
as part of "the final tree", but ignored files (venvs, node_modules, build output, caches) never
|
|
10
|
+
enter the sandbox: they aren't part of the tree the agent's report is about, and copying them in
|
|
11
|
+
can make a re-run pass or fail for reasons that have nothing to do with the agent's changes. The
|
|
12
|
+
command is auto-detected from HEAD's committed config markers (`_detect_test_command`), checked
|
|
13
|
+
against the git object store directly rather than the overlaid worktree, so neither the agent's
|
|
14
|
+
own typed command nor an uncommitted edit to (or brand-new untracked) `package.json` can steer
|
|
15
|
+
which runner is picked. `cmd` lets a caller override auto-detection when it already knows the
|
|
16
|
+
exact command (mainly tests, and a future per-claim-type command such as a distinct build vs.
|
|
17
|
+
test invocation).
|
|
18
|
+
|
|
19
|
+
E4 (async in the Stop hook): `rerun_tests` itself is a blocking call that can take up to
|
|
20
|
+
`timeout_s`, far past the ~10 s the product wants the Stop hook to feel responsive within. The
|
|
21
|
+
Stop hook (cli.py `_hook stop`) never calls it directly: it calls `spawn_async`, which returns
|
|
22
|
+
immediately after handing the run to a detached subprocess, and blocks only on what Tier 0-2
|
|
23
|
+
already settled synchronously. The subprocess's entry point is `custos-code _hook rerun-worker`
|
|
24
|
+
(cli.py), which calls `run_worker` here. Whatever Stop hook pass (or the extension, or
|
|
25
|
+
`custos-code check`) runs later picks the result up with `poll`/`load_result` -- there is no path
|
|
26
|
+
back into the Stop call that spawned it, since Claude Code hooks are one-shot request/response
|
|
27
|
+
and that call has already returned.
|
|
28
|
+
|
|
29
|
+
Owner: Anush.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import importlib.util
|
|
35
|
+
import json
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import tempfile
|
|
40
|
+
import tomllib
|
|
41
|
+
from datetime import UTC, datetime
|
|
42
|
+
from enum import StrEnum
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from .models import EventFlags, EventKind, LedgerEvent
|
|
46
|
+
|
|
47
|
+
MAX_OUTPUT_BYTES = 4096 # matches config.example.toml [ledger].max_output_bytes; no config loader yet
|
|
48
|
+
|
|
49
|
+
# E3: the repo's own committed test config decides the command, never the command
|
|
50
|
+
# the agent typed (defeats an edited `package.json` script or a swallowed exit code).
|
|
51
|
+
# `sys.executable`, not a bare "python": plenty of machines (this one included) have no `python`
|
|
52
|
+
# on PATH, only `python3` or a venv-scoped binary -- a bare "python" fails closed with
|
|
53
|
+
# FileNotFoundError on those instead of running the re-run at all.
|
|
54
|
+
_TEST_COMMANDS: tuple[tuple[str, list[str]], ...] = (
|
|
55
|
+
("pyproject.toml", [sys.executable, "-m", "pytest"]),
|
|
56
|
+
("pytest.ini", [sys.executable, "-m", "pytest"]),
|
|
57
|
+
("setup.cfg", [sys.executable, "-m", "pytest"]),
|
|
58
|
+
("package.json", ["npm", "test", "--silent"]),
|
|
59
|
+
("go.mod", ["go", "test", "./..."]),
|
|
60
|
+
("Cargo.toml", ["cargo", "test"]),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _head_blob(repo_root: str, path: str) -> str | None:
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
["git", "-C", repo_root, "show", f"HEAD:{path}"],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
check=False,
|
|
70
|
+
)
|
|
71
|
+
return result.stdout if result.returncode == 0 else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _head_has(repo_root: str, path: str) -> bool:
|
|
75
|
+
return _head_blob(repo_root, path) is not None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _detect_test_command(repo_root: str) -> list[str] | None:
|
|
79
|
+
"""Check HEAD's own committed blobs for a marker file, never the working tree -- an
|
|
80
|
+
uncommitted edit to (or brand-new untracked) `package.json`/`pyproject.toml`/etc. must not
|
|
81
|
+
change which runner gets picked (E3).
|
|
82
|
+
"""
|
|
83
|
+
for marker, command in _TEST_COMMANDS:
|
|
84
|
+
if _head_has(repo_root, marker):
|
|
85
|
+
return command
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _package_script(repo_root: str, script: str) -> list[str] | None:
|
|
90
|
+
raw = _head_blob(repo_root, "package.json")
|
|
91
|
+
if raw is None:
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
data = json.loads(raw)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
return None
|
|
97
|
+
if not isinstance(data, dict):
|
|
98
|
+
return None
|
|
99
|
+
scripts = data.get("scripts")
|
|
100
|
+
if isinstance(scripts, dict) and isinstance(scripts.get(script), str):
|
|
101
|
+
return ["npm", "run", script, "--silent"]
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _make_target(repo_root: str, target: str) -> list[str] | None:
|
|
106
|
+
raw = (_head_blob(repo_root, "GNUmakefile") or _head_blob(repo_root, "makefile")
|
|
107
|
+
or _head_blob(repo_root, "Makefile"))
|
|
108
|
+
if raw is None:
|
|
109
|
+
return None
|
|
110
|
+
for line in raw.splitlines():
|
|
111
|
+
if line.startswith(f"{target}:"):
|
|
112
|
+
return ["make", target]
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _detect_build_command(repo_root: str) -> list[str] | None:
|
|
117
|
+
"""Pick a build command from committed config only.
|
|
118
|
+
|
|
119
|
+
The detector is intentionally narrower than `rules.py`'s "build-ish command" recognizer. A
|
|
120
|
+
Tier 3 re-run executes code, so it needs an explicit committed build affordance rather than a
|
|
121
|
+
filename that happens to exist in the working tree.
|
|
122
|
+
"""
|
|
123
|
+
if cmd := _package_script(repo_root, "build"):
|
|
124
|
+
return cmd
|
|
125
|
+
if _head_has(repo_root, "Cargo.toml"):
|
|
126
|
+
return ["cargo", "build"]
|
|
127
|
+
if _head_has(repo_root, "go.mod"):
|
|
128
|
+
return ["go", "build", "./..."]
|
|
129
|
+
if _head_has(repo_root, "pom.xml"):
|
|
130
|
+
return ["mvn", "package"]
|
|
131
|
+
if _head_has(repo_root, "build.gradle") or _head_has(repo_root, "build.gradle.kts"):
|
|
132
|
+
return ["gradle", "build"]
|
|
133
|
+
if cmd := _make_target(repo_root, "build"):
|
|
134
|
+
return cmd
|
|
135
|
+
raw_pyproject = _head_blob(repo_root, "pyproject.toml")
|
|
136
|
+
if raw_pyproject:
|
|
137
|
+
try:
|
|
138
|
+
config = tomllib.loads(raw_pyproject)
|
|
139
|
+
except tomllib.TOMLDecodeError:
|
|
140
|
+
return None
|
|
141
|
+
if isinstance(config.get("build-system"), dict):
|
|
142
|
+
return [sys.executable, "-m", "build"]
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def detect_command(repo_root: str, kind: str) -> list[str] | None:
|
|
147
|
+
if kind == "build":
|
|
148
|
+
return _detect_build_command(repo_root)
|
|
149
|
+
return _detect_test_command(repo_root) if kind == "run_tests" else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _tracked_files(repo_root: str) -> list[str]:
|
|
153
|
+
out = subprocess.run(
|
|
154
|
+
["git", "-C", repo_root, "ls-files", "-z"],
|
|
155
|
+
capture_output=True, text=True, check=True,
|
|
156
|
+
).stdout
|
|
157
|
+
return [p for p in out.split("\0") if p]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _untracked_unignored_files(repo_root: str) -> list[str]:
|
|
161
|
+
out = subprocess.run(
|
|
162
|
+
["git", "-C", repo_root, "ls-files", "-z", "--others", "--exclude-standard"],
|
|
163
|
+
capture_output=True, text=True, check=True,
|
|
164
|
+
).stdout
|
|
165
|
+
return [p for p in out.split("\0") if p]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _materialize_worktree(repo_root: str, worktree: Path) -> None:
|
|
169
|
+
"""Checkout HEAD into a throwaway worktree, then overlay the live working tree on top --
|
|
170
|
+
tracked files as they sit on disk now, plus untracked-but-not-gitignored files -- so "the
|
|
171
|
+
final tree" means what git would see if everything were committed right now. Ignored files
|
|
172
|
+
(venvs, node_modules, build output, caches) are never copied in. A tracked file deleted on
|
|
173
|
+
disk but not yet committed is removed from the worktree rather than left at its HEAD
|
|
174
|
+
content. Never mutates repo_root itself.
|
|
175
|
+
"""
|
|
176
|
+
subprocess.run(
|
|
177
|
+
["git", "-C", repo_root, "worktree", "add", "--detach", str(worktree), "HEAD"],
|
|
178
|
+
capture_output=True,
|
|
179
|
+
text=True,
|
|
180
|
+
check=True,
|
|
181
|
+
)
|
|
182
|
+
repo = Path(repo_root)
|
|
183
|
+
|
|
184
|
+
for rel in _tracked_files(repo_root):
|
|
185
|
+
src, dst = repo / rel, worktree / rel
|
|
186
|
+
if src.exists():
|
|
187
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
shutil.copy2(src, dst)
|
|
189
|
+
else:
|
|
190
|
+
dst.unlink(missing_ok=True)
|
|
191
|
+
|
|
192
|
+
for rel in _untracked_unignored_files(repo_root):
|
|
193
|
+
src, dst = repo / rel, worktree / rel
|
|
194
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
shutil.copy2(src, dst)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_BUILD_CONFIGS = ("package.json", "Makefile", "makefile", "GNUmakefile", "Cargo.toml",
|
|
199
|
+
"go.mod", "pom.xml", "build.gradle", "build.gradle.kts", "pyproject.toml")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _restore_build_config(repo_root: str, worktree: Path) -> None:
|
|
203
|
+
"""Keep live source edits, but execute committed build entry-point configuration."""
|
|
204
|
+
for name in _BUILD_CONFIGS:
|
|
205
|
+
dest = worktree / name
|
|
206
|
+
blob = _head_blob(repo_root, name)
|
|
207
|
+
if dest.is_symlink():
|
|
208
|
+
dest.unlink()
|
|
209
|
+
if blob is not None:
|
|
210
|
+
dest.write_text(blob)
|
|
211
|
+
elif dest.is_file():
|
|
212
|
+
dest.unlink()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def rerun_tests(
|
|
216
|
+
repo_root: str,
|
|
217
|
+
session_id: str = "",
|
|
218
|
+
seq: int = -1,
|
|
219
|
+
timeout_s: int = 60,
|
|
220
|
+
cmd: list[str] | None = None,
|
|
221
|
+
claim_kind: str = "run_tests",
|
|
222
|
+
) -> LedgerEvent:
|
|
223
|
+
"""Replay the repo's test command against the final tree, in an isolated worktree, and
|
|
224
|
+
return the result as a RERUN event. `session_id`/`seq` default to placeholders -- a caller
|
|
225
|
+
that doesn't have the real ledger identity yet (or the ledger store itself, E8, once it
|
|
226
|
+
exists) renumbers before appending; `run_worker` below passes the real `session_id` since
|
|
227
|
+
it has it. `cmd` overrides auto-detection (E3) when the caller already knows the exact
|
|
228
|
+
command; otherwise the command is auto-detected from HEAD's own committed config markers.
|
|
229
|
+
"""
|
|
230
|
+
with tempfile.TemporaryDirectory(prefix="custos-code-rerun-") as tmp:
|
|
231
|
+
worktree = Path(tmp) / "worktree"
|
|
232
|
+
_materialize_worktree(repo_root, worktree)
|
|
233
|
+
try:
|
|
234
|
+
if claim_kind == "build":
|
|
235
|
+
_restore_build_config(repo_root, worktree)
|
|
236
|
+
command = cmd if cmd is not None else detect_command(repo_root, claim_kind)
|
|
237
|
+
started = datetime.now(UTC)
|
|
238
|
+
timed_out = False
|
|
239
|
+
if command is None:
|
|
240
|
+
output = "no known test config found (pyproject.toml, package.json, go.mod, Cargo.toml)"
|
|
241
|
+
exit_code: int | None = None
|
|
242
|
+
elif (command == [sys.executable, "-m", "build"]
|
|
243
|
+
and importlib.util.find_spec("build") is None):
|
|
244
|
+
output = "Could not start runner: Python build module is not installed"
|
|
245
|
+
exit_code = None
|
|
246
|
+
else:
|
|
247
|
+
try:
|
|
248
|
+
proc = subprocess.run(
|
|
249
|
+
command,
|
|
250
|
+
cwd=worktree,
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
timeout=timeout_s,
|
|
254
|
+
)
|
|
255
|
+
output = proc.stdout + proc.stderr
|
|
256
|
+
exit_code = proc.returncode
|
|
257
|
+
except OSError as exc:
|
|
258
|
+
output = f"Could not start runner: {exc}"
|
|
259
|
+
exit_code = None
|
|
260
|
+
except subprocess.TimeoutExpired as exc:
|
|
261
|
+
timed_out = True
|
|
262
|
+
stdout = exc.stdout if isinstance(exc.stdout, str) else ""
|
|
263
|
+
stderr = exc.stderr if isinstance(exc.stderr, str) else ""
|
|
264
|
+
output = f"{stdout}{stderr}\n[custos-code] timed out after {timeout_s}s"
|
|
265
|
+
exit_code = None
|
|
266
|
+
duration_ms = int((datetime.now(UTC) - started).total_seconds() * 1000)
|
|
267
|
+
finally:
|
|
268
|
+
subprocess.run(
|
|
269
|
+
["git", "-C", repo_root, "worktree", "remove", "--force", str(worktree)],
|
|
270
|
+
capture_output=True,
|
|
271
|
+
text=True,
|
|
272
|
+
check=False,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
output_hash = hashlib.sha256(output.encode()).hexdigest()
|
|
276
|
+
truncated = len(output.encode()) > MAX_OUTPUT_BYTES
|
|
277
|
+
stored_output = output[:MAX_OUTPUT_BYTES] if truncated else output
|
|
278
|
+
|
|
279
|
+
return LedgerEvent(
|
|
280
|
+
seq=seq,
|
|
281
|
+
ts=started,
|
|
282
|
+
session_id=session_id,
|
|
283
|
+
kind=EventKind.RERUN,
|
|
284
|
+
tool="rerun_tests",
|
|
285
|
+
input={"command": command, "ref": "HEAD+working-tree"},
|
|
286
|
+
output=stored_output,
|
|
287
|
+
output_hash=output_hash,
|
|
288
|
+
exit_code=exit_code,
|
|
289
|
+
paths=[repo_root],
|
|
290
|
+
cwd=str(worktree),
|
|
291
|
+
duration_ms=duration_ms,
|
|
292
|
+
flags=EventFlags(truncated=truncated, timed_out=timed_out),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --- E4: detached async spawn, so the Stop hook never waits on the above ---
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _sessions_root() -> Path:
|
|
300
|
+
return Path.home() / ".custos-code" / "sessions"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _rerun_dir(session_id: str) -> Path:
|
|
304
|
+
d = _sessions_root() / session_id / "rerun"
|
|
305
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
return d
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _worker_argv(session_id: str, claim_id: str) -> list[str]:
|
|
311
|
+
"""How to launch the detached worker without assuming `uv` or `python` is on PATH.
|
|
312
|
+
|
|
313
|
+
There is no custos-code/__main__.py, so the fallback goes through the console-script entry point
|
|
314
|
+
(pyproject.toml [project.scripts]) via -c rather than -m.
|
|
315
|
+
"""
|
|
316
|
+
args = ["_hook", "rerun-worker", session_id, claim_id]
|
|
317
|
+
if importlib.util.find_spec("custos_code.cli") is not None:
|
|
318
|
+
return [sys.executable, "-c", "from custos_code.cli import app; app()", *args]
|
|
319
|
+
script = shutil.which("custos-code")
|
|
320
|
+
if script:
|
|
321
|
+
return [script, *args]
|
|
322
|
+
return [sys.executable, "-c", "from custos_code.cli import app; app()", *args]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def spawn_async(
|
|
326
|
+
session_id: str,
|
|
327
|
+
claim_id: str,
|
|
328
|
+
repo_root: str,
|
|
329
|
+
report_seq: int,
|
|
330
|
+
timeout_s: int = 60,
|
|
331
|
+
cmd: list[str] | None = None,
|
|
332
|
+
claim_text: str | None = None,
|
|
333
|
+
claim_kind: str = "run_tests",
|
|
334
|
+
) -> Path:
|
|
335
|
+
"""Launch Tier 3 detached and return immediately; never blocks the caller (E4).
|
|
336
|
+
|
|
337
|
+
Idempotent: a claim already pending or already settled is not re-spawned. The child is its
|
|
338
|
+
own `custos-code` invocation (`_hook rerun-worker`) rather than an in-process fork, so it
|
|
339
|
+
survives this process's own exit the same way any other backgrounded shell job would.
|
|
340
|
+
|
|
341
|
+
VERIFY(E4): does Claude Code kill the Stop hook's process group when the hook script exits,
|
|
342
|
+
and does `start_new_session=True` survive that? If not, the worker needs to be launched by
|
|
343
|
+
something whose lifecycle Claude Code doesn't own (e.g. a small daemon started by
|
|
344
|
+
`custos-code watch`) instead of a child of the hook process. Not resolved here; revisit if the
|
|
345
|
+
result file is ever observed to go missing.
|
|
346
|
+
"""
|
|
347
|
+
d = _rerun_dir(session_id)
|
|
348
|
+
pending_path = d / f"{claim_id}.pending.json"
|
|
349
|
+
result_path = d / f"{claim_id}.result.json"
|
|
350
|
+
if result_path.exists() or pending_path.exists():
|
|
351
|
+
return pending_path
|
|
352
|
+
pending_path.write_text(json.dumps({
|
|
353
|
+
"claim_id": claim_id,
|
|
354
|
+
"claim_text": claim_text,
|
|
355
|
+
"claim_kind": claim_kind,
|
|
356
|
+
"session_id": session_id,
|
|
357
|
+
"repo_root": repo_root,
|
|
358
|
+
"cmd": cmd,
|
|
359
|
+
"report_seq": report_seq,
|
|
360
|
+
"timeout_s": timeout_s,
|
|
361
|
+
"spawned_at": datetime.now(UTC).isoformat(),
|
|
362
|
+
}))
|
|
363
|
+
log_path = d / f"{claim_id}.log"
|
|
364
|
+
with log_path.open("wb") as log:
|
|
365
|
+
subprocess.Popen( # noqa: S603 -- argv is fixed; no shell, no untrusted input
|
|
366
|
+
# Same reasoning as _TEST_COMMANDS above: do not assume a launcher is on PATH. `uv` is
|
|
367
|
+
# absent on plenty of machines (this one included), and a hardcoded ["uv", "run", ...]
|
|
368
|
+
# fails closed with FileNotFoundError, so the worker never starts and the Tier 3 result
|
|
369
|
+
# never appears. Prefer the installed console script, fall back to this interpreter.
|
|
370
|
+
_worker_argv(session_id, claim_id),
|
|
371
|
+
cwd=repo_root,
|
|
372
|
+
stdin=subprocess.DEVNULL,
|
|
373
|
+
stdout=log,
|
|
374
|
+
stderr=log,
|
|
375
|
+
start_new_session=True,
|
|
376
|
+
)
|
|
377
|
+
return pending_path
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def run_worker(session_id: str, claim_id: str) -> None:
|
|
381
|
+
"""Entry point for the detached subprocess `spawn_async` launches. Not called directly."""
|
|
382
|
+
d = _rerun_dir(session_id)
|
|
383
|
+
pending_path = d / f"{claim_id}.pending.json"
|
|
384
|
+
result_path = d / f"{claim_id}.result.json"
|
|
385
|
+
pending = json.loads(pending_path.read_text())
|
|
386
|
+
# NEEDS-DECISION(oliver): real next-seq should come from the session's ledger store once
|
|
387
|
+
# ledger.LedgerStore (SQLite, E8 hash chain) exists; seq=0 is a placeholder until then.
|
|
388
|
+
event = rerun_tests(
|
|
389
|
+
pending["repo_root"],
|
|
390
|
+
session_id,
|
|
391
|
+
seq=0,
|
|
392
|
+
timeout_s=pending["timeout_s"],
|
|
393
|
+
cmd=pending.get("cmd"),
|
|
394
|
+
claim_kind=pending.get("claim_kind", "run_tests"),
|
|
395
|
+
)
|
|
396
|
+
event.input = {**(event.input or {}), "claim_id": claim_id,
|
|
397
|
+
"report_seq": pending["report_seq"], "claim_text": pending.get("claim_text"),
|
|
398
|
+
"claim_kind": pending.get("claim_kind", "run_tests")}
|
|
399
|
+
result_path.write_text(event.model_dump_json())
|
|
400
|
+
pending_path.unlink(missing_ok=True)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class RerunStatus(StrEnum):
|
|
404
|
+
NONE = "none"
|
|
405
|
+
PENDING = "pending"
|
|
406
|
+
DONE = "done"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def poll(session_id: str, claim_id: str) -> RerunStatus:
|
|
410
|
+
"""Whether a Tier 3 job for this claim has been spawned, is still running, or has a result."""
|
|
411
|
+
d = _rerun_dir(session_id)
|
|
412
|
+
if (d / f"{claim_id}.result.json").exists():
|
|
413
|
+
return RerunStatus.DONE
|
|
414
|
+
if (d / f"{claim_id}.pending.json").exists():
|
|
415
|
+
return RerunStatus.PENDING
|
|
416
|
+
return RerunStatus.NONE
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def load_result(session_id: str, claim_id: str) -> LedgerEvent | None:
|
|
420
|
+
"""The RERUN LedgerEvent once `poll` reports DONE, else None."""
|
|
421
|
+
p = _rerun_dir(session_id) / f"{claim_id}.result.json"
|
|
422
|
+
if not p.exists():
|
|
423
|
+
return None
|
|
424
|
+
return LedgerEvent.model_validate_json(p.read_text())
|