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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Dataclasses for the Python API state tree.
|
|
2
|
+
|
|
3
|
+
These are the public types that ``agent_runner.api`` returns and that
|
|
4
|
+
``cli/`` formats. Phase 3 LLM/Critic will read these structures.
|
|
5
|
+
|
|
6
|
+
All frozen — state is immutable, no in-place mutation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ServiceMode(StrEnum):
|
|
18
|
+
SYSTEMD_USER = "systemd_user"
|
|
19
|
+
PID_FILE = "pid_file"
|
|
20
|
+
NONE = "none"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ServiceStatus:
|
|
25
|
+
mode: ServiceMode
|
|
26
|
+
active: bool
|
|
27
|
+
pid: int | None = None
|
|
28
|
+
uptime_s: float | None = None
|
|
29
|
+
unit_file: Path | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SystemMetrics:
|
|
34
|
+
mem_total_mb: int
|
|
35
|
+
mem_available_mb: int
|
|
36
|
+
disk_used_pct: float
|
|
37
|
+
disk_free_gb: float = 0.0
|
|
38
|
+
load_1m: float | None = None
|
|
39
|
+
cpu_pct: float | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RoundView:
|
|
44
|
+
round_num: int
|
|
45
|
+
phase: str | None
|
|
46
|
+
started_at: str
|
|
47
|
+
duration_so_far_s: float | None
|
|
48
|
+
pid: int | None
|
|
49
|
+
exit_code: int | None
|
|
50
|
+
timed_out: bool | None
|
|
51
|
+
log_path: Path
|
|
52
|
+
log_tail: str | None = None
|
|
53
|
+
recent_events: list[dict[str, Any]] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ProjectState:
|
|
58
|
+
project: str
|
|
59
|
+
status: dict[str, Any]
|
|
60
|
+
defenses: list[dict[str, Any]]
|
|
61
|
+
current_round: RoundView | None
|
|
62
|
+
recent_rounds: list[RoundView]
|
|
63
|
+
orphan: dict[str, Any] | None
|
|
64
|
+
system: SystemMetrics
|
|
65
|
+
service: ServiceStatus
|
|
66
|
+
recent_events: list[dict[str, Any]] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Alert:
|
|
71
|
+
severity: str
|
|
72
|
+
detector: str
|
|
73
|
+
message: str
|
|
74
|
+
context: dict[str, Any]
|
|
75
|
+
ts: str
|
|
76
|
+
auto_action: str = "none"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class InitResult:
|
|
81
|
+
work_dir: Path
|
|
82
|
+
files_created: list[Path]
|
|
83
|
+
committed: bool
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class InstallResult:
|
|
88
|
+
unit_path: Path
|
|
89
|
+
monitor_unit_path: Path | None
|
|
90
|
+
enabled: bool
|
|
91
|
+
started: bool
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def select_path(tree: Any, path: str) -> Any:
|
|
95
|
+
"""Resolve dot-notation path into a nested dataclass / dict / list tree.
|
|
96
|
+
|
|
97
|
+
Numeric segments index into lists. Raises KeyError naming the failed
|
|
98
|
+
segment so the caller (CLI) can give a precise error to the user.
|
|
99
|
+
"""
|
|
100
|
+
cur = tree
|
|
101
|
+
for part in path.split("."):
|
|
102
|
+
try:
|
|
103
|
+
if part.isdigit():
|
|
104
|
+
cur = cur[int(part)]
|
|
105
|
+
elif isinstance(cur, dict):
|
|
106
|
+
cur = cur[part]
|
|
107
|
+
else:
|
|
108
|
+
cur = getattr(cur, part)
|
|
109
|
+
except (AttributeError, IndexError, KeyError) as e:
|
|
110
|
+
raise KeyError(f"path segment {part!r} not found in select path {path!r}") from e
|
|
111
|
+
return cur
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CLI entry — argparse subcommand dispatcher.
|
|
2
|
+
|
|
3
|
+
Each subcommand lives in its own ``*_cmd.py`` file; this module just routes.
|
|
4
|
+
The ``--config`` and ``--json`` flags can be placed before OR after the
|
|
5
|
+
subcommand verb. To make this work without the subparser's defaults clobbering
|
|
6
|
+
values supplied to the main parser, the main parser owns the real defaults
|
|
7
|
+
while the subparser-shared parent declares the same flags with
|
|
8
|
+
``argparse.SUPPRESS`` so they only mutate the namespace when explicitly
|
|
9
|
+
supplied after the verb.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from agent_runner.cli import (
|
|
19
|
+
init_cmd,
|
|
20
|
+
install_cmd,
|
|
21
|
+
monitor_cmd,
|
|
22
|
+
peek_cmd,
|
|
23
|
+
round_cmd,
|
|
24
|
+
serve_cmd,
|
|
25
|
+
service_cmd,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="agent-runner",
|
|
32
|
+
description="Restart-on-exit supervisor for autonomous CLI agents.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--config",
|
|
36
|
+
type=Path,
|
|
37
|
+
default=Path("./agent-runner.toml"),
|
|
38
|
+
help="Path to agent-runner.toml (default: ./agent-runner.toml)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--json", action="store_true", help="Machine-readable JSON output (where supported)"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Parent parser shared by every subparser so the same flags can also be
|
|
45
|
+
# placed AFTER the verb. SUPPRESS keeps the subparser from overwriting
|
|
46
|
+
# values supplied to the main parser when the flag is omitted.
|
|
47
|
+
parent = argparse.ArgumentParser(add_help=False)
|
|
48
|
+
parent.add_argument("--config", type=Path, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
49
|
+
parent.add_argument(
|
|
50
|
+
"--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
sub = parser.add_subparsers(dest="command", required=False)
|
|
54
|
+
|
|
55
|
+
init_cmd.add_parser(sub, parent)
|
|
56
|
+
install_cmd.add_parser(sub, parent)
|
|
57
|
+
service_cmd.add_parser(sub, parent)
|
|
58
|
+
peek_cmd.add_parser(sub, parent)
|
|
59
|
+
monitor_cmd.add_parser(sub, parent)
|
|
60
|
+
serve_cmd.add_parser(sub, parent)
|
|
61
|
+
round_cmd.add_parser(sub, parent)
|
|
62
|
+
|
|
63
|
+
return parser
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv: list[str] | None = None) -> int:
|
|
67
|
+
parser = _build_parser()
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
if not args.command:
|
|
70
|
+
parser.print_help()
|
|
71
|
+
return 1
|
|
72
|
+
return args.func(args)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__": # pragma: no cover
|
|
76
|
+
sys.exit(main())
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Shared CLI helpers — config loading + JSON formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agent_runner.config import Config, load_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cfg_from_args(args) -> Config:
|
|
16
|
+
return load_config(args.config)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def work_dir_from_args(args) -> Path:
|
|
20
|
+
"""Resolve the project work_dir from --config's parent, falling back to cwd.
|
|
21
|
+
|
|
22
|
+
The api functions hardcode ``work_dir / "agent-runner.toml"`` for config loading,
|
|
23
|
+
so callers cannot rename the toml. Reject a mismatching filename loudly here
|
|
24
|
+
instead of letting api read the wrong file (or a missing file) silently.
|
|
25
|
+
"""
|
|
26
|
+
cfg = getattr(args, "config", None)
|
|
27
|
+
if cfg is None:
|
|
28
|
+
return Path.cwd().resolve()
|
|
29
|
+
cfg_path = Path(cfg).resolve()
|
|
30
|
+
if cfg_path.name != "agent-runner.toml":
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"--config must point at a file named 'agent-runner.toml', got {cfg_path.name!r}"
|
|
33
|
+
)
|
|
34
|
+
return cfg_path.parent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def emit(value: Any, *, json_mode: bool) -> None:
|
|
38
|
+
if json_mode:
|
|
39
|
+
print(json.dumps(_to_jsonable(value), indent=2, default=str))
|
|
40
|
+
else:
|
|
41
|
+
print(_pretty(value))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _to_jsonable(value: Any) -> Any:
|
|
45
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
46
|
+
return {f.name: _to_jsonable(getattr(value, f.name)) for f in dataclasses.fields(value)}
|
|
47
|
+
if isinstance(value, Enum):
|
|
48
|
+
return value.value
|
|
49
|
+
if isinstance(value, Path):
|
|
50
|
+
return str(value)
|
|
51
|
+
if isinstance(value, (list, tuple)):
|
|
52
|
+
return [_to_jsonable(x) for x in value]
|
|
53
|
+
if isinstance(value, dict):
|
|
54
|
+
return {k: _to_jsonable(v) for k, v in value.items()}
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pretty(value: Any) -> str:
|
|
59
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
60
|
+
lines: list[str] = [type(value).__name__ + ":"]
|
|
61
|
+
for f in dataclasses.fields(value):
|
|
62
|
+
v = getattr(value, f.name)
|
|
63
|
+
lines.append(f" {f.name}: {_pretty_inline(v)}")
|
|
64
|
+
return "\n".join(lines)
|
|
65
|
+
return _pretty_inline(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _pretty_inline(value: Any) -> str:
|
|
69
|
+
if isinstance(value, Enum):
|
|
70
|
+
return value.value
|
|
71
|
+
if isinstance(value, Path):
|
|
72
|
+
return str(value)
|
|
73
|
+
return repr(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fail(msg: str, *, code: int = 1) -> int:
|
|
77
|
+
print(f"agent-runner: {msg}", file=sys.stderr)
|
|
78
|
+
return code
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""init subcommand — scaffold project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agent_runner import api
|
|
6
|
+
from agent_runner.cli.common import emit, fail, work_dir_from_args
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def add_parser(sub, parent) -> None:
|
|
10
|
+
p = sub.add_parser("init", parents=[parent], help="Scaffold agent-runner project files")
|
|
11
|
+
p.add_argument("--force", action="store_true", help="Overwrite existing toml")
|
|
12
|
+
g = p.add_mutually_exclusive_group()
|
|
13
|
+
g.add_argument(
|
|
14
|
+
"--commit",
|
|
15
|
+
dest="commit",
|
|
16
|
+
action="store_true",
|
|
17
|
+
default=True,
|
|
18
|
+
help="git commit the new files (default)",
|
|
19
|
+
)
|
|
20
|
+
g.add_argument("--no-commit", dest="commit", action="store_false", help="Skip git commit")
|
|
21
|
+
p.set_defaults(func=cmd)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd(args) -> int:
|
|
25
|
+
work_dir = work_dir_from_args(args)
|
|
26
|
+
try:
|
|
27
|
+
result = api.init(work_dir, force=args.force, commit=args.commit)
|
|
28
|
+
except (FileExistsError, RuntimeError) as e:
|
|
29
|
+
return fail(str(e))
|
|
30
|
+
emit(result, json_mode=getattr(args, "json", False))
|
|
31
|
+
return 0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""install / uninstall subcommands — manage systemd user units."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agent_runner import api
|
|
6
|
+
from agent_runner.cli.common import emit, fail, work_dir_from_args
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def add_parser(sub, parent) -> None:
|
|
10
|
+
p = sub.add_parser(
|
|
11
|
+
"install", parents=[parent], help="Generate systemd user unit, enable + start"
|
|
12
|
+
)
|
|
13
|
+
p.add_argument(
|
|
14
|
+
"--system",
|
|
15
|
+
action="store_true",
|
|
16
|
+
help="Install at system level (not yet supported in Phase 2)",
|
|
17
|
+
)
|
|
18
|
+
p.add_argument(
|
|
19
|
+
"--monitor",
|
|
20
|
+
action="store_true",
|
|
21
|
+
help="Also install monitor sidekick service for auto-stop on critical alerts",
|
|
22
|
+
)
|
|
23
|
+
p.set_defaults(func=cmd_install)
|
|
24
|
+
|
|
25
|
+
u = sub.add_parser(
|
|
26
|
+
"uninstall", parents=[parent], help="Stop, disable, and remove systemd user unit(s)"
|
|
27
|
+
)
|
|
28
|
+
u.set_defaults(func=cmd_uninstall)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_install(args) -> int:
|
|
32
|
+
work_dir = work_dir_from_args(args)
|
|
33
|
+
try:
|
|
34
|
+
result = api.install(work_dir, system=args.system, with_monitor=args.monitor)
|
|
35
|
+
except (NotImplementedError, FileNotFoundError) as e:
|
|
36
|
+
return fail(str(e))
|
|
37
|
+
emit(result, json_mode=getattr(args, "json", False))
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_uninstall(args) -> int:
|
|
42
|
+
work_dir = work_dir_from_args(args)
|
|
43
|
+
api.uninstall(work_dir)
|
|
44
|
+
return 0
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""monitor subcommand — anomaly detection daemon (local or remote)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from agent_runner import api
|
|
9
|
+
from agent_runner.cli.common import _to_jsonable, work_dir_from_args
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def add_parser(sub, parent) -> None:
|
|
13
|
+
p = sub.add_parser(
|
|
14
|
+
"monitor", parents=[parent], help="Anomaly detection daemon (local or remote via --host)"
|
|
15
|
+
)
|
|
16
|
+
p.add_argument(
|
|
17
|
+
"--host",
|
|
18
|
+
type=str,
|
|
19
|
+
default=None,
|
|
20
|
+
metavar="SSH-ALIAS",
|
|
21
|
+
help="Watch a remote agent-runner via ssh",
|
|
22
|
+
)
|
|
23
|
+
p.add_argument(
|
|
24
|
+
"--interval",
|
|
25
|
+
type=int,
|
|
26
|
+
default=None,
|
|
27
|
+
metavar="SECONDS",
|
|
28
|
+
help="Poll interval (default 30s, 60s for remote)",
|
|
29
|
+
)
|
|
30
|
+
p.set_defaults(func=cmd)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd(args) -> int:
|
|
34
|
+
interval = args.interval if args.interval is not None else (60 if args.host else 30)
|
|
35
|
+
json_mode = getattr(args, "json", False)
|
|
36
|
+
try:
|
|
37
|
+
work_dir = work_dir_from_args(args)
|
|
38
|
+
for alert in api.monitor_loop(work_dir, host=args.host, interval_s=interval):
|
|
39
|
+
if json_mode:
|
|
40
|
+
print(json.dumps(_to_jsonable(alert)))
|
|
41
|
+
sys.stdout.flush()
|
|
42
|
+
else:
|
|
43
|
+
tag = {"info": "[OK]", "warning": "[WARN]", "critical": "[CRIT]"}[alert.severity]
|
|
44
|
+
print(f"{tag} {alert.detector} — {alert.message}")
|
|
45
|
+
sys.stdout.flush()
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
return 0
|
|
48
|
+
return 0
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""peek and watch subcommands — snapshot + auto-refresh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from agent_runner import api
|
|
10
|
+
from agent_runner.cli.common import emit, fail, work_dir_from_args
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _round_arg(s: str) -> int | str:
|
|
14
|
+
if s == "latest":
|
|
15
|
+
return s
|
|
16
|
+
try:
|
|
17
|
+
return int(s)
|
|
18
|
+
except ValueError as e:
|
|
19
|
+
raise argparse.ArgumentTypeError(f"--round expects int or 'latest', got {s!r}") from e
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_parser(sub, parent) -> None:
|
|
23
|
+
for verb, fn in (("peek", cmd_peek), ("watch", cmd_watch)):
|
|
24
|
+
p = sub.add_parser(
|
|
25
|
+
verb, parents=[parent], help=f"{verb} project state with optional drill-down"
|
|
26
|
+
)
|
|
27
|
+
p.add_argument(
|
|
28
|
+
"--round",
|
|
29
|
+
type=_round_arg,
|
|
30
|
+
default=None,
|
|
31
|
+
metavar="N",
|
|
32
|
+
help="Drill into round N (int or 'latest')",
|
|
33
|
+
)
|
|
34
|
+
p.add_argument("--log", action="store_true", help="Include current round's log tail")
|
|
35
|
+
p.add_argument(
|
|
36
|
+
"--events", type=int, default=None, metavar="N", help="Include last N events"
|
|
37
|
+
)
|
|
38
|
+
p.add_argument(
|
|
39
|
+
"--select",
|
|
40
|
+
type=str,
|
|
41
|
+
default=None,
|
|
42
|
+
help="Dot-path subtree to extract (e.g. system.disk_used_pct)",
|
|
43
|
+
)
|
|
44
|
+
if verb == "watch":
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--interval",
|
|
47
|
+
type=int,
|
|
48
|
+
default=2,
|
|
49
|
+
metavar="SECONDS",
|
|
50
|
+
help="Refresh interval (default 2)",
|
|
51
|
+
)
|
|
52
|
+
p.set_defaults(func=fn)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_peek(args) -> int:
|
|
56
|
+
try:
|
|
57
|
+
result = api.peek(
|
|
58
|
+
work_dir_from_args(args),
|
|
59
|
+
round=args.round,
|
|
60
|
+
log=args.log,
|
|
61
|
+
events=args.events,
|
|
62
|
+
select=args.select,
|
|
63
|
+
)
|
|
64
|
+
except KeyError as e:
|
|
65
|
+
return fail(str(e))
|
|
66
|
+
except FileNotFoundError as e:
|
|
67
|
+
return fail(f"config not found: {e}")
|
|
68
|
+
emit(result, json_mode=getattr(args, "json", False))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_watch(args) -> int:
|
|
73
|
+
while True:
|
|
74
|
+
sys.stdout.write("\x1b[2J\x1b[H")
|
|
75
|
+
rc = cmd_peek(args)
|
|
76
|
+
if rc != 0:
|
|
77
|
+
return rc
|
|
78
|
+
try:
|
|
79
|
+
time.sleep(args.interval)
|
|
80
|
+
except KeyboardInterrupt:
|
|
81
|
+
return 0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""round subcommand — runs one supervisor round (used by serve and systemd)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agent_runner.cli.common import cfg_from_args
|
|
6
|
+
from agent_runner.runner import run_one_round
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def add_parser(sub, parent) -> None:
|
|
10
|
+
p = sub.add_parser("round", parents=[parent], help="Run one round and exit")
|
|
11
|
+
p.set_defaults(func=cmd)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def cmd(args) -> int:
|
|
15
|
+
cfg = cfg_from_args(args)
|
|
16
|
+
run_one_round(cfg)
|
|
17
|
+
return 0
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""serve subcommand — long-running supervisor loop. THIN: <=60 LOC.
|
|
2
|
+
|
|
3
|
+
Trap signals, write/cleanup PID files, run `round` subprocess in a loop.
|
|
4
|
+
All real work delegated to `agent-runner round` (fresh import per round).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess # noqa: TID251
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from agent_runner.cli.common import cfg_from_args
|
|
16
|
+
from agent_runner.lifecycle import PIDFile, send_signal_to_pid
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_parser(sub, parent) -> None:
|
|
20
|
+
p = sub.add_parser("serve", parents=[parent], help="Long-running supervisor loop")
|
|
21
|
+
p.add_argument("--once", action="store_true", help="Run a single round then exit (debug)")
|
|
22
|
+
p.set_defaults(func=cmd)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd(args) -> int:
|
|
26
|
+
cfg = cfg_from_args(args)
|
|
27
|
+
pid_file = PIDFile(cfg.runtime.log_dir / "serve.pid")
|
|
28
|
+
pid_file.write(os.getpid())
|
|
29
|
+
stop = {"requested": False}
|
|
30
|
+
round_pid_file = PIDFile(cfg.runtime.log_dir / "round.pid")
|
|
31
|
+
|
|
32
|
+
def graceful(_sig, _frame):
|
|
33
|
+
stop["requested"] = True
|
|
34
|
+
|
|
35
|
+
def cancel(_sig, _frame):
|
|
36
|
+
stop["requested"] = True
|
|
37
|
+
rp = round_pid_file.read()
|
|
38
|
+
if rp is not None:
|
|
39
|
+
send_signal_to_pid(-rp, signal.SIGINT)
|
|
40
|
+
|
|
41
|
+
signal.signal(signal.SIGTERM, graceful)
|
|
42
|
+
signal.signal(signal.SIGINT, graceful)
|
|
43
|
+
signal.signal(signal.SIGUSR1, cancel)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
while not stop["requested"]:
|
|
47
|
+
r = subprocess.run(
|
|
48
|
+
[sys.executable, "-m", "agent_runner.cli", "--config", str(args.config), "round"],
|
|
49
|
+
)
|
|
50
|
+
if args.once or stop["requested"]:
|
|
51
|
+
break
|
|
52
|
+
delay = (
|
|
53
|
+
cfg.runtime.restart_delay_s
|
|
54
|
+
if r.returncode == 0
|
|
55
|
+
else cfg.runtime.restart_delay_s * 2
|
|
56
|
+
)
|
|
57
|
+
time.sleep(delay)
|
|
58
|
+
finally:
|
|
59
|
+
pid_file.unlink()
|
|
60
|
+
return 0
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""service subcommands — start / stop / kill / cancel / restart / status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agent_runner import api
|
|
6
|
+
from agent_runner.cli.common import emit, work_dir_from_args
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def add_parser(sub, parent) -> None:
|
|
10
|
+
for verb, fn, help_text in (
|
|
11
|
+
("start", cmd_start, "Start the service"),
|
|
12
|
+
("stop", cmd_stop, "Graceful stop (waits for current round)"),
|
|
13
|
+
("kill", cmd_kill, "Force terminate (5s grace then SIGKILL)"),
|
|
14
|
+
("cancel", cmd_cancel, "Best-effort: SIGINT to claude (commit-and-exit hint)"),
|
|
15
|
+
("restart", cmd_restart, "stop + start (use --force for kill semantics)"),
|
|
16
|
+
("status", cmd_status, "Show current service state"),
|
|
17
|
+
):
|
|
18
|
+
p = sub.add_parser(verb, parents=[parent], help=help_text)
|
|
19
|
+
if verb == "restart":
|
|
20
|
+
p.add_argument("--force", action="store_true", help="Use kill instead of stop")
|
|
21
|
+
p.set_defaults(func=fn)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_start(args) -> int:
|
|
25
|
+
emit(api.start(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def cmd_stop(args) -> int:
|
|
30
|
+
emit(api.stop(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cmd_kill(args) -> int:
|
|
35
|
+
emit(api.kill(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_cancel(args) -> int:
|
|
40
|
+
api.cancel(work_dir_from_args(args))
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cmd_restart(args) -> int:
|
|
45
|
+
emit(
|
|
46
|
+
api.restart(work_dir_from_args(args), force=args.force),
|
|
47
|
+
json_mode=getattr(args, "json", False),
|
|
48
|
+
)
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_status(args) -> int:
|
|
53
|
+
emit(api.status(work_dir_from_args(args)), json_mode=getattr(args, "json", False))
|
|
54
|
+
return 0
|