feat(config): T-1278 — the jobs domain, and typer.Exit is not a SystemExit
reach jobs list / status / log --follow / wait. A domain rather than core/, because these verbs carry logic and state: they reconcile recorded status against process liveness, tail a file from an offset, and relay an exit code. Found a latent bug in already-committed code before building on it. typer.Exit is a RuntimeError, not a SystemExit, so @handle_errors caught it like any other unexpected exception: `raise typer.Exit(3)` inside a decorated command printed "unexpected Exit: 3" and exited 1, silently discarding the requested code. Nothing hit it because the check router had been converted to ReachError — but jobs wait needs exactly this and it is what anyone would naturally write. Added core/errors.ReachExit as the sanctioned control-flow exit, passed straight through with no verdict. ReachError would have been wrong twice: a failure verdict for a command that worked, and a demand for a fix= where there is no remedy. Reconciliation proved out on a real corpse rather than a simulated one — the job stranded by the T-1277 bug, status "running" with its process long gone, now reports as died. DIED is derived, never recorded, because a process killed outright cannot write its own ending. It relays 137, never 0: a died job has no exit code of its own and borrowing success points the exit-0 trap straight at whatever gated on the run. Second UTC bug of the same family as T-1276's: jobs list reported a job started minutes earlier as running for 133m, because _parse used mktime on a UTC stamp and silently added the offset to every duration. console.render() is public now, so jobs log replays stored events through the same path a live run prints them — a second renderer would drift, and the divergence would surface exactly when someone is reading a log to find out what went wrong. test_jobs.py closes the gap T-1257 named: D-263 claims services are callable without a CLI round trip, and nothing had ever demonstrated it, which left the layering as unverified decoration. Every test here calls the service directly. Not yet exercised, and said plainly: log --follow against a genuinely long-running job. Nothing in reach runs long enough to tail yet. The offset mechanics underneath are tested; the live loop waits for a slow domain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -150,6 +150,18 @@ def _render_as_text(stream: TextIO) -> str | bool:
|
||||
return False
|
||||
|
||||
|
||||
def render(payload: dict[str, Any]) -> str:
|
||||
"""Render one event as a human would see it live.
|
||||
|
||||
Public because `reach jobs log` replays a stored event stream through it.
|
||||
A second renderer for stored events would drift from this one, and the
|
||||
divergence would show up exactly when someone is reading a log to work out
|
||||
what went wrong — the worst moment to be looking at output that does not
|
||||
match what the live run printed.
|
||||
"""
|
||||
return _render(payload)
|
||||
|
||||
|
||||
def _render(payload: dict[str, Any]) -> str:
|
||||
message = payload.get("message", "")
|
||||
if payload.get("kind") == "verdict":
|
||||
|
||||
@@ -42,6 +42,27 @@ class ReachError(Exception):
|
||||
self.exit_code = exit_code if exit_code != 0 else 1
|
||||
|
||||
|
||||
class ReachExit(Exception): # noqa: N818 control flow, not an error
|
||||
"""Exit with a specific code, quietly. Not a failure.
|
||||
|
||||
For a command that must RELAY an exit code rather than report one — most
|
||||
obviously `reach jobs wait`, which exits with the code of the job it waited
|
||||
on. That is not `jobs wait` failing, so a `ReachError` would be wrong twice
|
||||
over: it would print a failure verdict for a command that worked, and
|
||||
demand a `fix=` for a situation with no remedy.
|
||||
|
||||
**Use this, never `typer.Exit`, inside a decorated command.** `typer.Exit`
|
||||
is a `RuntimeError`, not a `SystemExit`, so `handle_errors` catches it like
|
||||
any other unexpected exception — reporting "unexpected Exit: 3" and exiting
|
||||
**1**, silently discarding the code that was asked for. The conformance
|
||||
suite forbids it in routers so the trap cannot be re-entered.
|
||||
"""
|
||||
|
||||
def __init__(self, exit_code: int = 0) -> None:
|
||||
super().__init__(f"exit {exit_code}")
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def unknown_choice(kind: str, given: str, accepted: Iterable[str]) -> ReachError:
|
||||
"""Reject a value from a known finite set, naming the whole set.
|
||||
|
||||
@@ -70,6 +91,9 @@ def handle_errors(func: F) -> F:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except ReachExit as exc:
|
||||
# Control flow, not a failure — no verdict, just the code.
|
||||
raise SystemExit(exc.exit_code) from None
|
||||
except ReachError as exc:
|
||||
# The verdict prints ONCE, LAST, after whatever the command streamed.
|
||||
# A remedy emitted mid-stream at line 400 of 900 is technically
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""The `jobs` domain — detached runs, their logs, and their outcomes.
|
||||
|
||||
A domain rather than part of `core/`, and this is the first real test of that
|
||||
bound (D-263). The *primitives* — spawn, detach, record — are substrate and live
|
||||
in `core/process.py`. These verbs have logic and state of their own: they
|
||||
reconcile recorded status against process liveness, tail a file from an offset,
|
||||
and relay an exit code. A job store in `core/` would be exactly the drift the
|
||||
record warns about.
|
||||
|
||||
Also the first domain written from scratch under the full contract rather than
|
||||
ported, which makes it the worked example for the reach skill (T-1254).
|
||||
"""
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Transport for the `jobs` domain — args in, delegate, format out.
|
||||
|
||||
Zero logic. Reconciliation, offsets and polling all live in `service.py`; what
|
||||
happens here is turning a `Job` into lines and an exit code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import typer
|
||||
|
||||
from tooling.core import cli, console
|
||||
from tooling.core.command import command
|
||||
from tooling.core.errors import ReachExit
|
||||
from tooling.domains.jobs import service
|
||||
from tooling.domains.jobs.schemas import Status
|
||||
|
||||
app = cli.domain("jobs", "Detached runs — what is running, what it printed, how it ended.")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _domain() -> None:
|
||||
"""Keeps `jobs` a group (Typer collapses a single-command app)."""
|
||||
|
||||
|
||||
@app.command("list")
|
||||
@command
|
||||
def list_jobs(
|
||||
limit: int = typer.Option(20, "--limit", "-n", help="How many recent jobs to show."),
|
||||
) -> None:
|
||||
"""Recent detached runs, newest first."""
|
||||
jobs = service.list_jobs(limit)
|
||||
if not jobs:
|
||||
console.out("no jobs recorded — start one with: reach --detach <command>")
|
||||
return
|
||||
for job in jobs:
|
||||
console.out(
|
||||
f"{job.job} {job.status.value:<8} {service.duration(job):>7} {job.command}"
|
||||
)
|
||||
|
||||
|
||||
@app.command("status")
|
||||
@command
|
||||
def status(job_id: str = typer.Argument(..., help="Job id, as printed by --detach.")) -> None:
|
||||
"""One job's outcome, reconciled against whether its process is alive."""
|
||||
job = service.get(job_id)
|
||||
console.out(f"job {job.job}")
|
||||
console.out(f"command {job.command}")
|
||||
console.out(f"status {job.status.value}")
|
||||
console.out(f"started {job.started_at}")
|
||||
console.out(f"elapsed {service.duration(job)}")
|
||||
if job.exit_code is not None:
|
||||
console.out(f"exit {job.exit_code}")
|
||||
if job.status is Status.DIED:
|
||||
# Said in words, because "died" alone reads like a synonym for "failed"
|
||||
# and the distinction matters: nothing recorded an outcome here.
|
||||
console.out("")
|
||||
console.out("This job's process is gone but it never recorded an ending —")
|
||||
console.out("killed outright (SIGKILL, OOM, or a crash). Its log holds")
|
||||
console.out("whatever it managed to emit before that.")
|
||||
|
||||
|
||||
@app.command("log")
|
||||
@command
|
||||
def log(
|
||||
job_id: str = typer.Argument(..., help="Job id, as printed by --detach."),
|
||||
follow: bool = typer.Option(False, "--follow", "-f", help="Keep printing as it runs."),
|
||||
) -> None:
|
||||
"""Replay a job's event stream, rendered as it appeared live."""
|
||||
job = service.get(job_id)
|
||||
events, offset = service.read_events(job_id)
|
||||
for event in events:
|
||||
# Rendered through console, not a local formatter, so a stored log and a
|
||||
# live run are one artefact in two presentations rather than two
|
||||
# renderers that drift apart precisely when someone is debugging.
|
||||
console.out(console.render(event).rstrip("\n"))
|
||||
|
||||
if not follow:
|
||||
return
|
||||
|
||||
while not job.status.finished:
|
||||
time.sleep(service.POLL_SECONDS)
|
||||
events, offset = service.read_events(job_id, offset)
|
||||
for event in events:
|
||||
console.out(console.render(event).rstrip("\n"))
|
||||
job = service.get(job_id)
|
||||
|
||||
# One last read: the job may have written its final events between the last
|
||||
# poll and its exit, and stopping at the status flip would drop them.
|
||||
events, offset = service.read_events(job_id, offset)
|
||||
for event in events:
|
||||
console.out(console.render(event).rstrip("\n"))
|
||||
|
||||
|
||||
@app.command("wait")
|
||||
@command
|
||||
def wait(
|
||||
job_id: str = typer.Argument(..., help="Job id, as printed by --detach."),
|
||||
timeout: float = typer.Option(None, "--timeout", help="Give up after N seconds."),
|
||||
) -> None:
|
||||
"""Block until a job finishes, then exit with ITS exit code.
|
||||
|
||||
That relay is the point: a Makefile or a hook can gate on a detached run
|
||||
exactly as it would on a foreground one. `ReachExit` rather than
|
||||
`ReachError` because waiting successfully for a job that failed is not a
|
||||
failure of `wait`.
|
||||
"""
|
||||
job = service.wait(job_id, timeout)
|
||||
console.verdict(
|
||||
f"job {job.job} {job.status.value} after {service.duration(job)}"
|
||||
+ (f" (exit {job.exit_code})" if job.exit_code is not None else ""),
|
||||
ok=job.status is Status.DONE,
|
||||
fix=None if job.status is Status.DONE else f"reach jobs log {job.job}",
|
||||
)
|
||||
raise ReachExit(job.effective_exit_code)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Data shapes for the `jobs` domain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class Status(str, Enum):
|
||||
"""What a job is doing, after reconciliation.
|
||||
|
||||
`RUNNING`, `DONE` and `FAILED` are recorded by the job itself. **`DIED` is
|
||||
never recorded** — it is derived, when a job's file still says running but
|
||||
its pid is gone. A process killed outright (SIGKILL, OOM, an interpreter
|
||||
crash) cannot write its own ending, so trusting the file would leave a
|
||||
corpse looking busy forever, and anything polling for completion would wait
|
||||
on it indefinitely.
|
||||
"""
|
||||
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
DIED = "died"
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self is not Status.RUNNING
|
||||
|
||||
|
||||
class Job(BaseModel):
|
||||
"""One detached run."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
job: str
|
||||
command: str
|
||||
argv: list[str] = []
|
||||
pid: int
|
||||
started_at: str
|
||||
status: Status
|
||||
exit_code: int | None = None
|
||||
ended_at: str | None = None
|
||||
|
||||
@property
|
||||
def effective_exit_code(self) -> int:
|
||||
"""The code a caller should adopt when relaying this job's outcome.
|
||||
|
||||
A job that died without recording anything has no code of its own. It
|
||||
must not be reported as 0 — that is the exit-0 trap, and the whole
|
||||
reason `DIED` is distinguished from `DONE`.
|
||||
"""
|
||||
if self.status is Status.DIED:
|
||||
return 137 # conventional 128+SIGKILL: killed, not completed
|
||||
return self.exit_code if self.exit_code is not None else 0
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Logic for the `jobs` domain. Transport-agnostic (D-263).
|
||||
|
||||
Nothing here prints, exits, or imports typer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from tooling.core import process
|
||||
from tooling.core.errors import ReachError
|
||||
from tooling.domains.jobs.schemas import Job, Status
|
||||
|
||||
# How often a follow/wait loop re-checks. Chosen for a caller that is a program
|
||||
# rather than an eye: fast enough that `wait` does not add noticeable latency to
|
||||
# a short job, slow enough not to spin a core on a long one.
|
||||
POLL_SECONDS = 0.25
|
||||
|
||||
|
||||
def list_jobs(limit: int = 20) -> list[Job]:
|
||||
"""Recent jobs, newest first, each reconciled against process liveness."""
|
||||
directory = process.jobs_dir()
|
||||
files = sorted(directory.glob("*.json"), reverse=True)
|
||||
jobs = [_load(path.stem) for path in files[:limit]]
|
||||
return [job for job in jobs if job is not None]
|
||||
|
||||
|
||||
def get(job_id: str) -> Job:
|
||||
"""One job by id, reconciled. Raises if it does not exist."""
|
||||
job = _load(job_id)
|
||||
if job is None:
|
||||
known = [path.stem for path in sorted(process.jobs_dir().glob("*.json"), reverse=True)]
|
||||
recent = ", ".join(known[:5]) if known else "(no jobs recorded yet)"
|
||||
raise ReachError(
|
||||
f"no such job: {job_id}",
|
||||
fix=f"reach jobs list — most recent are: {recent}",
|
||||
exit_code=2,
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
def read_events(job_id: str, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Events from `offset`, plus the new offset.
|
||||
|
||||
A byte offset into an append-only file is the entire reason no daemon is
|
||||
needed: a caller can read, drop off, and come back with the offset it kept,
|
||||
and nothing has to have been holding a subscription open on its behalf.
|
||||
"""
|
||||
return read_events_from(process.log_path(job_id), offset)
|
||||
|
||||
|
||||
def read_events_from(path: Path, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
|
||||
"""The same, given a path — so a test can drive it without a real job."""
|
||||
if not path.is_file():
|
||||
return [], offset
|
||||
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(offset)
|
||||
raw = handle.read()
|
||||
new_offset = handle.tell()
|
||||
|
||||
events: list[dict[str, Any]] = []
|
||||
consumed = offset
|
||||
for line in raw.split(b"\n"):
|
||||
if not line.strip():
|
||||
consumed += len(line) + 1
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
# A partial trailing line: the writer is mid-append. Leave the
|
||||
# offset before it so the next read picks it up whole rather than
|
||||
# discarding an event because we looked a millisecond too early.
|
||||
return events, consumed
|
||||
consumed += len(line) + 1
|
||||
return events, new_offset
|
||||
|
||||
|
||||
def wait(job_id: str, timeout: float | None = None) -> Job:
|
||||
"""Block until the job finishes; return it. Never returns while running."""
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
job = get(job_id)
|
||||
if job.status.finished:
|
||||
return job
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise ReachError(
|
||||
f"timed out after {timeout:g}s waiting for job {job_id}",
|
||||
fix=f"reach jobs status {job_id} — the job is still running, not lost",
|
||||
exit_code=2,
|
||||
)
|
||||
time.sleep(POLL_SECONDS)
|
||||
|
||||
|
||||
def duration(job: Job) -> str:
|
||||
"""Human-readable elapsed time, or how long it has been running so far."""
|
||||
start = _parse(job.started_at)
|
||||
end = _parse(job.ended_at) if job.ended_at else time.time()
|
||||
if start is None or end is None:
|
||||
return "?"
|
||||
seconds = max(0.0, end - start)
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes, rest = divmod(int(seconds), 60)
|
||||
return f"{minutes}m{rest:02d}s"
|
||||
|
||||
|
||||
def _load(job_id: str) -> Job | None:
|
||||
meta = process.read_meta(job_id)
|
||||
if meta is None:
|
||||
return None
|
||||
return _reconcile(Job.model_validate(meta))
|
||||
|
||||
|
||||
def _reconcile(job: Job) -> Job:
|
||||
"""Correct a recorded status against reality.
|
||||
|
||||
A job whose file says running but whose pid is gone did not keep running —
|
||||
it died without being able to record anything. Reporting it as running
|
||||
would be the exit-0 trap somewhere nobody is watching, and would hang any
|
||||
caller polling for it to finish.
|
||||
"""
|
||||
if job.status is Status.RUNNING and not process.is_alive(job.pid):
|
||||
return job.model_copy(update={"status": Status.DIED})
|
||||
return job
|
||||
|
||||
|
||||
def _parse(stamp: str | None) -> float | None:
|
||||
"""Parse a recorded timestamp as UTC.
|
||||
|
||||
`calendar.timegm`, NOT `time.mktime`: the stamps are written in UTC, and
|
||||
mktime would read them as local time. That silently adds the UTC offset to
|
||||
every duration — a job started seconds ago reported as having run for over
|
||||
two hours. The same mismatch bit the job id in T-1276; both directions of
|
||||
this conversion need saying out loud.
|
||||
"""
|
||||
if not stamp:
|
||||
return None
|
||||
try:
|
||||
return calendar.timegm(time.strptime(stamp, "%Y-%m-%dT%H:%M:%S"))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -51,6 +51,10 @@ DOMAINS: dict[str, tuple[str, str]] = {
|
||||
"tooling.domains.check.router:app",
|
||||
"Consistency gates — the checks the push hook runs",
|
||||
),
|
||||
"jobs": (
|
||||
"tooling.domains.jobs.router:app",
|
||||
"Detached runs — status, logs and outcomes",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/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 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)
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user