Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion agent_sys/cli/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from env_mgr.fs.domain import DomainRegistry
from env_mgr.isolation.policy import Granted, Mode, interpreter_grants
from env_mgr.isolation.probe import Availability, probe, select
from env_mgr.prefix import CLAUDE_CONFIG_ENV_VAR, Prefix
from env_mgr.protocols import Context, DomainKind, NoConfinement, Tier

__all__ = [
Expand Down Expand Up @@ -282,6 +283,47 @@ def confinement(availability: Availability | None = None) -> str:
# Credentials


def _probe_environment() -> dict[str, str]:
"""The ambient environment **plus** the o11y prefix's `CLAUDE_CONFIG_DIR`.

Gate 1 covers *agent* children; this subprocess is not one, so it dropped a
JSONL into `~/.claude/projects` every run — measured. Copied, not replaced:
a bare `env={...}` strips `PATH`, and a probe that cannot run refuses the
whole run. Never into our own `os.environ`.
"""
env = dict(os.environ)
env[CLAUDE_CONFIG_ENV_VAR] = str(Prefix.resolve(os.environ).claude_home)
return env


#: Where the probe runs. Its own directory, because AgentsView names a project
#: after the session's cwd — resolving the git *main repository* when there is
#: one — so inheriting the caller's put ten identical probe transcripts into the
#: real `infera` project. A plain directory falls back to its basename, and
#: `probe` is what these sessions are.
PROBE_DIR = "probe"


def probe_cwd(prefix: Prefix) -> Path:
return prefix.state / PROBE_DIR


def _probe_cwd_or_none(prefix: Prefix) -> str | None:
"""The probe's own directory, or `None` if we could not make one.

**A cwd is not worth failing the run for.** `preflight_credentials` aborts
everything when it fails, and a child refuses a cwd that does not exist —
so an unwritable prefix must fall back to the old behaviour, not turn a
misfiled transcript into a dead deployment.
"""
try:
cwd = probe_cwd(prefix)
cwd.mkdir(parents=True, exist_ok=True)
except OSError:
return None
return str(cwd)


def preflight_credentials(*, cli: str = BACKEND, timeout: float = 90.0) -> str:
"""Ask the backend whether it can run at all, **before any zone is built**.

Expand All @@ -293,7 +335,12 @@ def preflight_credentials(*, cli: str = BACKEND, timeout: float = 90.0) -> str:
`CredentialsMissing` carrying **stdout and stderr both** on failure.

**It does not test what the run does, and saying so is the point.** This
runs `claude -p` *unconfined*, against the operator's own config directory.
runs `claude -p` *unconfined*, against the operator's own credentials — but
not their own config directory: `CLAUDE_CONFIG_DIR` points into
`~/.infera_agent_sys` like every other `claude` child we spawn, so the
transcript lands there. Measured to keep authentication working; see
`_probe_environment`. Not the relocation the table below is about.

A confined task gets a different arm: `material.deploy` points
`CLAUDE_CONFIG_DIR` into the zone — correctly, it is what removed the `$HOME`
grant — which also moves away the `env` block in `~/.claude/settings.json`
Expand Down Expand Up @@ -330,6 +377,8 @@ def preflight_credentials(*, cli: str = BACKEND, timeout: float = 90.0) -> str:
try:
done = subprocess.run( # noqa: S603 — `binary` came from `shutil.which`
[binary, "-p", "Reply with exactly one word: ready"],
env=_probe_environment(),
cwd=_probe_cwd_or_none(Prefix.resolve(os.environ)),
capture_output=True,
text=True,
timeout=timeout,
Expand Down
13 changes: 12 additions & 1 deletion agent_sys/cli/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

__all__ = ["SCHEMA_VERSION", "Event", "EventKind"]

SCHEMA_VERSION = "1.3"
SCHEMA_VERSION = "1.4"
"""The schema of the machine-readable stream.

Criterion 14 makes this an interface: **bump it on any change to `EventKind`,
Expand Down Expand Up @@ -68,6 +68,16 @@
one that was never declared, and the difference is the whole claim the run is
making.

**1.4** — `O11Y_PANEL`. The AgentsView panel's URL, and the notice that its
binary was fetched for the first time, were `log.info` calls. **Nothing in this
repository configures `logging`**, so the root logger sits at `WARNING` with no
handler and both lines were discarded — while the o11y failure paths, being
`log.warning`, reached stderr through `logging.lastResort`. Failures were
visible and successes were not, and the tests did not notice because
`caplog.at_level("INFO")` forces the level from pytest's side. A fact the user
is meant to read belongs in the stream, which is the thing in this package
whose job is being read; `logging` here is for the operator's diary.

`docs/interfaces.md` §5.7: once the whole-system CLI wants the same stream,
two artefacts share this constant with no bump policy. That is open.
"""
Expand Down Expand Up @@ -96,6 +106,7 @@ class EventKind(str, Enum):
PERMISSIONS_DISABLED = "permissions_disabled"
ZONE_PREPARED = "zone_prepared"
ACCESS_DENIED = "access_denied"
O11Y_PANEL = "o11y_panel"

# what this run did NOT check, and why. Absent is not the same as dropped.
VALIDATION_DROPPED = "validation_dropped"
Expand Down
161 changes: 156 additions & 5 deletions agent_sys/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@

import argparse
import logging
import os
import shutil
import sys
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from contextlib import ExitStack
from pathlib import Path
from typing import Any, TextIO
Expand All @@ -46,6 +47,16 @@
from cli.render.machine import JsonLinesRenderer
from cli.stream import Stream
from env_mgr import meta
from env_mgr.o11y.agentsview import (
RECIPE_PATH,
freshly_installed,
ensure_installed,
ensure_run_project,
ensure_running,
pinned_version,
resolve_port,
)
from env_mgr.prefix import Prefix
from env_mgr.prepare import EnvManager, permissions_enforced
from env_mgr.protocols import NoConfinement, PrepareRefused, UnresolvedGrant
from env_mgr.remote.connection import sync_transport
Expand Down Expand Up @@ -173,6 +184,21 @@ def parser() -> argparse.ArgumentParser:
"ends in seconds regardless; this only bounds one that never stops"
),
)
run.add_argument(
"--agentsview-port",
type=int,
default=None,
metavar="N",
help=(
"port for the AgentsView o11y panel (default 18888; "
"a port already in use is a warning and a skip)"
),
)
run.add_argument(
"--no-agentsview",
action="store_true",
help="do not start the AgentsView o11y panel",
)
return top


Expand Down Expand Up @@ -209,7 +235,16 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
if args.verb == "show":
return _show(args, stream)
return _run(args, stream)
# The one call site: the daemon outlives the run, so it starts
# once per invocation and its result never reaches the exit code.
# Not for `--dry-run` (whose contract is *resolve everything, do
# nothing*) or `--clean` (which deletes every run and exits).
panel_url = _start_o11y(
args.agentsview_port,
disabled=args.no_agentsview or args.dry_run or args.clean,
stream=stream,
)
return _run(args, stream, panel_url)
except package.PackageNotFound as exc:
return _fail(stream, PRECONDITION, str(exc))
except SpecInvalid as exc:
Expand All @@ -226,6 +261,110 @@ def main(argv: Sequence[str] | None = None) -> int:
return UNEXPECTED_FAILURE # pragma: no cover — ExitStack always returns above


def _install_item(prefix: Prefix) -> Callable[[], Sequence[Any]]:

@dorado269 dorado269 Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this func should live in env_mgr, utils sub folder or somewhere

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not moved, and I would rather say why than move it quietly. _install_item exists in cli/ because of the wall it would cross.

env_mgr spec §9 forbids anything under env_mgr/ from importing recipe, runner or installers, and tests/env_mgr/test_imports.py enforces it structurally — it derives the "above" set from the filesystem and walks it with rglob, with env_mgr/cli.py the single exemption. _install_item's whole body is load_recipe + runner.run, so putting it anywhere under env_mgr/o11y/ fails that test. A first draft of ensure_installed did exactly this and failed exactly that test, which is why the function is injected as a zero-argument callable rather than looked up.

So there are three options and I do not think it is mine to pick:

  1. Leave it in cli/main.py (today). The wall stays intact; the cost is that one o11y-shaped function lives with the CLI.
  2. Move it to env_mgr/cli.py, the one module already exempt. Legal, but that file is env_mgr's own command-line entry point, not a utility home — it would be there for the exemption rather than because it belongs.
  3. Widen the exemption to a new env_mgr/o11y/install.py. Cleanest to read, and it weakens a guard that was put there deliberately — the test's own docstring records a case where a stale hand-written list let a module go unchecked.

Happy to do 2 or 3 if you have a preference; I did not want to weaken the wall on my own judgement.

"""The recipe call `ensure_installed` injects rather than performs.

**This is o11y-shaped code living in `cli/`, and review asked why. It is
here because it cannot be under `env_mgr/`.** Spec §9 walls `recipe`,
`runner` and `installers` off from every module there, and
`tests/env_mgr/test_imports.py` enforces it structurally — it derives the
"above the wall" set from the filesystem and walks it with `rglob`, so a new
subpackage is covered the moment it exists, with `env_mgr/cli.py` the single
exemption. This function's whole body is `load_recipe` + `runner.run`, so
any home under `env_mgr/o11y/` fails that test. A first draft of
`ensure_installed` did exactly that and failed exactly that test, which is
why it takes an injected callable rather than looking the recipe up itself.

Moving it would mean either putting it in `env_mgr/cli.py` — legal, but that
is env_mgr's command-line entry point and it would be there for the
exemption rather than because it belongs — or widening the exemption, which
weakens a guard whose own docstring records a module going unchecked when
the list was maintained by hand. Neither is this function's call to make.

**Zero-argument, not a precomputed list**: a list evaluated at the call site
would run the installer before `--dry-run` could stop it. `target.path` is
overridden because the checked-in recipe's value is a placeholder — nothing
in `env_mgr` expands `${VAR}` in a YAML value.
"""

def call() -> Sequence[Any]:
from env_mgr.recipe import load_recipe
from env_mgr.runner import Filters, run

target, items = load_recipe(RECIPE_PATH)
target.path = str(prefix.root)
outs, _status = run(target, items, "install", Filters(item="agentsview"))
return outs

return call


def _start_o11y(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this func should live in env_mgr/o11y

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed in principle, not done in this push — it is a refactor rather than a file move, and I would rather you saw the shape first.

_start_o11y is bound to cli/ at two points, and only one of them is incidental:

  • it calls _install_item(prefix), which cannot leave cli/ for the reason in the thread above (it crosses the decoupling wall);
  • it emits EventKind.O11Y_PANEL on the Stream, so moving it as-is would have env_mgr importing from cli — a library importing its own consumer, which is worse than the thing being fixed.

The honest version is start_panel(prefix, port_flag, install_item, announce) in o11y/agentsview/, where the caller supplies both the wall-crossing closure and an announce(message, **fields) callback, and cli/main.py keeps a four-line adapter. That is a good shape. It also re-points about ten tests that currently patch cli_main.ensure_installed / cli_main.ensure_running — module-level patches that stop reaching the code once it lives elsewhere — so it is a change I would want to make deliberately rather than fold into a review-fix push.

Say the word and I will do it as its own commit. If you would rather keep the blast radius small for now, the other six comments are addressed in e1490385 and this one can be a follow-up.

port_flag: int | None, disabled: bool, stream: Stream | None = None
) -> str | None:
"""The one call site. Returns the panel URL, or None, and never raises.

**Also o11y-shaped code in `cli/`, and also deliberate.** Review asked for
it to live in `env_mgr/o11y/`, and the destination is right; the move is a
refactor rather than a relocation, because this function is tied to `cli/`
at two points. It calls `_install_item`, which cannot leave (see there). And
it emits on the `Stream`, so moving it as written would have `env_mgr`
importing `cli` — a library importing its own consumer, which is a worse
inversion than the one being fixed. The honest shape is
`start_panel(prefix, port_flag, install_item, announce)` in the o11y package
with a four-line adapter here, and it re-points the ten or so tests that
patch `cli_main.ensure_installed` / `cli_main.ensure_running` at module
level. Worth doing on its own, not folded into a review-fix.

**The bare `except Exception` is the point**: everything inside
`ensure_running` already degrades to a warning, and this catches what that
module has not thought of. A side-car that can abort a run is a worse bug
than a missing panel.

**Success goes to the `stream`, failure to `logging`.** Both were
`log.info`, and this package never configures `logging` — so the root
logger sits at `WARNING` with no handler and they reached nobody, while the
warnings still reached stderr through `lastResort`. `stream` is optional
because the failure-mode tests are not about it; `main` always passes one.

`os.environ` is read here and never written.
"""
if disabled:
return None

def say(message: str, **fields: Any) -> None:
if stream is not None:
stream.emit(EventKind.O11Y_PANEL, message, **fields)

try:
prefix = Prefix.resolve(os.environ)
installed = ensure_installed(prefix, _install_item(prefix))
if not installed.running:
# `ensure_installed` has already logged the one warning. Starting a
# daemon whose binary is absent would only add a second.
return None
if freshly_installed(installed.reason):
# Only on the run that downloaded: a line on every run is how a
# real warning gets scrolled past. Says what arrived and where,
# because a 45 MB download nobody asked for should be inspectable.
version = pinned_version()
path = str(prefix.bin / "agentsview")
message = (
f"fetched the o11y panel binary (agentsview v{version}, "
f"from github.com/kenn-io/agentsview) into {path}"
)
log.info("agentsview: %s", message)
say(message, version=version, path=path, installed=True)
status = ensure_running(prefix, port=resolve_port(port_flag, os.environ))
if status.running:
log.info("agentsview: o11y panel at %s", status.url)
say(f"panel at {status.url}", url=status.url)
return status.url
except Exception as e: # noqa: BLE001
log.warning("agentsview: o11y start-up failed (%s); continuing without a panel.", e)
return None


def _fail(stream: Stream, code: int, message: str, *, kind: EventKind | None = None) -> int:
stream.emit(kind or EventKind.RUN_COMPLETE, message, exit_code=code, ok=False)
return code
Expand Down Expand Up @@ -259,12 +398,12 @@ def _show(args: argparse.Namespace, stream: Stream) -> int:
# run


def _run(args: argparse.Namespace, stream: Stream) -> int:
def _run(args: argparse.Namespace, stream: Stream, panel_url: str | None = None) -> int:
if args.clean:
return _clean(args, stream)
if args.dry_run:
return _dry_run(args, stream)
return _real_run(args, stream)
return _real_run(args, stream, panel_url)


def _clean(args: argparse.Namespace, stream: Stream) -> int:
Expand Down Expand Up @@ -326,7 +465,7 @@ def _layout(args: argparse.Namespace) -> Layout:
return layout_for(root).create()


def _real_run(args: argparse.Namespace, stream: Stream) -> int:
def _real_run(args: argparse.Namespace, stream: Stream, panel_url: str | None = None) -> int:
"""Everything. Needs credentials, a sandbox, and a model.

The order of the two preconditions is measured rather than aesthetic: the
Expand All @@ -351,6 +490,18 @@ def _real_run(args: argparse.Namespace, stream: Stream) -> int:
promises = expectations.for_package(package.locate(args.package))

layout = _layout(args)
# **Here, and not in `_start_o11y`, because the run id does not exist yet
# when the panel starts.** Before any task runs, so the mapping is in place
# before the first transcript is ingested -- measured: a mapping that
# exists at ingest labels the session at sync time, with no second call.
mapped = ensure_run_project(panel_url, layout.run)
if mapped.running:
stream.emit(
EventKind.O11Y_PANEL,
f"this run is project {mapped.reason!r} on the panel",
project=mapped.reason,
run=str(layout.run),
)
root = package.locate(args.package)
# **Read once, at start-up, and it is the run's fact rather than a task's.**
# `env_mgr.prepare.permissions_enforced()` is the single reader of the
Expand Down
1 change: 1 addition & 0 deletions agent_sys/cli/render/human.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
EventKind.PERMISSIONS_DISABLED: "NO SANDBOX",
EventKind.VALIDATION_DROPPED: "DROPPED",
EventKind.ZONE_PREPARED: "zone",
EventKind.O11Y_PANEL: "o11y",
EventKind.ACCESS_DENIED: " denied",
EventKind.TASK_DISPATCHED: "dispatch",
EventKind.PHASE_START: " phase",
Expand Down
Loading