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/runner.py ADDED
@@ -0,0 +1,236 @@
1
+ """Main round orchestration. Conducts the other modules; does not touch
2
+ subprocess / git / prompt details directly. Pure rotation — no event-driven
3
+ branches based on prior round state (§7 IMMUTABLE).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import fcntl
9
+ import os
10
+ import sys
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from agent_runner import (
17
+ agent_runtime,
18
+ context_store,
19
+ events,
20
+ metrics,
21
+ prompt_loader,
22
+ startup_check,
23
+ vcs_state,
24
+ )
25
+ from agent_runner.config import Config
26
+ from agent_runner.events import now_iso_ms
27
+
28
+
29
+ class LockHeldError(RuntimeError):
30
+ pass
31
+
32
+
33
+ def _acquire_lock_or_raise(lock_path: Path) -> int:
34
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
35
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
36
+ try:
37
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
38
+ except BlockingIOError as e:
39
+ os.close(fd)
40
+ raise LockHeldError(f"another agent-runner is holding {lock_path}") from e
41
+ return fd
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RoundResult:
46
+ round_num: int
47
+ exit_code: int
48
+ duration_s: float
49
+ timed_out: bool
50
+ dirty_files: list[str]
51
+ stashed: bool
52
+
53
+
54
+ def _phase_for(round_num: int, phases: list[str] | None) -> tuple[str | None, int]:
55
+ if not phases:
56
+ return None, 0
57
+ idx = (round_num - 1) % len(phases)
58
+ return phases[idx], idx
59
+
60
+
61
+ def _previous_block(prev: context_store.Status | None, dirty_last: bool) -> dict[str, Any] | None:
62
+ if prev is None:
63
+ return None
64
+ return {
65
+ "exit_code": prev.last_exit_code,
66
+ "duration_s": prev.last_duration_s,
67
+ "ended_at": prev.last_completed_at,
68
+ "had_dirty_tree": dirty_last,
69
+ }
70
+
71
+
72
+ def _round_context_for_prompt(
73
+ round_num: int,
74
+ started_at: str,
75
+ phase: str | None,
76
+ orphan_block: dict[str, Any] | None,
77
+ ) -> dict[str, Any]:
78
+ ctx: dict[str, Any] = {"round_num": round_num, "started_at": started_at}
79
+ if phase is not None:
80
+ ctx["phase"] = phase
81
+ if orphan_block is not None:
82
+ ctx["orphan_stash"] = orphan_block
83
+ return ctx
84
+
85
+
86
+ def run_one_round(cfg: Config) -> RoundResult:
87
+ log_dir = cfg.runtime.log_dir
88
+ log_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ # L3: startup precondition battery (R721 + #446 defense)
91
+ failures = [r for r in startup_check.run_battery(cfg) if not r.ok]
92
+ if failures:
93
+ for r in failures:
94
+ print(
95
+ f"STARTUP FAIL: {r.name}: {r.reason} | how-to-fix: {r.how_to_fix}",
96
+ file=sys.stderr,
97
+ )
98
+ events.emit(log_dir, "smoke_check_failed", reason=f"{r.name}: {r.reason}")
99
+ sys.exit(1)
100
+
101
+ # Concurrency lock (per-project)
102
+ lock_fd = _acquire_lock_or_raise(log_dir / "agent-runner.lock")
103
+ try:
104
+ return _run_one_round_inner(cfg)
105
+ finally:
106
+ os.close(lock_fd)
107
+
108
+
109
+ def _run_one_round_inner(cfg: Config) -> RoundResult:
110
+ log_dir = cfg.runtime.log_dir
111
+
112
+ prev_status = context_store.read_status(log_dir)
113
+ if (log_dir / "status.json").exists() and prev_status is None:
114
+ events.emit(log_dir, "status_recovered", reason="status.json could not be parsed")
115
+
116
+ round_num = (prev_status.round_num if prev_status else 0) + 1
117
+ phase, phase_idx = _phase_for(round_num, cfg.phases)
118
+ started_at = now_iso_ms()
119
+
120
+ orphan = context_store.read_orphan_state(log_dir)
121
+ orphan_block: dict[str, Any] | None = None
122
+ if orphan and orphan.stashed_ref:
123
+ orphan_block = {
124
+ "ref": orphan.stashed_ref,
125
+ "message": orphan.stash_message,
126
+ "files": orphan.files,
127
+ }
128
+
129
+ previous_block = _previous_block(prev_status, dirty_last=bool(orphan))
130
+
131
+ context_store.write_round_context(
132
+ log_dir,
133
+ round_num=round_num,
134
+ started_at=started_at,
135
+ phase=phase,
136
+ previous=previous_block,
137
+ orphan_stash=orphan_block,
138
+ )
139
+ events.emit(log_dir, "round_start", round_num=round_num, phase=phase)
140
+ metrics.log_metrics(log_dir, event="round_start", round_num=round_num, phase=phase)
141
+
142
+ rounds_dir = log_dir / "rounds"
143
+ rounds_dir.mkdir(exist_ok=True)
144
+ log_path = rounds_dir / f"R{round_num}-{datetime.now(UTC).strftime('%Y%m%dT%H%M%S')}.log"
145
+
146
+ prompt = prompt_loader.assemble_prompt(
147
+ cfg.prompt.file,
148
+ context=_round_context_for_prompt(round_num, started_at, phase, orphan_block),
149
+ inject_context=cfg.prompt.inject_context,
150
+ )
151
+
152
+ events.emit(log_dir, "agent_spawn", round_num=round_num, timeout_s=cfg.runtime.round_timeout_s)
153
+ result = agent_runtime.run(
154
+ command=cfg.agent.command,
155
+ prompt_arg_template=cfg.agent.prompt_arg_template,
156
+ prompt=prompt,
157
+ timeout_s=cfg.runtime.round_timeout_s,
158
+ log_path=log_path,
159
+ env_extra=agent_runtime.merge_critical_envs({}),
160
+ )
161
+ events.emit(
162
+ log_dir,
163
+ "agent_exit",
164
+ round_num=round_num,
165
+ exit_code=result.exit_code,
166
+ duration_s=result.duration_s,
167
+ timed_out=result.timed_out,
168
+ )
169
+
170
+ dirty = vcs_state.detect_dirty_files(cfg.runtime.work_dir)
171
+ if dirty:
172
+ events.emit(log_dir, "dirty_detected", round_num=round_num, files=dirty[:20])
173
+
174
+ stashed = False
175
+ if dirty and not result.timed_out and result.exit_code == 0:
176
+ ref = vcs_state.stash_orphan(
177
+ cfg.runtime.work_dir,
178
+ round_num=round_num,
179
+ phase=phase,
180
+ idempotency_s=cfg.vcs.stash_idempotency_s,
181
+ )
182
+ if ref is not None:
183
+ context_store.write_orphan_state(
184
+ log_dir,
185
+ context_store.OrphanState(
186
+ round_num=round_num,
187
+ files=dirty,
188
+ stashed_ref=ref.sha,
189
+ stash_message=ref.message,
190
+ timestamp=now_iso_ms(),
191
+ phase=phase,
192
+ ),
193
+ )
194
+ events.emit(
195
+ log_dir,
196
+ "orphan_stashed",
197
+ round_num=round_num,
198
+ ref=ref.sha,
199
+ reason="clean_exit_with_dirty_tree",
200
+ )
201
+ stashed = True
202
+ elif not dirty:
203
+ context_store.clear_orphan_state(log_dir)
204
+
205
+ if result.timed_out:
206
+ events.emit(
207
+ log_dir,
208
+ "round_timeout_kill",
209
+ round_num=round_num,
210
+ reason=f"exceeded round_timeout_s={cfg.runtime.round_timeout_s}",
211
+ )
212
+
213
+ completed_at = now_iso_ms()
214
+ context_store.write_status(
215
+ log_dir,
216
+ context_store.Status(
217
+ round_num=round_num,
218
+ running=False,
219
+ last_completed_at=completed_at,
220
+ last_exit_code=result.exit_code,
221
+ last_duration_s=result.duration_s,
222
+ current_phase=phase,
223
+ phase_index=phase_idx,
224
+ ),
225
+ )
226
+ metrics.log_metrics(log_dir, event="round_end", round_num=round_num, phase=phase)
227
+ events.emit(log_dir, "round_end", round_num=round_num)
228
+
229
+ return RoundResult(
230
+ round_num=round_num,
231
+ exit_code=result.exit_code,
232
+ duration_s=result.duration_s,
233
+ timed_out=result.timed_out,
234
+ dirty_files=dirty,
235
+ stashed=stashed,
236
+ )
@@ -0,0 +1,124 @@
1
+ """Project scaffold for `agent-runner init`.
2
+
3
+ Writes three files into a git repo:
4
+ agent-runner.toml — copy of example template, project name substituted
5
+ prompts/main.md — neutral 8-line placeholder
6
+ .gitignore — append "logs/" if missing
7
+
8
+ Optionally commits in one step (default true via the CLI).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import subprocess # noqa: TID251 — scaffold needs git for the commit step
14
+ from pathlib import Path
15
+
16
+ from agent_runner.api_types import InitResult
17
+ from agent_runner.vcs_state import is_git_repo
18
+
19
+ _TOML_TEMPLATE = """\
20
+ # agent-runner.toml — generated by `agent-runner init`. Edit fields as needed.
21
+
22
+ [agent]
23
+ command = ["claude", "--model", "claude-opus-4-7",
24
+ "--dangerously-skip-permissions",
25
+ "--verbose", "--output-format", "stream-json"]
26
+ prompt_arg_template = ["-p", "{prompt}"]
27
+
28
+ [runtime]
29
+ work_dir = "."
30
+ log_dir = "~/.agent-runner/{project}/logs"
31
+ round_timeout_s = 1800
32
+ restart_delay_s = 3
33
+
34
+ [prompt]
35
+ file = "./prompts/main.md"
36
+ inject_context = true
37
+
38
+ # [phases] # optional — uncomment for phase rotation
39
+ # list = ["diverge", "converge", "refine"]
40
+
41
+ [vcs]
42
+ orphan_action = "stash"
43
+ stash_idempotency_s = 5
44
+
45
+ # [monitor] # optional — auto-stop policy overrides
46
+ # auto_stop_on = ["oauth_fail", "disk_critical"]
47
+ # disk_warning_pct = 90.0
48
+ # disk_critical_pct = 95.0
49
+
50
+ # [llm] # Phase 3 — reserved, not yet used
51
+ # endpoint = "anthropic"
52
+ # api_key_env = "ANTHROPIC_API_KEY"
53
+ # model = "claude-haiku-4-5"
54
+ """
55
+
56
+ _PROMPT_TEMPLATE = """\
57
+ # Agent Prompt
58
+
59
+ You are an autonomous agent working on this project. Each round begins with a
60
+ `round-context` JSON block prepended above this prompt — read it first.
61
+
62
+ If `round_num == 1`: orient yourself with the project structure (README, file tree).
63
+ If `previous.exit_code != 0`: investigate what went wrong before resuming.
64
+ If `orphan_stash` is present: decide salvage (`git stash pop`) or abandon (`git stash drop`).
65
+
66
+ Always: commit and push your work before exiting the round. The supervisor will
67
+ auto-stash if you forget, but explicit commits with meaningful messages are better.
68
+ """
69
+
70
+ _GITIGNORE_LINE = "logs/"
71
+
72
+
73
+ def scaffold_project(work_dir: Path, *, force: bool, commit: bool) -> InitResult:
74
+ if not is_git_repo(work_dir):
75
+ raise RuntimeError(f"{work_dir} is not a git working tree — run `git init` first")
76
+
77
+ toml_path = work_dir / "agent-runner.toml"
78
+ prompt_dir = work_dir / "prompts"
79
+ prompt_path = prompt_dir / "main.md"
80
+ gitignore_path = work_dir / ".gitignore"
81
+
82
+ if toml_path.exists() and not force:
83
+ raise FileExistsError(f"{toml_path} already exists; pass force=True to overwrite")
84
+
85
+ files_created: list[Path] = []
86
+
87
+ project = work_dir.resolve().name or "default"
88
+ toml_path.write_text(_TOML_TEMPLATE.replace("{project}", project))
89
+ files_created.append(toml_path)
90
+
91
+ prompt_dir.mkdir(parents=True, exist_ok=True)
92
+ if not prompt_path.exists() or force:
93
+ prompt_path.write_text(_PROMPT_TEMPLATE)
94
+ files_created.append(prompt_path)
95
+
96
+ existing = gitignore_path.read_text() if gitignore_path.exists() else ""
97
+ if _GITIGNORE_LINE not in existing.splitlines():
98
+ new_text = existing
99
+ if existing and not existing.endswith("\n"):
100
+ new_text += "\n"
101
+ new_text += _GITIGNORE_LINE + "\n"
102
+ gitignore_path.write_text(new_text)
103
+ files_created.append(gitignore_path)
104
+
105
+ committed = False
106
+ if commit:
107
+ subprocess.run(["git", "add", "."], cwd=work_dir, check=True)
108
+ r = subprocess.run(
109
+ [
110
+ "git",
111
+ "-c",
112
+ "commit.gpgsign=false",
113
+ "commit",
114
+ "-q",
115
+ "-m",
116
+ "chore: agent-runner initial config",
117
+ ],
118
+ cwd=work_dir,
119
+ capture_output=True,
120
+ text=True,
121
+ )
122
+ committed = r.returncode == 0 # may fail if nothing changed
123
+
124
+ return InitResult(work_dir=work_dir, files_created=files_created, committed=committed)
@@ -0,0 +1,74 @@
1
+ """systemd user-unit content generators for serve and monitor.
2
+
3
+ Two units per project:
4
+ agent-runner@<project>.service - runs `agent-runner serve`
5
+ agent-runner-monitor@<project>.service - runs `agent-runner monitor`
6
+
7
+ Install command writes these to ~/.config/systemd/user/. The graceful-stop
8
+ contract relies on KillSignal=SIGTERM + TimeoutStopSec=round_timeout_s+60.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from agent_runner.config import Config
16
+
17
+ _GRACE_S = 60
18
+
19
+
20
+ def serve_unit_filename(project: str) -> str:
21
+ return f"agent-runner@{project}.service"
22
+
23
+
24
+ def monitor_unit_filename(project: str) -> str:
25
+ return f"agent-runner-monitor@{project}.service"
26
+
27
+
28
+ def _config_path(cfg: Config) -> Path:
29
+ """Where the config TOML lives (always relative to work_dir for now)."""
30
+ return cfg.runtime.work_dir / "agent-runner.toml"
31
+
32
+
33
+ def render_serve_unit(cfg: Config, *, venv_bin: Path) -> str:
34
+ """Generate the serve systemd unit body."""
35
+ timeout_total = cfg.runtime.round_timeout_s + _GRACE_S
36
+ return (
37
+ f"[Unit]\n"
38
+ f"Description=Agent Runner Supervisor ({cfg.runtime.work_dir.name})\n"
39
+ f"After=network.target\n"
40
+ f"\n"
41
+ f"[Service]\n"
42
+ f"Type=simple\n"
43
+ f"WorkingDirectory={cfg.runtime.work_dir}\n"
44
+ f"ExecStart={venv_bin}/agent-runner serve "
45
+ f"--config {_config_path(cfg)}\n"
46
+ f"Restart=always\n"
47
+ f"RestartSec=3\n"
48
+ f"KillSignal=SIGTERM\n"
49
+ f"TimeoutStopSec={timeout_total}\n"
50
+ f"\n"
51
+ f"[Install]\n"
52
+ f"WantedBy=default.target\n"
53
+ )
54
+
55
+
56
+ def render_monitor_unit(cfg: Config, *, venv_bin: Path) -> str:
57
+ """Generate the monitor sidekick systemd unit body."""
58
+ return (
59
+ f"[Unit]\n"
60
+ f"Description=Agent Runner Monitor ({cfg.runtime.work_dir.name})\n"
61
+ f"After=network.target "
62
+ f"agent-runner@{cfg.runtime.work_dir.name}.service\n"
63
+ f"\n"
64
+ f"[Service]\n"
65
+ f"Type=simple\n"
66
+ f"WorkingDirectory={cfg.runtime.work_dir}\n"
67
+ f"ExecStart={venv_bin}/agent-runner monitor "
68
+ f"--config {_config_path(cfg)}\n"
69
+ f"Restart=always\n"
70
+ f"RestartSec=10\n"
71
+ f"\n"
72
+ f"[Install]\n"
73
+ f"WantedBy=default.target\n"
74
+ )
@@ -0,0 +1,132 @@
1
+ """Boot-time precondition battery. R721 + #446 lesson — fail loud before
2
+ spawning the agent so we never silent-burn rounds on broken config.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import shutil
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+
12
+ from agent_runner.config import Config
13
+ from agent_runner.prompt_loader import assemble_prompt
14
+
15
+ ESCAPE_HATCH_ENV = "AGENT_RUNNER_SKIP_STARTUP_CHECK"
16
+
17
+ _MIN_PROMPT_BYTES = 500
18
+ _FORBIDDEN_FIRST_CHARS = frozenset({"-", " ", "\n", "\t", "\r"})
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class CheckResult:
23
+ name: str
24
+ ok: bool
25
+ reason: str = ""
26
+ how_to_fix: str = ""
27
+
28
+
29
+ def _check_log_dir(cfg: Config) -> CheckResult:
30
+ try:
31
+ cfg.runtime.log_dir.mkdir(parents=True, exist_ok=True)
32
+ probe = cfg.runtime.log_dir / ".write_probe"
33
+ probe.write_text("x")
34
+ probe.unlink()
35
+ return CheckResult("log_dir_writable", True)
36
+ except OSError as e:
37
+ return CheckResult(
38
+ "log_dir_writable",
39
+ False,
40
+ reason=f"cannot create or write {cfg.runtime.log_dir}: {e}",
41
+ how_to_fix="chmod / chown the dir, or change runtime.log_dir in config",
42
+ )
43
+
44
+
45
+ def _check_agent_cli(cfg: Config) -> CheckResult:
46
+ if not cfg.agent.command:
47
+ return CheckResult("agent_cli_in_path", False, "agent.command is empty")
48
+ cli = cfg.agent.command[0]
49
+ if shutil.which(cli) is None:
50
+ return CheckResult(
51
+ "agent_cli_in_path",
52
+ False,
53
+ reason=f"{cli!r} not found on PATH",
54
+ how_to_fix=f"install {cli} or set agent.command[0] to its absolute path",
55
+ )
56
+ return CheckResult("agent_cli_in_path", True)
57
+
58
+
59
+ def _check_work_dir_is_git(cfg: Config) -> CheckResult:
60
+ from agent_runner.vcs_state import is_git_repo
61
+
62
+ if not is_git_repo(cfg.runtime.work_dir):
63
+ return CheckResult(
64
+ "work_dir_is_git_repo",
65
+ False,
66
+ reason=f"{cfg.runtime.work_dir} is not a git working tree",
67
+ how_to_fix="run `git init` in the work_dir, or change runtime.work_dir in config",
68
+ )
69
+ return CheckResult("work_dir_is_git_repo", True)
70
+
71
+
72
+ def _check_prompt_file(cfg: Config) -> CheckResult:
73
+ if not cfg.prompt.file.exists():
74
+ return CheckResult(
75
+ "prompt_file_exists",
76
+ False,
77
+ reason=f"{cfg.prompt.file} does not exist",
78
+ how_to_fix="create the prompt .md file or fix prompt.file in config",
79
+ )
80
+ return CheckResult("prompt_file_exists", True)
81
+
82
+
83
+ def _check_prompt_smoke(cfg: Config) -> CheckResult:
84
+ if not cfg.prompt.file.exists():
85
+ return CheckResult(
86
+ "prompt_smoke_passes",
87
+ False,
88
+ "prompt file missing — see prompt_file_exists",
89
+ )
90
+ try:
91
+ prompt = assemble_prompt(cfg.prompt.file, context=None, inject_context=False)
92
+ except Exception as e:
93
+ return CheckResult("prompt_smoke_passes", False, f"assembly failed: {e}")
94
+ if not prompt:
95
+ return CheckResult("prompt_smoke_passes", False, "assembled prompt is empty")
96
+ if prompt[0] in _FORBIDDEN_FIRST_CHARS:
97
+ return CheckResult(
98
+ "prompt_smoke_passes",
99
+ False,
100
+ reason=f"first char {prompt[0]!r} is forbidden (R721 — claude CLI rejects it)",
101
+ how_to_fix="ensure the prompt body does not start with -, space, or newline",
102
+ )
103
+ if len(prompt.encode("utf-8")) < _MIN_PROMPT_BYTES:
104
+ return CheckResult(
105
+ "prompt_smoke_passes",
106
+ False,
107
+ reason=(f"prompt is {len(prompt.encode('utf-8'))} bytes < {_MIN_PROMPT_BYTES} minimum"),
108
+ how_to_fix="add substantive content — a stub prompt suggests a broken config",
109
+ )
110
+ return CheckResult("prompt_smoke_passes", True)
111
+
112
+
113
+ def _check_config_loaded(cfg: Config) -> CheckResult:
114
+ # Already loaded if we're here; this slot exists to surface the check name in events.
115
+ return CheckResult("config_loaded", True)
116
+
117
+
118
+ CHECKS: list[Callable[[Config], CheckResult]] = [
119
+ _check_config_loaded,
120
+ _check_log_dir,
121
+ _check_agent_cli,
122
+ _check_work_dir_is_git,
123
+ _check_prompt_file,
124
+ _check_prompt_smoke,
125
+ ]
126
+
127
+
128
+ def run_battery(cfg: Config) -> list[CheckResult]:
129
+ """Run all checks. Returns empty list if escape hatch env is set."""
130
+ if os.environ.get(ESCAPE_HATCH_ENV, "").lower() in ("1", "true", "yes", "on"):
131
+ return []
132
+ return [check(cfg) for check in CHECKS]