Files
jpmschweitzerandClaude Opus 5 de69bd70b4 feat(config): T-1280 — job log retention, and the corpse that would never die
Pruning happens at spawn time rather than on a schedule: a retention pass that
depends on someone remembering to run it is one that silently never happens.
reach jobs prune is the explicit escape hatch for reclaiming space now.

The cap was measured rather than guessed, which is why this ticket ran last. A
chatty short job writes ~1.8 KB across its three files, so 100 jobs is
single-digit megabytes even if a generator emits per-body progress — inside
.cache/, where being wrong costs disk and never data. SR_JOB_KEEP overrides it.

The interesting part is what "a running job is never pruned" has to mean. Not
"the file says running" — a process killed outright never updates its own
status, so that reading would make every crashed job immortal. Those are
exactly the ones that accumulate, so the naive rule produces the opposite of
retention: the only logs that never go away are the ones nobody wants. The
check consults the process table instead.

Verified both directions. Live, a running 30-second job survived a prune to
--keep 1. Pinned with a fixture holding a finished job, a corpse (record says
running, pid gone), and a genuinely live one — asserting the live one survives
and the corpse does not. Proven to fail by dropping the liveness check.

One false alarm worth recording: my first live test looked exactly like the bug,
showing a running job pruned. It was not — my commands ran two minutes apart, so
the "20-second" job had finished long before. The test was invalid, not the
guard. A timing-sensitive check across separate shell turns proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:27:25 +02:00

