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>
56 lines
1.6 KiB
Python
56 lines
1.6 KiB
Python
"""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
|