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/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]: ...
@@ -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
@@ -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")