204 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Units for the jobs service (T-1278), called DIRECTLY — no CLI round trip.
That is half the point of these tests. D-263 says a service must be
transport-agnostic so it can be called by a test, by another service, or by a
future second front end. If nothing ever exercises that, the layering is
unverified decoration — a claim in a decision record with no evidence behind it.
Every test here imports `service` and calls a function.
The other half is the two behaviours that are easy to get wrong and impossible
to notice when they are:
1. A partial trailing line. The log is appended to by a live process, so a
reader can arrive mid-write. Parsing greedily would either crash or, worse,
silently discard the event and advance past it — losing exactly one line,
the one being written when someone looked.
2. Reconciliation. A job killed outright cannot record its ending, so its file
says `running` forever. Trusting the file leaves a corpse looking busy and
hangs anything waiting on it.
Run: python3 tooling/test_jobs.py
"""
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tooling.domains.jobs import service # noqa: E402
from tooling.domains.jobs.schemas import Job, Status # noqa: E402
def _job(**overrides) -> Job:
base = {
"job": "20260101T000000-test",
"command": "reach check client-version",
"argv": ["check", "client-version"],
"pid": 1,
"started_at": "2026-01-01T00:00:00",
"status": Status.RUNNING,
}
return Job(**{**base, **overrides})
def test_partial_trailing_line(failures: list[str]) -> None:
"""A half-written final line is left for the next read, not dropped."""
with tempfile.TemporaryDirectory() as tmp:
log = Path(tmp) / "j.jsonl"
complete = json.dumps({"message": "one"}) + "\n"
partial = '{"message": "tw'
log.write_text(complete + partial, encoding="utf-8")
events, offset = service.read_events_from(log, 0)
if len(events) != 1:
failures.append(f"partial line: expected 1 complete event, got {len(events)}")
if offset != len(complete):
failures.append(
f"partial line: offset {offset} should stop at {len(complete)}, "
"before the incomplete line — otherwise that event is lost forever"
)
# Now the writer finishes the line. The event must appear.
log.write_text(complete + json.dumps({"message": "two"}) + "\n", encoding="utf-8")
events, _ = service.read_events_from(log, offset)
if [e.get("message") for e in events] != ["two"]:
failures.append(f"partial line: resumed read lost the completed event: {events}")
def test_offset_resume_is_stable(failures: list[str]) -> None:
"""Reading from the end returns nothing and does not move the offset."""
with tempfile.TemporaryDirectory() as tmp:
log = Path(tmp) / "j.jsonl"
log.write_text(json.dumps({"message": "one"}) + "\n", encoding="utf-8")
_, first = service.read_events_from(log, 0)
events, second = service.read_events_from(log, first)
if events or first != second:
failures.append(
f"offset resume: re-reading returned {len(events)} events and moved "
f"{first}->{second}; a follow loop would replay forever"
)
def test_dead_pid_is_reconciled(failures: list[str]) -> None:
"""running + a pid that is gone == died, never running."""
# PID 1 exists; a very high pid almost certainly does not.
alive = service._reconcile(_job(pid=1))
if alive.status is not Status.RUNNING:
failures.append("reconcile: a live pid was reported as not running")
dead = service._reconcile(_job(pid=4_000_000))
if dead.status is not Status.DIED:
failures.append(
f"reconcile: a dead pid stayed {dead.status.value} — a corpse that looks "
"busy hangs every caller waiting on it"
)
def test_died_never_relays_success(failures: list[str]) -> None:
"""A job that died has no exit code of its own, and must not borrow 0."""
died = _job(status=Status.DIED)
if died.effective_exit_code == 0:
failures.append(
"died job relayed exit 0 — that is the exit-0 trap: a killed job "
"reported as success to whatever gated on it"
)
finished = _job(status=Status.FAILED, exit_code=2)
if finished.effective_exit_code != 2:
failures.append("failed job did not relay its own exit code")
def _fixture_jobs(root: Path, specs: list[tuple[str, str, int]]) -> None:
"""Write job metadata into a throwaway repo root. specs: (id, status, pid)."""
directory = root / ".cache" / "reach" / "jobs"
directory.mkdir(parents=True, exist_ok=True)
for job_id, status, pid in specs:
(directory / f"{job_id}.json").write_text(
json.dumps(
{
"job": job_id,
"command": "reach dev selftest",
"argv": ["dev", "selftest"],
"pid": pid,
"started_at": "2026-01-01T00:00:00",
"status": status,
}
),
encoding="utf-8",
)
(directory / f"{job_id}.jsonl").write_text("", encoding="utf-8")
def test_prune_spares_running_but_not_corpses(failures: list[str]) -> None:
"""The retention rule, and the half of it that is easy to get wrong.
A job that is genuinely running survives pruning however old it is — a
generator can outlive the window. But a job whose FILE says running while
its process is gone must be prunable, or every crashed job becomes immortal
and those are exactly what accumulates. The check has to consult the process
table, not the record.
"""
import os
from tooling.core import process
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "project.yaml").write_text("version: 0.0.0\n", encoding="utf-8")
_fixture_jobs(
root,
[
("20260101T000001-aaaa", "done", 1),
("20260101T000002-bbbb", "running", 4_000_000), # corpse: dead pid
("20260101T000003-cccc", "running", os.getpid()), # genuinely alive
("20260101T000004-dddd", "done", 1),
],
)
previous = os.environ.get("SR_REPO_ROOT")
os.environ["SR_REPO_ROOT"] = str(root)
try:
removed = set(process.prune(keep=1))
finally:
if previous is None:
del os.environ["SR_REPO_ROOT"]
else:
os.environ["SR_REPO_ROOT"] = previous
if "20260101T000003-cccc" in removed:
failures.append(
"prune: removed a job whose process is alive — a long generator "
"would lose its own log while still writing to it"
)
if "20260101T000002-bbbb" not in removed:
failures.append(
"prune: kept a job whose file says running but whose process is "
"gone — a crashed job must not be immortal, and those are exactly "
"the ones that accumulate"
)
if "20260101T000001-aaaa" not in removed:
failures.append("prune: kept a finished job beyond the keep window")
def main() -> int:
failures: list[str] = []
test_partial_trailing_line(failures)
test_offset_resume_is_stable(failures)
test_dead_pid_is_reconciled(failures)
test_died_never_relays_success(failures)
test_prune_spares_running_but_not_corpses(failures)
if failures:
print("test_jobs: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print("test_jobs: OK — offsets resume cleanly, dead pids reconcile, died never relays 0")
return 0
if __name__ == "__main__":
sys.exit(main())