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/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""TOML config loader with dataclass-based validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AgentConfig:
|
|
12
|
+
command: list[str]
|
|
13
|
+
prompt_arg_template: list[str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RuntimeConfig:
|
|
18
|
+
work_dir: Path
|
|
19
|
+
log_dir: Path
|
|
20
|
+
round_timeout_s: int = 1800
|
|
21
|
+
restart_delay_s: int = 3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PromptConfig:
|
|
26
|
+
file: Path
|
|
27
|
+
inject_context: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class VcsConfig:
|
|
32
|
+
orphan_action: str = "stash"
|
|
33
|
+
stash_idempotency_s: int = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Config:
|
|
38
|
+
agent: AgentConfig
|
|
39
|
+
runtime: RuntimeConfig
|
|
40
|
+
prompt: PromptConfig
|
|
41
|
+
vcs: VcsConfig = field(default_factory=VcsConfig)
|
|
42
|
+
phases: list[str] | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require(d: dict, *path: str) -> object:
|
|
46
|
+
cur: object = d
|
|
47
|
+
for p in path:
|
|
48
|
+
if not isinstance(cur, dict) or p not in cur:
|
|
49
|
+
raise ValueError(f"missing required field: {'.'.join(path)}")
|
|
50
|
+
cur = cur[p]
|
|
51
|
+
return cur
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _expand_path(s: str, project_name: str) -> Path:
|
|
55
|
+
return Path(s.replace("{project}", project_name)).expanduser()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(toml_path: Path) -> Config:
|
|
59
|
+
if not toml_path.exists():
|
|
60
|
+
raise FileNotFoundError(f"config not found: {toml_path}")
|
|
61
|
+
with toml_path.open("rb") as f:
|
|
62
|
+
raw = tomllib.load(f)
|
|
63
|
+
|
|
64
|
+
agent = AgentConfig(
|
|
65
|
+
command=list(_require(raw, "agent", "command")),
|
|
66
|
+
prompt_arg_template=list(_require(raw, "agent", "prompt_arg_template")),
|
|
67
|
+
)
|
|
68
|
+
raw_work_dir = str(_require(raw, "runtime", "work_dir"))
|
|
69
|
+
work_dir = _expand_path(raw_work_dir, "").resolve()
|
|
70
|
+
project_name = work_dir.name or "default"
|
|
71
|
+
|
|
72
|
+
runtime_d = raw.get("runtime", {})
|
|
73
|
+
runtime = RuntimeConfig(
|
|
74
|
+
work_dir=work_dir,
|
|
75
|
+
log_dir=_expand_path(str(_require(raw, "runtime", "log_dir")), project_name),
|
|
76
|
+
round_timeout_s=int(runtime_d.get("round_timeout_s", 1800)),
|
|
77
|
+
restart_delay_s=int(runtime_d.get("restart_delay_s", 3)),
|
|
78
|
+
)
|
|
79
|
+
prompt_d = raw.get("prompt", {})
|
|
80
|
+
prompt = PromptConfig(
|
|
81
|
+
file=_expand_path(str(_require(raw, "prompt", "file")), project_name),
|
|
82
|
+
inject_context=bool(prompt_d.get("inject_context", True)),
|
|
83
|
+
)
|
|
84
|
+
vcs_d = raw.get("vcs", {})
|
|
85
|
+
vcs = VcsConfig(
|
|
86
|
+
orphan_action=str(vcs_d.get("orphan_action", "stash")),
|
|
87
|
+
stash_idempotency_s=int(vcs_d.get("stash_idempotency_s", 5)),
|
|
88
|
+
)
|
|
89
|
+
phases_d = raw.get("phases", {})
|
|
90
|
+
phases = list(phases_d["list"]) if "list" in phases_d else None
|
|
91
|
+
|
|
92
|
+
return Config(agent=agent, runtime=runtime, prompt=prompt, vcs=vcs, phases=phases)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Persistent JSON state — status / round-context / orphan-state, atomic writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
STATUS_FILE = "status.json"
|
|
13
|
+
CONTEXT_FILE = "round-context.json"
|
|
14
|
+
ORPHAN_FILE = "orphan-state.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Status:
|
|
19
|
+
round_num: int
|
|
20
|
+
running: bool
|
|
21
|
+
last_completed_at: str | None = None
|
|
22
|
+
last_exit_code: int | None = None
|
|
23
|
+
last_duration_s: float | None = None
|
|
24
|
+
current_phase: str | None = None
|
|
25
|
+
phase_index: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class OrphanState:
|
|
30
|
+
round_num: int
|
|
31
|
+
files: list[str]
|
|
32
|
+
stashed_ref: str | None
|
|
33
|
+
stash_message: str | None
|
|
34
|
+
timestamp: str
|
|
35
|
+
phase: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def atomic_write_json(path: Path, payload: dict[str, Any] | list[Any]) -> None:
|
|
39
|
+
"""Write JSON atomically: tmp file in same dir, fsync, rename."""
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
44
|
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
45
|
+
f.flush()
|
|
46
|
+
os.fsync(f.fileno())
|
|
47
|
+
os.replace(tmp, path)
|
|
48
|
+
except Exception:
|
|
49
|
+
Path(tmp).unlink(missing_ok=True)
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_json(path: Path) -> dict[str, Any] | None:
|
|
54
|
+
"""Read + parse JSON; return None on missing file or parse failure.
|
|
55
|
+
|
|
56
|
+
Single TOCTOU-free read replaces three near-identical exists+read patterns.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def write_status(log_dir: Path, status: Status) -> None:
|
|
65
|
+
payload = {k: v for k, v in asdict(status).items() if v is not None or isinstance(v, bool)}
|
|
66
|
+
atomic_write_json(log_dir / STATUS_FILE, payload)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_status(log_dir: Path) -> Status | None:
|
|
70
|
+
data = read_json(log_dir / STATUS_FILE)
|
|
71
|
+
if data is None:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return Status(**data)
|
|
75
|
+
except TypeError:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def write_round_context(
|
|
80
|
+
log_dir: Path,
|
|
81
|
+
*,
|
|
82
|
+
round_num: int,
|
|
83
|
+
started_at: str,
|
|
84
|
+
phase: str | None = None,
|
|
85
|
+
previous: dict[str, Any] | None = None,
|
|
86
|
+
orphan_stash: dict[str, Any] | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
ctx: dict[str, Any] = {"round_num": round_num, "started_at": started_at}
|
|
89
|
+
if phase is not None:
|
|
90
|
+
ctx["phase"] = phase
|
|
91
|
+
if previous is not None:
|
|
92
|
+
ctx["previous"] = previous
|
|
93
|
+
if orphan_stash is not None:
|
|
94
|
+
ctx["orphan_stash"] = orphan_stash
|
|
95
|
+
atomic_write_json(log_dir / CONTEXT_FILE, ctx)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_round_context(log_dir: Path) -> dict[str, Any] | None:
|
|
99
|
+
return read_json(log_dir / CONTEXT_FILE)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def write_orphan_state(log_dir: Path, state: OrphanState) -> None:
|
|
103
|
+
atomic_write_json(log_dir / ORPHAN_FILE, asdict(state))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_orphan_state(log_dir: Path) -> OrphanState | None:
|
|
107
|
+
data = read_json(log_dir / ORPHAN_FILE)
|
|
108
|
+
if data is None:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return OrphanState(**data)
|
|
112
|
+
except TypeError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def clear_orphan_state(log_dir: Path) -> None:
|
|
117
|
+
(log_dir / ORPHAN_FILE).unlink(missing_ok=True)
|
agent_runner/critic.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Phase 3 critic interface — empty stub in Phase 2.
|
|
2
|
+
|
|
3
|
+
A Critic analyses the current ProjectState (recent rounds, defenses, events)
|
|
4
|
+
and emits Findings: drift / dark-code / inefficiency observations that should
|
|
5
|
+
be fed into the next round's prompt context or recorded for the operator.
|
|
6
|
+
|
|
7
|
+
Phase 3 implements concrete Critics (LLM-backed, invariant-runner, etc.).
|
|
8
|
+
Phase 2 ships only the Protocols so the rest of the system can reference
|
|
9
|
+
the type without committing to an implementation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from agent_runner.api_types import ProjectState
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Finding(Protocol):
|
|
21
|
+
"""A single observation emitted by a Critic."""
|
|
22
|
+
|
|
23
|
+
severity: str # "info" | "warning" | "critical"
|
|
24
|
+
detector: str # critic-defined identifier
|
|
25
|
+
message: str
|
|
26
|
+
suggested_action: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class Critic(Protocol):
|
|
31
|
+
"""Phase 3 implements: analyse a ProjectState snapshot, return findings."""
|
|
32
|
+
|
|
33
|
+
def analyze(self, state: ProjectState) -> list[Finding]: ...
|
agent_runner/defenses.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Structured catalog of supervisor defenses.
|
|
2
|
+
|
|
3
|
+
Each defense is a tuple of (current value, what historical incident it codifies,
|
|
4
|
+
which invariant test guards it, current health). This is the single source of
|
|
5
|
+
truth — peek/status/start banner all import from here.
|
|
6
|
+
|
|
7
|
+
Adding a new defense = one entry here + auto-surfaces everywhere via the API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from agent_runner.agent_runtime import CRITICAL_ENV_DEFAULTS
|
|
17
|
+
from agent_runner.config import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Defense:
|
|
22
|
+
name: str
|
|
23
|
+
value: Any
|
|
24
|
+
codifies: str | None
|
|
25
|
+
guarded_by: Path | None
|
|
26
|
+
current_state: str # "active" | "degraded" | "off"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def catalog(cfg: Config) -> list[Defense]:
|
|
30
|
+
"""Return the 11-entry defense catalog parameterised by current config."""
|
|
31
|
+
return [
|
|
32
|
+
Defense(
|
|
33
|
+
name="round_timeout_s",
|
|
34
|
+
value=cfg.runtime.round_timeout_s,
|
|
35
|
+
codifies="R1128 — TaskOutput polling loop 60min, scheduler grace fails to trigger",
|
|
36
|
+
guarded_by=None,
|
|
37
|
+
current_state="active",
|
|
38
|
+
),
|
|
39
|
+
Defense(
|
|
40
|
+
name="process_group_isolation",
|
|
41
|
+
value="start_new_session=True",
|
|
42
|
+
codifies="#307 — process group reaping for descendant cleanup",
|
|
43
|
+
guarded_by=Path("tests/unit/test_agent_runtime.py"),
|
|
44
|
+
current_state="active",
|
|
45
|
+
),
|
|
46
|
+
Defense(
|
|
47
|
+
name="sigterm_reaper",
|
|
48
|
+
value="install_sigterm_reaper",
|
|
49
|
+
codifies="R725 — SIGTERM-during-round dual-claude race",
|
|
50
|
+
guarded_by=None,
|
|
51
|
+
current_state="active",
|
|
52
|
+
),
|
|
53
|
+
Defense(
|
|
54
|
+
name="orphan_stash_idempotency_s",
|
|
55
|
+
value=cfg.vcs.stash_idempotency_s,
|
|
56
|
+
codifies="R820 — same-second 3 phantom stashes",
|
|
57
|
+
guarded_by=None,
|
|
58
|
+
current_state="active",
|
|
59
|
+
),
|
|
60
|
+
Defense(
|
|
61
|
+
name="sha_locked_stash",
|
|
62
|
+
value="drop/pop accept SHA only",
|
|
63
|
+
codifies="§9 IMMUTABLE — batch drop by index breaks under concurrent stash",
|
|
64
|
+
guarded_by=Path("tests/invariants/test_stash_uses_sha_not_index.py"),
|
|
65
|
+
current_state="active",
|
|
66
|
+
),
|
|
67
|
+
Defense(
|
|
68
|
+
name="set_diff_classification",
|
|
69
|
+
value="set_diff_vs_head",
|
|
70
|
+
codifies="R2110 — rotation-only diff via +-line scan misclassifies",
|
|
71
|
+
guarded_by=None,
|
|
72
|
+
current_state="active",
|
|
73
|
+
),
|
|
74
|
+
Defense(
|
|
75
|
+
name="critical_envs_injection",
|
|
76
|
+
value=list(CRITICAL_ENV_DEFAULTS.keys()),
|
|
77
|
+
codifies=(
|
|
78
|
+
"DISABLE_AUTOUPDATER + CLAUDE_CODE_EFFORT_LEVEL stop claude self-updates mid-loop"
|
|
79
|
+
),
|
|
80
|
+
guarded_by=None,
|
|
81
|
+
current_state="active",
|
|
82
|
+
),
|
|
83
|
+
Defense(
|
|
84
|
+
name="startup_smoke_check",
|
|
85
|
+
value="6 checks (config / log_dir / agent_cli / git / prompt_file / prompt_smoke)",
|
|
86
|
+
codifies="R721 + #446 — _common.md frontmatter caused 4h/123-round silent burn",
|
|
87
|
+
guarded_by=None,
|
|
88
|
+
current_state="active",
|
|
89
|
+
),
|
|
90
|
+
Defense(
|
|
91
|
+
name="flock_concurrency",
|
|
92
|
+
value="agent-runner.lock",
|
|
93
|
+
codifies="Phase 1 design — prevent concurrent supervisors corrupting state",
|
|
94
|
+
guarded_by=None,
|
|
95
|
+
current_state="active",
|
|
96
|
+
),
|
|
97
|
+
Defense(
|
|
98
|
+
name="atomic_state_writes",
|
|
99
|
+
value="tmp + fsync + rename",
|
|
100
|
+
codifies="Data integrity — crashes never leave half-written state files",
|
|
101
|
+
guarded_by=Path("tests/invariants/test_atomic_write_enforced.py"),
|
|
102
|
+
current_state="active",
|
|
103
|
+
),
|
|
104
|
+
Defense(
|
|
105
|
+
name="event_kind_registry",
|
|
106
|
+
value="KNOWN_EVENT_KINDS frozenset (14 kinds)",
|
|
107
|
+
codifies="Prevent events.emit() typos / unregistered kinds slipping past CI",
|
|
108
|
+
guarded_by=Path("tests/invariants/test_event_kind_registry.py"),
|
|
109
|
+
current_state="active",
|
|
110
|
+
),
|
|
111
|
+
]
|
agent_runner/events.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Structured event emitter — JSON Lines, monthly UTC naming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
KNOWN_EVENT_KINDS = frozenset(
|
|
11
|
+
{
|
|
12
|
+
"round_start",
|
|
13
|
+
"agent_spawn",
|
|
14
|
+
"agent_exit",
|
|
15
|
+
"dirty_detected",
|
|
16
|
+
"orphan_stashed",
|
|
17
|
+
"orphan_idempotent_skip",
|
|
18
|
+
"orphan_stash_failed",
|
|
19
|
+
"round_timeout_kill",
|
|
20
|
+
"sigterm_received",
|
|
21
|
+
"status_recovered",
|
|
22
|
+
"smoke_check_failed",
|
|
23
|
+
"round_end",
|
|
24
|
+
# Phase 2 monitor events
|
|
25
|
+
"monitor_alert_emitted", # any detector fired (info/warning)
|
|
26
|
+
"monitor_auto_stop_triggered", # critical alert triggered service stop
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def now_iso_ms() -> str:
|
|
32
|
+
"""UTC ISO-8601 timestamp with millisecond precision and trailing 'Z'.
|
|
33
|
+
|
|
34
|
+
Shared helper — also used by metrics.py and runner.py for matching format.
|
|
35
|
+
"""
|
|
36
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def emit(log_dir: Path, kind: str, **fields: Any) -> None:
|
|
40
|
+
"""Append one event line to events-YYYY-MM.jsonl (UTC).
|
|
41
|
+
|
|
42
|
+
Caller must ensure ``log_dir`` exists (runner.run_one_round does this once
|
|
43
|
+
per round; tests use the ``tmp_log_dir`` fixture which creates it).
|
|
44
|
+
"""
|
|
45
|
+
if kind not in KNOWN_EVENT_KINDS:
|
|
46
|
+
raise ValueError(f"unknown event kind: {kind!r}")
|
|
47
|
+
now = datetime.now(UTC)
|
|
48
|
+
month = now.strftime("%Y-%m")
|
|
49
|
+
ts = now.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
50
|
+
path = log_dir / f"events-{month}.jsonl"
|
|
51
|
+
payload = {"ts": ts, "event": kind, **fields}
|
|
52
|
+
with path.open("a", encoding="utf-8") as f:
|
|
53
|
+
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Service-lifecycle primitives: PID files, signal sending, service-mode detection.
|
|
2
|
+
|
|
3
|
+
Used by ``cli/serve_cmd.py`` (writes serve.pid) and ``cli/service_cmd.py``
|
|
4
|
+
(reads PID + signals it for stop/kill/cancel). Also tells callers whether the
|
|
5
|
+
project is managed by systemd-user or a plain serve process.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from agent_runner.api_types import ServiceMode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class PIDFile:
|
|
19
|
+
path: Path
|
|
20
|
+
|
|
21
|
+
def write(self, pid: int) -> None:
|
|
22
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self.path.write_text(str(pid))
|
|
24
|
+
|
|
25
|
+
def read(self) -> int | None:
|
|
26
|
+
try:
|
|
27
|
+
return int(self.path.read_text().strip())
|
|
28
|
+
except (FileNotFoundError, ValueError):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def unlink(self) -> None:
|
|
32
|
+
self.path.unlink(missing_ok=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def pid_alive(pid: int) -> bool:
|
|
36
|
+
"""True iff the process exists and we have permission to signal it."""
|
|
37
|
+
try:
|
|
38
|
+
os.kill(pid, 0)
|
|
39
|
+
except (ProcessLookupError, PermissionError):
|
|
40
|
+
return False
|
|
41
|
+
except OSError:
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def send_signal_to_pid(pid: int, sig: int) -> bool:
|
|
47
|
+
"""Send ``sig`` to ``pid``. Returns True on success, False if pid gone / forbidden."""
|
|
48
|
+
try:
|
|
49
|
+
os.kill(pid, sig)
|
|
50
|
+
return True
|
|
51
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _user_systemd_dir() -> Path:
|
|
56
|
+
"""Patchable in tests."""
|
|
57
|
+
return Path.home() / ".config" / "systemd" / "user"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def detect_service_mode(project: str, *, log_dir: Path) -> ServiceMode:
|
|
61
|
+
"""Decide how this project is managed: systemd unit, plain pidfile, or nothing."""
|
|
62
|
+
unit = _user_systemd_dir() / f"agent-runner@{project}.service"
|
|
63
|
+
if unit.exists():
|
|
64
|
+
return ServiceMode.SYSTEMD_USER
|
|
65
|
+
if (log_dir / "serve.pid").exists():
|
|
66
|
+
return ServiceMode.PID_FILE
|
|
67
|
+
return ServiceMode.NONE
|
agent_runner/metrics.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Cross-platform metrics — mem (system) + disk (log_dir partition) + load + cpu.
|
|
2
|
+
|
|
3
|
+
Same monthly UTC naming convention as events.jsonl.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import psutil
|
|
15
|
+
|
|
16
|
+
from agent_runner.events import now_iso_ms
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def collect(disk_path: Path) -> dict[str, Any]:
|
|
20
|
+
vm = psutil.virtual_memory()
|
|
21
|
+
du = psutil.disk_usage(str(disk_path))
|
|
22
|
+
out: dict[str, Any] = {
|
|
23
|
+
"mem_total_mb": vm.total // (1024 * 1024),
|
|
24
|
+
"mem_available_mb": vm.available // (1024 * 1024),
|
|
25
|
+
"mem_used_pct": round(vm.percent, 1),
|
|
26
|
+
"disk_total_gb": round(du.total / (1024**3), 1),
|
|
27
|
+
"disk_free_gb": round(du.free / (1024**3), 1),
|
|
28
|
+
"disk_used_pct": round(du.percent, 1),
|
|
29
|
+
}
|
|
30
|
+
try:
|
|
31
|
+
load = os.getloadavg()
|
|
32
|
+
out["load_1m"] = round(load[0], 2)
|
|
33
|
+
out["load_5m"] = round(load[1], 2)
|
|
34
|
+
out["load_15m"] = round(load[2], 2)
|
|
35
|
+
except (AttributeError, OSError):
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
out["cpu_pct"] = round(psutil.cpu_percent(interval=None), 1)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def log_metrics(
|
|
45
|
+
log_dir: Path,
|
|
46
|
+
*,
|
|
47
|
+
event: str = "periodic",
|
|
48
|
+
round_num: int | None = None,
|
|
49
|
+
phase: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Append one metrics sample to metrics-YYYY-MM.jsonl (UTC).
|
|
52
|
+
|
|
53
|
+
Caller must ensure ``log_dir`` exists. Disk-usage stats are sampled from
|
|
54
|
+
``log_dir``'s partition (callers that wanted a different mount can reach
|
|
55
|
+
for psutil directly — single-mount is the only real-world case so far).
|
|
56
|
+
"""
|
|
57
|
+
month = datetime.now(UTC).strftime("%Y-%m")
|
|
58
|
+
path = log_dir / f"metrics-{month}.jsonl"
|
|
59
|
+
payload: dict[str, Any] = {
|
|
60
|
+
"ts": now_iso_ms(),
|
|
61
|
+
"event": event,
|
|
62
|
+
**collect(log_dir),
|
|
63
|
+
}
|
|
64
|
+
if round_num is not None:
|
|
65
|
+
payload["round_num"] = round_num
|
|
66
|
+
if phase is not None:
|
|
67
|
+
payload["phase"] = phase
|
|
68
|
+
with path.open("a", encoding="utf-8") as f:
|
|
69
|
+
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|