feat(config): T-1276 — every invocation is a job, carried ambiently
Streaming as a decorator, first half. Each invocation of reach gets an id and every event it emits is tagged with it, which is what will let a detached run's log be read back and what correlates the lines of a run that streamed for nine minutes. No command signature changed and no command imports core.jobs — that is the point, per the D-263 amendment: a command must not know jobs exist, because the alternative is call-site discipline wearing a different hat. A ContextVar rather than a module global. A global is correct only until something runs two invocations in one process — which a test harness or a future batch verb does immediately, and which would then interleave two jobs' events under one id with nothing reporting an error. The job context is the OUTERMOST wrapper, and it has to be. @logged emits from its finally and @handle_errors emits its verdict while unwinding, so a context established inside either would already be reset by the time the two most important events are written — leaving them the only untagged lines in the log, and they are precisely the ones a detached run gets read back for. Fixed in passing: the job id used local time while every event's ts is UTC, so an id read 155327 beside its own first log line reading 13:53:27. Two hours apart reads as a logging bug every time someone correlates them by eye. New conformance invariant — nothing outside core/ may import core.jobs. My first version of it inspected only the module path, so it missed `from tooling.core import jobs`, where the name is in the import LIST and which is the form anyone would actually write. It passed while checking nothing. Rewritten to catch all three reachable forms and then verified by committing a real violation, which it named by file and line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-7
@@ -17,9 +17,11 @@ will call:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from tooling.core import jobs
|
||||
from tooling.core.errors import handle_errors
|
||||
from tooling.core.logging import logged
|
||||
|
||||
@@ -35,13 +37,30 @@ MARKER = "__reach_command__"
|
||||
def command(func: F) -> F:
|
||||
"""Compose the invocation contract onto one command function.
|
||||
|
||||
**Order is load-bearing.** `handle_errors` wraps `logged`, not the reverse:
|
||||
the logger's `finally` then sees the ORIGINAL exception and records its type
|
||||
as the outcome. Invert them and the error handler converts everything to
|
||||
`SystemExit` first, so every failure is logged as "SystemExit" and the
|
||||
record says nothing about what actually went wrong — while still looking
|
||||
like it worked.
|
||||
**Order is load-bearing, in both directions.**
|
||||
|
||||
`handle_errors` wraps `logged`, not the reverse: the logger's `finally` then
|
||||
sees the ORIGINAL exception and records its type as the outcome. Invert them
|
||||
and the error handler converts everything to `SystemExit` first, so every
|
||||
failure is logged as "SystemExit" and the record says nothing about what
|
||||
actually went wrong — while still looking like it worked.
|
||||
|
||||
The **job context is outermost**, wrapping both. It has to be: `logged`
|
||||
emits from its `finally` and `handle_errors` emits its verdict while
|
||||
unwinding, so a context established inside either would already be gone by
|
||||
the time the two most important events are written. Those would then be the
|
||||
only untagged lines in the log — and they are precisely the ones a detached
|
||||
run is read back for.
|
||||
"""
|
||||
wrapped = handle_errors(logged(func))
|
||||
inner = handle_errors(logged(func))
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
token = jobs.begin()
|
||||
try:
|
||||
return inner(*args, **kwargs)
|
||||
finally:
|
||||
jobs.end(token)
|
||||
|
||||
setattr(wrapped, MARKER, True)
|
||||
return wrapped # type: ignore[return-value]
|
||||
|
||||
@@ -33,6 +33,8 @@ import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TextIO
|
||||
|
||||
from tooling.core import jobs
|
||||
|
||||
LEVELS: dict[str, int] = {"debug": 10, "info": 20, "warn": 30, "error": 40}
|
||||
|
||||
ENV_LEVEL = "SR_LOG_LEVEL"
|
||||
@@ -115,6 +117,14 @@ def _now() -> str:
|
||||
|
||||
|
||||
def _write(payload: dict[str, Any]) -> None:
|
||||
# Tagged here rather than at each call site, so nothing can emit an
|
||||
# uncorrelated line. The two events that matter most for a detached run —
|
||||
# the verdict and the invocation record — are written while unwinding, and
|
||||
# would be the ones a call-site approach missed.
|
||||
job_id = jobs.current()
|
||||
if job_id:
|
||||
payload = {**payload, "job": job_id}
|
||||
|
||||
stream: TextIO = sys.stderr
|
||||
if _render_as_text(stream):
|
||||
stream.write(_render(payload))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Job identity, carried ambiently so no command has to know it exists (D-263).
|
||||
|
||||
Every invocation of `reach` is a job. It gets an id, and every event emitted
|
||||
during it is tagged with that id — which is what lets a detached run's log be
|
||||
read back later, and what correlates the lines of a run that streamed for nine
|
||||
minutes.
|
||||
|
||||
**Ambient on purpose.** The alternative is threading a job through every command
|
||||
signature, or having each command open one and remember to close it. That is
|
||||
call-site discipline wearing a different hat, and it fails the same way: the
|
||||
fortieth command of a long porting session forgets, and its output silently
|
||||
loses its correlation with nothing to indicate anything is missing. `@command`
|
||||
sets this up; commands never touch it.
|
||||
|
||||
A `ContextVar` rather than a module global, because a global is only correct
|
||||
until something runs two invocations in one process — which a test harness or a
|
||||
future batch verb does immediately, and which would then interleave two jobs'
|
||||
events under one id with no error anywhere.
|
||||
|
||||
**Foreground runs get an id but no file.** Only detached runs persist (T-1277).
|
||||
Writing a log for all four gate invocations on every push would create retention
|
||||
pressure for output nobody reads. The cost is that a foreground run killed by a
|
||||
timeout loses its output — which is exactly the case that should have used
|
||||
`--detach`, so the tradeoff points the right way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from contextvars import ContextVar, Token
|
||||
|
||||
_current: ContextVar[str | None] = ContextVar("reach_job_id", default=None)
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
"""A sortable, readable job id: `20260831T134512-a3f2`.
|
||||
|
||||
Timestamp-first so `jobs list` sorts chronologically by name alone, and
|
||||
short enough to type or paste without friction. The four random hex
|
||||
characters break ties within the same second — two invocations from one
|
||||
script would otherwise collide and write to the same log.
|
||||
|
||||
**UTC, matching the `ts` field on every event.** Local time here would put a
|
||||
job id and its own log lines two hours apart, which reads as a bug in the
|
||||
logging every time someone correlates them by eye.
|
||||
"""
|
||||
return f"{time.strftime('%Y%m%dT%H%M%S', time.gmtime())}-{os.urandom(2).hex()}"
|
||||
|
||||
|
||||
def begin(job_id: str | None = None) -> Token[str | None]:
|
||||
"""Start a job context. Returns the token needed to end it."""
|
||||
return _current.set(job_id or new_id())
|
||||
|
||||
|
||||
def end(token: Token[str | None]) -> None:
|
||||
"""End a job context, restoring whatever was current before it."""
|
||||
_current.reset(token)
|
||||
|
||||
|
||||
def current() -> str | None:
|
||||
"""The id of the invocation in progress, or None outside one."""
|
||||
return _current.get()
|
||||
@@ -103,6 +103,43 @@ def check_transport_isolation(failures: list[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def check_jobs_stay_ambient(failures: list[str]) -> None:
|
||||
"""No domain imports core.jobs — job identity is the decorator's business.
|
||||
|
||||
The whole point of carrying the job ambiently is that a command never has to
|
||||
open one, tag one, or remember to close one. The moment a domain imports
|
||||
`core.jobs`, that has become call-site discipline again, and it will fail the
|
||||
same way: one command forgets and its output loses correlation silently.
|
||||
|
||||
core/ is exempt — command.py and console.py are where the ambience is
|
||||
implemented.
|
||||
"""
|
||||
for path in _package_files():
|
||||
if path.parent.name == "core":
|
||||
continue
|
||||
source = path.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
# Three forms reach the same module and all three must be caught:
|
||||
# from tooling.core import jobs -> module, names
|
||||
# from tooling.core.jobs import ... -> module
|
||||
# import tooling.core.jobs -> names
|
||||
hit = False
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
hit = node.module == "tooling.core.jobs" or (
|
||||
node.module == "tooling.core"
|
||||
and any(alias.name == "jobs" for alias in node.names)
|
||||
)
|
||||
elif isinstance(node, ast.Import):
|
||||
hit = any(alias.name == "tooling.core.jobs" for alias in node.names)
|
||||
if hit:
|
||||
failures.append(
|
||||
f"[jobs] {path.relative_to(REPO_ROOT)}:{node.lineno} imports core.jobs — "
|
||||
"job identity is ambient and belongs to @command; a command that "
|
||||
"touches it has reintroduced the call-site discipline this replaced"
|
||||
)
|
||||
|
||||
|
||||
def check_single_output_path(failures: list[str]) -> None:
|
||||
"""(2) Nothing prints but console."""
|
||||
for path in _package_files():
|
||||
@@ -229,6 +266,7 @@ def main() -> int:
|
||||
|
||||
failures: list[str] = []
|
||||
check_transport_isolation(failures)
|
||||
check_jobs_stay_ambient(failures)
|
||||
check_single_output_path(failures)
|
||||
check_commands_decorated(failures)
|
||||
check_errors_name_a_remedy(failures)
|
||||
|
||||
Reference in New Issue
Block a user