Files
settled-reach/tooling/domains/jobs/schemas.py
T
jpmschweitzerandClaude Opus 5 6f08cc9156 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>
2026-08-31 17:02:36 +02:00

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