cli-agent-runner 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_runner/__init__.py +3 -0
- agent_runner/_docgen.py +200 -0
- agent_runner/_version.py +24 -0
- agent_runner/agent_runtime.py +127 -0
- agent_runner/api.py +331 -0
- agent_runner/api_types.py +111 -0
- agent_runner/cli/__init__.py +76 -0
- agent_runner/cli/__main__.py +3 -0
- agent_runner/cli/common.py +78 -0
- agent_runner/cli/init_cmd.py +31 -0
- agent_runner/cli/install_cmd.py +44 -0
- agent_runner/cli/monitor_cmd.py +48 -0
- agent_runner/cli/peek_cmd.py +81 -0
- agent_runner/cli/round_cmd.py +17 -0
- agent_runner/cli/serve_cmd.py +60 -0
- agent_runner/cli/service_cmd.py +54 -0
- agent_runner/config.py +92 -0
- agent_runner/context_store.py +117 -0
- agent_runner/critic.py +33 -0
- agent_runner/defenses.py +111 -0
- agent_runner/events.py +53 -0
- agent_runner/lifecycle.py +67 -0
- agent_runner/metrics.py +69 -0
- agent_runner/monitor.py +515 -0
- agent_runner/prompt_loader.py +44 -0
- agent_runner/round_view.py +86 -0
- agent_runner/runner.py +236 -0
- agent_runner/scaffold.py +124 -0
- agent_runner/service_unit.py +74 -0
- agent_runner/startup_check.py +132 -0
- agent_runner/vcs_state.py +222 -0
- cli_agent_runner-0.1.0.dist-info/METADATA +150 -0
- cli_agent_runner-0.1.0.dist-info/RECORD +36 -0
- cli_agent_runner-0.1.0.dist-info/WHEEL +4 -0
- cli_agent_runner-0.1.0.dist-info/entry_points.txt +2 -0
- cli_agent_runner-0.1.0.dist-info/licenses/LICENSE +202 -0
agent_runner/__init__.py
ADDED
agent_runner/_docgen.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Documentation generator — replaces <!-- gen:NAME --> ... <!-- /gen:NAME -->
|
|
2
|
+
content blocks in docs/*.md from registered renderers.
|
|
3
|
+
|
|
4
|
+
The marker primitive `replace_block` is intentionally separate from the
|
|
5
|
+
renderer registry so the substitution rule is testable in isolation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import re
|
|
12
|
+
import typing
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from agent_runner.config import (
|
|
17
|
+
AgentConfig,
|
|
18
|
+
Config,
|
|
19
|
+
PromptConfig,
|
|
20
|
+
RuntimeConfig,
|
|
21
|
+
VcsConfig,
|
|
22
|
+
)
|
|
23
|
+
from agent_runner.defenses import catalog
|
|
24
|
+
from agent_runner.events import KNOWN_EVENT_KINDS
|
|
25
|
+
from agent_runner.monitor import AUTO_STOP_ALERTS, KNOWN_ALERT_KINDS
|
|
26
|
+
|
|
27
|
+
_SECTIONS = [
|
|
28
|
+
("agent", AgentConfig),
|
|
29
|
+
("runtime", RuntimeConfig),
|
|
30
|
+
("prompt", PromptConfig),
|
|
31
|
+
("vcs", VcsConfig),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _type_label(t: typing.Any) -> str:
|
|
36
|
+
# dataclasses.fields exposes `.type` as a string (PEP 563), so we render
|
|
37
|
+
# the raw annotation. Strip ``Path`` / ``Path | None`` wrappers cosmetically.
|
|
38
|
+
s = str(t).replace("pathlib.", "")
|
|
39
|
+
return s
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _default_label(field: dataclasses.Field) -> str:
|
|
43
|
+
if field.default is not dataclasses.MISSING:
|
|
44
|
+
return repr(field.default)
|
|
45
|
+
if field.default_factory is not dataclasses.MISSING: # type: ignore[misc]
|
|
46
|
+
return repr(field.default_factory())
|
|
47
|
+
return "—"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_config_schema_table() -> str:
|
|
51
|
+
"""Markdown sub-sections per Config dataclass with field/type/default."""
|
|
52
|
+
parts: list[str] = []
|
|
53
|
+
for name, dc in _SECTIONS:
|
|
54
|
+
parts.append(f"### `[{name}]`")
|
|
55
|
+
parts.append("")
|
|
56
|
+
parts.append("| Field | Type | Default |")
|
|
57
|
+
parts.append("|---|---|---|")
|
|
58
|
+
for f in dataclasses.fields(dc):
|
|
59
|
+
parts.append(f"| `{f.name}` | `{_type_label(f.type)}` | {_default_label(f)} |")
|
|
60
|
+
parts.append("")
|
|
61
|
+
return "\n".join(parts).rstrip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def replace_block(text: str, name: str, new_content: str) -> str:
|
|
65
|
+
"""Replace the body between ``<!-- gen:NAME -->`` and ``<!-- /gen:NAME -->``.
|
|
66
|
+
|
|
67
|
+
The opening / closing markers themselves are preserved. Returns the
|
|
68
|
+
original text unchanged when the opening marker is absent. Raises
|
|
69
|
+
``ValueError`` when the opening marker is present without a matching close.
|
|
70
|
+
"""
|
|
71
|
+
open_tag = f"<!-- gen:{name} -->"
|
|
72
|
+
close_tag = f"<!-- /gen:{name} -->"
|
|
73
|
+
if open_tag not in text:
|
|
74
|
+
return text
|
|
75
|
+
if close_tag not in text:
|
|
76
|
+
raise ValueError(f"<!-- gen:{name} --> has no matching close tag")
|
|
77
|
+
pattern = re.compile(
|
|
78
|
+
re.escape(open_tag) + r".*?" + re.escape(close_tag),
|
|
79
|
+
re.DOTALL,
|
|
80
|
+
)
|
|
81
|
+
return pattern.sub(f"{open_tag}\n{new_content}\n{close_tag}", text)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _default_cfg() -> Config:
|
|
85
|
+
"""Build a default Config for doc rendering — defaults only, no user values."""
|
|
86
|
+
return Config(
|
|
87
|
+
agent=AgentConfig(command=["agent"], prompt_arg_template=[]),
|
|
88
|
+
runtime=RuntimeConfig(
|
|
89
|
+
work_dir=Path("."),
|
|
90
|
+
log_dir=Path("./logs"),
|
|
91
|
+
),
|
|
92
|
+
prompt=PromptConfig(file=Path("./prompt.md")),
|
|
93
|
+
vcs=VcsConfig(),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_defenses_table() -> str:
|
|
98
|
+
"""Markdown table of the defense catalog. Renders defaults only."""
|
|
99
|
+
cfg = _default_cfg()
|
|
100
|
+
lines = [
|
|
101
|
+
"| Defense | Codifies | Guarded by |",
|
|
102
|
+
"|---|---|---|",
|
|
103
|
+
]
|
|
104
|
+
for d in catalog(cfg):
|
|
105
|
+
codifies = d.codifies or "—"
|
|
106
|
+
guarded = str(d.guarded_by) if d.guarded_by is not None else "—"
|
|
107
|
+
lines.append(f"| `{d.name}` | {codifies} | `{guarded}` |")
|
|
108
|
+
return "\n".join(lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def render_alert_kinds_list() -> str:
|
|
112
|
+
"""Flat bullet list of all known alert kinds, alphabetised."""
|
|
113
|
+
return "\n".join(f"- `{k}`" for k in sorted(KNOWN_ALERT_KINDS))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_detector_list() -> str:
|
|
117
|
+
"""Bullet list of detectors; auto-stop kinds flagged inline."""
|
|
118
|
+
lines: list[str] = []
|
|
119
|
+
for k in sorted(KNOWN_ALERT_KINDS):
|
|
120
|
+
suffix = " — **auto-stop**" if k in AUTO_STOP_ALERTS else ""
|
|
121
|
+
lines.append(f"- `{k}`{suffix}")
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def render_event_kinds_list() -> str:
|
|
126
|
+
"""Flat bullet list of all known event kinds, alphabetised."""
|
|
127
|
+
return "\n".join(f"- `{k}`" for k in sorted(KNOWN_EVENT_KINDS))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def render_verb_table() -> str:
|
|
131
|
+
"""Walk the argparse subparsers and render a verb table."""
|
|
132
|
+
from agent_runner.cli import _build_parser
|
|
133
|
+
|
|
134
|
+
parser = _build_parser()
|
|
135
|
+
# Find the sub-parsers action — there's exactly one.
|
|
136
|
+
sub_action = next(a for a in parser._actions if a.__class__.__name__ == "_SubParsersAction")
|
|
137
|
+
rows = [
|
|
138
|
+
"| Verb | Description |",
|
|
139
|
+
"|---|---|",
|
|
140
|
+
]
|
|
141
|
+
for verb, _sp in sub_action.choices.items():
|
|
142
|
+
# Argparse stores help text via `sub_action._choices_actions` indexed by add order.
|
|
143
|
+
help_text = (
|
|
144
|
+
next(
|
|
145
|
+
(c.help for c in sub_action._choices_actions if c.dest == verb),
|
|
146
|
+
"",
|
|
147
|
+
)
|
|
148
|
+
or ""
|
|
149
|
+
)
|
|
150
|
+
rows.append(f"| `{verb}` | {help_text} |")
|
|
151
|
+
return "\n".join(rows)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
RENDERERS: dict[str, Callable[[], str]] = {
|
|
155
|
+
"defenses-table": render_defenses_table,
|
|
156
|
+
"alert-kinds": render_alert_kinds_list,
|
|
157
|
+
"detector-list": render_detector_list,
|
|
158
|
+
"event-kinds": render_event_kinds_list,
|
|
159
|
+
"config-schema": render_config_schema_table,
|
|
160
|
+
"verb-table": render_verb_table,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
_GEN_OPEN = re.compile(r"<!-- gen:([a-z0-9-]+) -->")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def render(docs_dir: Path, *, write: bool = True) -> dict[Path, str]:
|
|
167
|
+
"""Render every ``<!-- gen:NAME -->`` block in ``docs_dir/*.md``.
|
|
168
|
+
|
|
169
|
+
Returns a {path: rendered_text} mapping. When ``write=True`` also writes
|
|
170
|
+
the rewritten text back to each path.
|
|
171
|
+
|
|
172
|
+
Raises ``ValueError`` when a marker references an unknown renderer name.
|
|
173
|
+
"""
|
|
174
|
+
out: dict[Path, str] = {}
|
|
175
|
+
for md in sorted(docs_dir.glob("*.md")):
|
|
176
|
+
text = md.read_text(encoding="utf-8")
|
|
177
|
+
for match in _GEN_OPEN.finditer(text):
|
|
178
|
+
name = match.group(1)
|
|
179
|
+
if name not in RENDERERS:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"{md.name}: unknown gen marker {name!r} — valid names: {sorted(RENDERERS)}"
|
|
182
|
+
)
|
|
183
|
+
try:
|
|
184
|
+
text = replace_block(text, name, RENDERERS[name]())
|
|
185
|
+
except ValueError as e:
|
|
186
|
+
raise ValueError(f"{md.name}: {e}") from e
|
|
187
|
+
out[md] = text
|
|
188
|
+
if write:
|
|
189
|
+
md.write_text(text, encoding="utf-8")
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> int:
|
|
194
|
+
repo_root = Path(__file__).resolve().parent.parent
|
|
195
|
+
render(docs_dir=repo_root / "docs")
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__": # pragma: no cover
|
|
200
|
+
raise SystemExit(main())
|
agent_runner/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Agent subprocess management — ONLY module that spawns the claude CLI.
|
|
2
|
+
|
|
3
|
+
Defenses encoded here:
|
|
4
|
+
- R725: SIGTERM handler reaps process group before runner exits
|
|
5
|
+
- R1128: ROUND_TIMEOUT is wall-clock hard wall (no activity-based extension)
|
|
6
|
+
- #307: start_new_session=True isolates subprocess in its own pgrp
|
|
7
|
+
- env injection: DISABLE_AUTOUPDATER=1 + CLAUDE_CODE_EFFORT_LEVEL caller-provided
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess # noqa: TID251 — sanctioned subprocess caller
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
REAP_GRACE_S = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RunResult:
|
|
25
|
+
exit_code: int
|
|
26
|
+
duration_s: float
|
|
27
|
+
timed_out: bool
|
|
28
|
+
pid: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_argv(command: list[str], prompt_arg_template: list[str], prompt: str) -> list[str]:
|
|
32
|
+
"""Build full argv: command + prompt args (with {prompt} substituted)."""
|
|
33
|
+
return list(command) + [a.replace("{prompt}", prompt) for a in prompt_arg_template]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _kill_pgroup(proc: subprocess.Popen) -> None:
|
|
37
|
+
pgid = proc.pid
|
|
38
|
+
try:
|
|
39
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
40
|
+
except OSError:
|
|
41
|
+
pass
|
|
42
|
+
deadline = time.time() + REAP_GRACE_S
|
|
43
|
+
while time.time() < deadline and proc.poll() is None:
|
|
44
|
+
time.sleep(0.1)
|
|
45
|
+
try:
|
|
46
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
try:
|
|
50
|
+
proc.wait(timeout=10)
|
|
51
|
+
except subprocess.TimeoutExpired:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run(
|
|
56
|
+
*,
|
|
57
|
+
command: list[str],
|
|
58
|
+
prompt_arg_template: list[str],
|
|
59
|
+
prompt: str,
|
|
60
|
+
timeout_s: int,
|
|
61
|
+
log_path: Path,
|
|
62
|
+
env_extra: dict[str, str],
|
|
63
|
+
) -> RunResult:
|
|
64
|
+
"""Spawn the agent subprocess and wait for exit or timeout.
|
|
65
|
+
|
|
66
|
+
Wall-clock timeout (R1128). On timeout: SIGTERM pgroup → REAP_GRACE_S → SIGKILL.
|
|
67
|
+
"""
|
|
68
|
+
argv = _build_argv(command, prompt_arg_template, prompt)
|
|
69
|
+
env = {**os.environ, **env_extra}
|
|
70
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
log_file = log_path.open("w", encoding="utf-8")
|
|
72
|
+
start = time.time()
|
|
73
|
+
proc = subprocess.Popen(
|
|
74
|
+
argv,
|
|
75
|
+
env=env,
|
|
76
|
+
stdin=subprocess.DEVNULL,
|
|
77
|
+
stdout=log_file,
|
|
78
|
+
stderr=subprocess.STDOUT,
|
|
79
|
+
start_new_session=True,
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
ret = proc.poll()
|
|
84
|
+
now = time.time()
|
|
85
|
+
if ret is not None:
|
|
86
|
+
duration = now - start
|
|
87
|
+
return RunResult(exit_code=ret, duration_s=duration, timed_out=False, pid=proc.pid)
|
|
88
|
+
if now - start > timeout_s:
|
|
89
|
+
_kill_pgroup(proc)
|
|
90
|
+
duration = time.time() - start
|
|
91
|
+
exit_code = proc.returncode if proc.returncode is not None else -1
|
|
92
|
+
return RunResult(
|
|
93
|
+
exit_code=exit_code, duration_s=duration, timed_out=True, pid=proc.pid
|
|
94
|
+
)
|
|
95
|
+
time.sleep(0.2)
|
|
96
|
+
finally:
|
|
97
|
+
log_file.close()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
CRITICAL_ENV_DEFAULTS: dict[str, str] = {
|
|
101
|
+
"DISABLE_AUTOUPDATER": "1", # do not let claude self-update mid-loop
|
|
102
|
+
"CLAUDE_CODE_EFFORT_LEVEL": "xhigh", # full effort, not default
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def merge_critical_envs(user_env: dict[str, str]) -> dict[str, str]:
|
|
107
|
+
"""Merge user env with CRITICAL_ENV_DEFAULTS — critical always wins."""
|
|
108
|
+
merged = dict(user_env)
|
|
109
|
+
merged.update(CRITICAL_ENV_DEFAULTS)
|
|
110
|
+
return merged
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def install_sigterm_reaper(reaper: Callable[[], None]) -> object:
|
|
114
|
+
"""Install a SIGTERM handler that calls ``reaper()`` first.
|
|
115
|
+
|
|
116
|
+
R725 defense: when supervisor receives SIGTERM (e.g. systemctl stop, manual
|
|
117
|
+
kill), bash wrapper would otherwise respawn fresh runner while old claude
|
|
118
|
+
keeps running → two claudes race on the same git tree, second commit can
|
|
119
|
+
swallow first commit's chat-room entry. Reaper terminates pgroup first.
|
|
120
|
+
|
|
121
|
+
Returns the previous SIGTERM handler so caller can restore it.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def _handler(_signum: int, _frame: object) -> None:
|
|
125
|
+
reaper()
|
|
126
|
+
|
|
127
|
+
return signal.signal(signal.SIGTERM, _handler)
|
agent_runner/api.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Public Python API mirroring CLI verbs.
|
|
2
|
+
|
|
3
|
+
Every CLI subcommand has a corresponding api function. CLI files do
|
|
4
|
+
``api.X(...)`` and format the returned dataclass for display. External
|
|
5
|
+
agents (Phase 3 outer Claude Code) can `from agent_runner import api`
|
|
6
|
+
and skip CLI text parsing entirely.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess # noqa: TID251 — api uses systemctl + ssh, both subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from agent_runner import lifecycle
|
|
20
|
+
from agent_runner.api_types import (
|
|
21
|
+
InitResult,
|
|
22
|
+
InstallResult,
|
|
23
|
+
ProjectState,
|
|
24
|
+
ServiceMode,
|
|
25
|
+
ServiceStatus,
|
|
26
|
+
select_path,
|
|
27
|
+
)
|
|
28
|
+
from agent_runner.config import load_config
|
|
29
|
+
from agent_runner.lifecycle import (
|
|
30
|
+
PIDFile,
|
|
31
|
+
detect_service_mode,
|
|
32
|
+
pid_alive,
|
|
33
|
+
send_signal_to_pid,
|
|
34
|
+
)
|
|
35
|
+
from agent_runner.scaffold import scaffold_project
|
|
36
|
+
from agent_runner.service_unit import (
|
|
37
|
+
monitor_unit_filename,
|
|
38
|
+
render_monitor_unit,
|
|
39
|
+
render_serve_unit,
|
|
40
|
+
serve_unit_filename,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _project_name(work_dir: Path) -> str:
|
|
45
|
+
return work_dir.resolve().name or "default"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _log_dir(work_dir: Path) -> Path:
|
|
49
|
+
"""Return the configured log_dir from agent-runner.toml.
|
|
50
|
+
|
|
51
|
+
Falls back to the conventional ~/.agent-runner/<project>/logs only when
|
|
52
|
+
the toml is missing. This keeps `api.status` / `api.stop` aligned with
|
|
53
|
+
where `serve_cmd.py` actually writes serve.pid.
|
|
54
|
+
"""
|
|
55
|
+
cfg_path = work_dir / "agent-runner.toml"
|
|
56
|
+
if cfg_path.exists():
|
|
57
|
+
return load_config(cfg_path).runtime.log_dir
|
|
58
|
+
return Path.home() / ".agent-runner" / _project_name(work_dir) / "logs"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _venv_bin() -> Path:
|
|
62
|
+
"""Where this Python interpreter lives — for ExecStart."""
|
|
63
|
+
return Path(sys.executable).parent
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _systemctl_user(*args: str) -> None:
|
|
67
|
+
subprocess.run(["systemctl", "--user", *args], check=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# init / install / uninstall
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def init(work_dir: Path | None = None, *, force: bool = False, commit: bool = True) -> InitResult:
|
|
75
|
+
if work_dir is None:
|
|
76
|
+
work_dir = Path.cwd()
|
|
77
|
+
return scaffold_project(work_dir, force=force, commit=commit)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def install(
|
|
81
|
+
work_dir: Path | None = None, *, system: bool = False, with_monitor: bool = False
|
|
82
|
+
) -> InstallResult:
|
|
83
|
+
if work_dir is None:
|
|
84
|
+
work_dir = Path.cwd()
|
|
85
|
+
if system:
|
|
86
|
+
raise NotImplementedError("--system install not yet implemented in Phase 2")
|
|
87
|
+
cfg_path = work_dir / "agent-runner.toml"
|
|
88
|
+
cfg = load_config(cfg_path)
|
|
89
|
+
project = _project_name(work_dir)
|
|
90
|
+
|
|
91
|
+
units_dir = lifecycle._user_systemd_dir()
|
|
92
|
+
units_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
serve_path = units_dir / serve_unit_filename(project)
|
|
95
|
+
serve_path.write_text(render_serve_unit(cfg, venv_bin=_venv_bin()))
|
|
96
|
+
|
|
97
|
+
monitor_path: Path | None = None
|
|
98
|
+
if with_monitor:
|
|
99
|
+
monitor_path = units_dir / monitor_unit_filename(project)
|
|
100
|
+
monitor_path.write_text(render_monitor_unit(cfg, venv_bin=_venv_bin()))
|
|
101
|
+
|
|
102
|
+
_systemctl_user("daemon-reload")
|
|
103
|
+
_systemctl_user("enable", serve_unit_filename(project))
|
|
104
|
+
_systemctl_user("start", serve_unit_filename(project))
|
|
105
|
+
if with_monitor:
|
|
106
|
+
_systemctl_user("enable", monitor_unit_filename(project))
|
|
107
|
+
_systemctl_user("start", monitor_unit_filename(project))
|
|
108
|
+
|
|
109
|
+
return InstallResult(
|
|
110
|
+
unit_path=serve_path, monitor_unit_path=monitor_path, enabled=True, started=True
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def uninstall(work_dir: Path | None = None) -> bool:
|
|
115
|
+
if work_dir is None:
|
|
116
|
+
work_dir = Path.cwd()
|
|
117
|
+
project = _project_name(work_dir)
|
|
118
|
+
units_dir = lifecycle._user_systemd_dir()
|
|
119
|
+
serve = units_dir / serve_unit_filename(project)
|
|
120
|
+
monitor = units_dir / monitor_unit_filename(project)
|
|
121
|
+
for p in (serve, monitor):
|
|
122
|
+
if p.exists():
|
|
123
|
+
_systemctl_user("stop", p.name)
|
|
124
|
+
_systemctl_user("disable", p.name)
|
|
125
|
+
p.unlink(missing_ok=True)
|
|
126
|
+
_systemctl_user("daemon-reload")
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Lifecycle: start / stop / kill / cancel / restart / status
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def start(project: str | Path) -> ServiceStatus:
|
|
135
|
+
pname = _resolve_project(project)
|
|
136
|
+
log_dir = _log_dir_for_project(project)
|
|
137
|
+
mode = detect_service_mode(pname, log_dir=log_dir)
|
|
138
|
+
if mode == ServiceMode.SYSTEMD_USER:
|
|
139
|
+
_systemctl_user("start", serve_unit_filename(pname))
|
|
140
|
+
return status(project)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def stop(project: str | Path) -> ServiceStatus:
|
|
144
|
+
pname = _resolve_project(project)
|
|
145
|
+
log_dir = _log_dir_for_project(project)
|
|
146
|
+
mode = detect_service_mode(pname, log_dir=log_dir)
|
|
147
|
+
if mode == ServiceMode.SYSTEMD_USER:
|
|
148
|
+
_systemctl_user("stop", serve_unit_filename(pname))
|
|
149
|
+
return status(project)
|
|
150
|
+
pid = PIDFile(log_dir / "serve.pid").read()
|
|
151
|
+
if pid is not None:
|
|
152
|
+
send_signal_to_pid(pid, signal.SIGTERM)
|
|
153
|
+
return status(project)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def kill(project: str | Path) -> ServiceStatus:
|
|
157
|
+
pname = _resolve_project(project)
|
|
158
|
+
log_dir = _log_dir_for_project(project)
|
|
159
|
+
mode = detect_service_mode(pname, log_dir=log_dir)
|
|
160
|
+
if mode == ServiceMode.SYSTEMD_USER:
|
|
161
|
+
_systemctl_user("kill", "--signal=SIGTERM", serve_unit_filename(pname))
|
|
162
|
+
return status(project)
|
|
163
|
+
pid = PIDFile(log_dir / "serve.pid").read()
|
|
164
|
+
if pid is None:
|
|
165
|
+
return status(project)
|
|
166
|
+
send_signal_to_pid(pid, signal.SIGTERM)
|
|
167
|
+
deadline = time.time() + 5
|
|
168
|
+
alive = True
|
|
169
|
+
while time.time() < deadline:
|
|
170
|
+
alive = pid_alive(pid)
|
|
171
|
+
if not alive:
|
|
172
|
+
break
|
|
173
|
+
time.sleep(0.1)
|
|
174
|
+
if alive:
|
|
175
|
+
send_signal_to_pid(pid, signal.SIGKILL)
|
|
176
|
+
return ServiceStatus(mode=ServiceMode.PID_FILE, active=alive, pid=pid)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cancel(project: str | Path) -> bool:
|
|
180
|
+
pname = _resolve_project(project)
|
|
181
|
+
log_dir = _log_dir_for_project(project)
|
|
182
|
+
mode = detect_service_mode(pname, log_dir=log_dir)
|
|
183
|
+
if mode == ServiceMode.SYSTEMD_USER:
|
|
184
|
+
_systemctl_user("kill", "--signal=SIGUSR1", serve_unit_filename(pname))
|
|
185
|
+
return True
|
|
186
|
+
pid = PIDFile(log_dir / "serve.pid").read()
|
|
187
|
+
if pid is None:
|
|
188
|
+
return False
|
|
189
|
+
return send_signal_to_pid(pid, signal.SIGUSR1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def restart(project: str | Path, *, force: bool = False) -> ServiceStatus:
|
|
193
|
+
if force:
|
|
194
|
+
kill(project)
|
|
195
|
+
else:
|
|
196
|
+
stop(project)
|
|
197
|
+
return start(project)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def status(project: str | Path) -> ServiceStatus:
|
|
201
|
+
pname = _resolve_project(project)
|
|
202
|
+
log_dir = _log_dir_for_project(project)
|
|
203
|
+
mode = detect_service_mode(pname, log_dir=log_dir)
|
|
204
|
+
if mode == ServiceMode.PID_FILE:
|
|
205
|
+
pid = PIDFile(log_dir / "serve.pid").read()
|
|
206
|
+
return ServiceStatus(mode=mode, active=pid is not None and pid_alive(pid), pid=pid)
|
|
207
|
+
if mode == ServiceMode.SYSTEMD_USER:
|
|
208
|
+
unit = lifecycle._user_systemd_dir() / serve_unit_filename(pname)
|
|
209
|
+
return ServiceStatus(mode=mode, active=True, unit_file=unit)
|
|
210
|
+
return ServiceStatus(mode=ServiceMode.NONE, active=False)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _resolve_project(project: str | Path) -> str:
|
|
214
|
+
if isinstance(project, Path):
|
|
215
|
+
return _project_name(project)
|
|
216
|
+
if "/" in project or "\\" in project:
|
|
217
|
+
return _project_name(Path(project))
|
|
218
|
+
return project
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _log_dir_for_project(project: str | Path) -> Path:
|
|
222
|
+
if isinstance(project, Path):
|
|
223
|
+
return _log_dir(project)
|
|
224
|
+
p = Path.cwd() if project == _project_name(Path.cwd()) else None
|
|
225
|
+
if p is not None:
|
|
226
|
+
return _log_dir(p)
|
|
227
|
+
return Path.home() / ".agent-runner" / project / "logs"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Observation: peek / monitor_loop / _poll_once
|
|
232
|
+
#
|
|
233
|
+
# Imported lazily to avoid pulling monitor + defenses at module load time
|
|
234
|
+
# for callers that only use lifecycle verbs.
|
|
235
|
+
|
|
236
|
+
from agent_runner import defenses, monitor # noqa: E402
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def peek(
|
|
240
|
+
project: str | Path | None = None,
|
|
241
|
+
*,
|
|
242
|
+
round: int | str | None = None,
|
|
243
|
+
log: bool = False,
|
|
244
|
+
events: int | None = None,
|
|
245
|
+
select: str | None = None,
|
|
246
|
+
) -> ProjectState | Any:
|
|
247
|
+
"""Build a ProjectState snapshot. With select, return that subtree."""
|
|
248
|
+
from agent_runner import round_view
|
|
249
|
+
|
|
250
|
+
work_dir = project if isinstance(project, Path) else Path.cwd()
|
|
251
|
+
cfg = load_config(work_dir / "agent-runner.toml")
|
|
252
|
+
log_dir = cfg.runtime.log_dir
|
|
253
|
+
src = monitor.LocalSource(log_dir=log_dir)
|
|
254
|
+
base_state = monitor.assemble_project_state(src, project=_project_name(work_dir))
|
|
255
|
+
parsed_events = monitor.parse_events_from_jsonl_files(src.events_files())
|
|
256
|
+
round_num = round_view.resolve_round_arg(round, log_dir)
|
|
257
|
+
current: Any = base_state.current_round
|
|
258
|
+
if round_num is not None:
|
|
259
|
+
current = round_view.build_round_view(log_dir, round_num, parsed_events, want_log=log)
|
|
260
|
+
if current is None:
|
|
261
|
+
raise KeyError(f"round {round_num} not found under {log_dir}/rounds/")
|
|
262
|
+
recent = parsed_events[-events:] if events else []
|
|
263
|
+
|
|
264
|
+
state = ProjectState(
|
|
265
|
+
project=base_state.project,
|
|
266
|
+
status=base_state.status,
|
|
267
|
+
defenses=[
|
|
268
|
+
{
|
|
269
|
+
"name": d.name,
|
|
270
|
+
"value": d.value,
|
|
271
|
+
"codifies": d.codifies,
|
|
272
|
+
"guarded_by": str(d.guarded_by) if d.guarded_by else None,
|
|
273
|
+
"current_state": d.current_state,
|
|
274
|
+
}
|
|
275
|
+
for d in defenses.catalog(cfg)
|
|
276
|
+
],
|
|
277
|
+
current_round=current,
|
|
278
|
+
recent_rounds=base_state.recent_rounds,
|
|
279
|
+
orphan=base_state.orphan,
|
|
280
|
+
system=base_state.system,
|
|
281
|
+
service=status(project if project is not None else work_dir),
|
|
282
|
+
recent_events=recent,
|
|
283
|
+
)
|
|
284
|
+
return state if select is None else select_path(state, select)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _poll_once(project: str | Path, *, host: str | None) -> list[monitor.Alert]:
|
|
288
|
+
work_dir = project if isinstance(project, Path) else Path.cwd()
|
|
289
|
+
cfg = load_config(work_dir / "agent-runner.toml")
|
|
290
|
+
src: monitor.StateSource
|
|
291
|
+
if host is None:
|
|
292
|
+
src = monitor.LocalSource(log_dir=cfg.runtime.log_dir)
|
|
293
|
+
else:
|
|
294
|
+
src = monitor.RemoteSource(host=host, project=_project_name(work_dir))
|
|
295
|
+
events = monitor.parse_events_from_jsonl_files(src.events_files())
|
|
296
|
+
metrics = monitor.parse_events_from_jsonl_files(src.metrics_files())
|
|
297
|
+
log_tails = monitor.load_round_log_tails(src.rounds_dir())
|
|
298
|
+
return monitor.run_all_detectors(
|
|
299
|
+
events=events,
|
|
300
|
+
metrics=metrics,
|
|
301
|
+
log_tails=log_tails,
|
|
302
|
+
round_timeout_s=cfg.runtime.round_timeout_s,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def monitor_loop(
|
|
307
|
+
project: str | Path | None = None, *, host: str | None = None, interval_s: int = 30
|
|
308
|
+
) -> Iterator[monitor.Alert]:
|
|
309
|
+
"""Yield alerts as they're detected. Caller decides what to do.
|
|
310
|
+
|
|
311
|
+
The loop dedups alerts by (detector, json.dumps(context)) within session.
|
|
312
|
+
"""
|
|
313
|
+
import json as _json
|
|
314
|
+
|
|
315
|
+
seen: set[str] = set()
|
|
316
|
+
work_dir = project if isinstance(project, Path) else Path.cwd()
|
|
317
|
+
cfg = load_config(work_dir / "agent-runner.toml")
|
|
318
|
+
while True:
|
|
319
|
+
for alert in _poll_once(work_dir, host=host):
|
|
320
|
+
key = f"{alert.detector}:{_json.dumps(alert.context, sort_keys=True)}"
|
|
321
|
+
if key in seen:
|
|
322
|
+
continue
|
|
323
|
+
seen.add(key)
|
|
324
|
+
yield alert
|
|
325
|
+
monitor.on_alert(
|
|
326
|
+
alert,
|
|
327
|
+
project=_project_name(work_dir),
|
|
328
|
+
host=host,
|
|
329
|
+
log_dir=cfg.runtime.log_dir,
|
|
330
|
+
)
|
|
331
|
+
time.sleep(interval_s)
|