feat(agent-loop): improve output format with header, URLs, and no prefix (#133)
- Add `---------------------- Starting YYYY-MM-DD HH:MMZ` header at each run - Remove `[agent_loop]` prefix from all output lines - Show full Codeberg URL for CI runs instead of bare run ID - Show full issue URL and title when referencing issues - Store issue_title in state file so "still running" messages include the title Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3bd38e7a69
commit
e46dc2961f
+39
-24
@@ -39,6 +39,7 @@ os.environ["PATH"] = f"{Path.home()}/.nix-profile/bin:{os.environ.get('PATH', '/
|
||||
# ── configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
REPO = "guettli/sharedinbox"
|
||||
REPO_URL = f"https://codeberg.org/{REPO}"
|
||||
STATE_FILE = Path.home() / ".sharedinbox-agent-state.json"
|
||||
MAX_AGENT_AGE_SECONDS = 3600 # 1 hour
|
||||
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects" / (
|
||||
@@ -54,6 +55,14 @@ LABEL_PRIO_HIGH = "Prio/High"
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _issue_url(number: int) -> str:
|
||||
return f"{REPO_URL}/issues/{number}"
|
||||
|
||||
|
||||
def _ci_run_url(run_id: int) -> str:
|
||||
return f"{REPO_URL}/actions/runs/{run_id}"
|
||||
|
||||
|
||||
def _tea(*args: str) -> dict | list | None:
|
||||
"""Run a `tea api` command and return parsed JSON, or None on 204."""
|
||||
method = "GET"
|
||||
@@ -145,18 +154,16 @@ def _read_state() -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _write_state(pid: int, issue: int | None, kind: str) -> None:
|
||||
STATE_FILE.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pid": pid,
|
||||
"issue": issue,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"type": kind,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
def _write_state(pid: int, issue: int | None, kind: str, issue_title: str | None = None) -> None:
|
||||
data: dict = {
|
||||
"pid": pid,
|
||||
"issue": issue,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"type": kind,
|
||||
}
|
||||
if issue_title is not None:
|
||||
data["issue_title"] = issue_title
|
||||
STATE_FILE.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
def _clear_state() -> None:
|
||||
@@ -191,8 +198,8 @@ def _start_agent(prompt: str, session_name: str) -> int:
|
||||
proc.stdin.write(b"\n")
|
||||
proc.stdin.close()
|
||||
|
||||
print(f"[agent_loop] Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f"[agent_loop] Resume: claude --resume {shlex.quote(session_name)}")
|
||||
print(f"Started agent pid={proc.pid}, log={log_file}")
|
||||
print(f" Resume: claude --resume {shlex.quote(session_name)}")
|
||||
return proc.pid
|
||||
|
||||
|
||||
@@ -235,7 +242,7 @@ def _kill_agent(state: dict) -> None:
|
||||
def cmd_list() -> int:
|
||||
"""List recent agent-loop sessions, newest first."""
|
||||
if not CLAUDE_PROJECTS_DIR.exists():
|
||||
print(f"[agent_loop] No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
|
||||
print(f"No sessions found (directory missing: {CLAUDE_PROJECTS_DIR})")
|
||||
return 0
|
||||
|
||||
sessions = []
|
||||
@@ -259,7 +266,7 @@ def cmd_list() -> int:
|
||||
sessions.append((jsonl.stat().st_mtime, agent_name, session_id))
|
||||
|
||||
if not sessions:
|
||||
print("[agent_loop] No agent sessions found.")
|
||||
print("No agent sessions found.")
|
||||
return 0
|
||||
|
||||
sessions.sort(reverse=True)
|
||||
@@ -278,6 +285,9 @@ def cmd_list() -> int:
|
||||
|
||||
|
||||
def _run_loop() -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"---------------------- Starting {now.strftime('%Y-%m-%d %H:%MZ')}")
|
||||
|
||||
state = _read_state()
|
||||
|
||||
# ── 1. Agent already running? ─────────────────────────────────────────────
|
||||
@@ -287,20 +297,25 @@ def _run_loop() -> int:
|
||||
kind = state.get("type", "issue")
|
||||
pid = state.get("pid", "?")
|
||||
|
||||
issue_title = state.get("issue_title", "")
|
||||
issue_ref = (
|
||||
f"{_issue_url(issue)} {issue_title}".strip() if issue else str(issue)
|
||||
)
|
||||
|
||||
if age > MAX_AGENT_AGE_SECONDS:
|
||||
print(
|
||||
f"[agent_loop] Agent pid={pid!r} (issue #{issue}) "
|
||||
f"Agent pid={pid!r} ({issue_ref}) "
|
||||
f"has been running for {age/60:.0f} min — aborting."
|
||||
)
|
||||
_kill_agent(state)
|
||||
_clear_state()
|
||||
if issue:
|
||||
_set_labels(issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||
print(f"[agent_loop] Set issue #{issue} to State/Question.")
|
||||
print(f"Set {_issue_url(issue)} to State/Question.")
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"[agent_loop] Agent pid={pid!r} ({kind}, issue #{issue}) "
|
||||
f"Agent pid={pid!r} ({kind}, {issue_ref}) "
|
||||
f"still running ({age/60:.0f} min). Waiting."
|
||||
)
|
||||
return 0
|
||||
@@ -313,11 +328,11 @@ def _run_loop() -> int:
|
||||
run = _latest_ci_run()
|
||||
|
||||
if run and run.get("status") == "running":
|
||||
print(f"[agent_loop] CI run {run['id']} is still running. Waiting.")
|
||||
print(f"CI run {_ci_run_url(run['id'])} is still running. Waiting.")
|
||||
return 0
|
||||
|
||||
if run and run.get("status") in ("failure", "error"):
|
||||
print(f"[agent_loop] CI run {run['id']} failed — starting fix agent.")
|
||||
print(f"CI run {_ci_run_url(run['id'])} failed — starting fix agent.")
|
||||
prompt = (
|
||||
"The Codeberg CI for guettli/sharedinbox just failed. "
|
||||
f"The CI run ID is {run['id']}. "
|
||||
@@ -333,7 +348,7 @@ def _run_loop() -> int:
|
||||
# CI is ok (or no run) — find a Ready issue.
|
||||
issues = _ready_issues()
|
||||
if not issues:
|
||||
print("[agent_loop] No issues with State/Ready. Nothing to do.")
|
||||
print("No issues with State/Ready. Nothing to do.")
|
||||
return 0
|
||||
|
||||
issue = issues[0]
|
||||
@@ -341,7 +356,7 @@ def _run_loop() -> int:
|
||||
issue_title = issue["title"]
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
print(f"[agent_loop] Starting agent for issue #{issue_number}: {issue_title}")
|
||||
print(f"Starting agent for {_issue_url(issue_number)} {issue_title}")
|
||||
|
||||
# Mark InProgress before starting so the next cron tick sees it even if
|
||||
# the agent hasn't had time to do so yet.
|
||||
@@ -371,7 +386,7 @@ Instructions:
|
||||
"""
|
||||
|
||||
pid = _start_agent(prompt, f"issue-{issue_number}")
|
||||
_write_state(pid, issue_number, "issue")
|
||||
_write_state(pid, issue_number, "issue", issue_title)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for agent_loop.py."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -14,6 +15,16 @@ sys.path.insert(0, str(Path(__file__).parent))
|
||||
import agent_loop
|
||||
|
||||
|
||||
class TestUrlHelpers(unittest.TestCase):
|
||||
def test_issue_url(self):
|
||||
url = agent_loop._issue_url(128)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/issues/128")
|
||||
|
||||
def test_ci_run_url(self):
|
||||
url = agent_loop._ci_run_url(4145144)
|
||||
self.assertEqual(url, "https://codeberg.org/guettli/sharedinbox/actions/runs/4145144")
|
||||
|
||||
|
||||
class TestStateFile(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json")
|
||||
@@ -54,6 +65,16 @@ class TestStateFile(unittest.TestCase):
|
||||
agent_loop._clear_state()
|
||||
self.assertIsNone(agent_loop._read_state())
|
||||
|
||||
def test_write_state_stores_issue_title(self):
|
||||
agent_loop._write_state(42, 10, "issue", "My Test Issue")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertEqual(data["issue_title"], "My Test Issue")
|
||||
|
||||
def test_write_state_omits_issue_title_when_none(self):
|
||||
agent_loop._write_state(42, None, "ci-fix")
|
||||
data = json.loads(Path(self._tmp.name).read_text())
|
||||
self.assertNotIn("issue_title", data)
|
||||
|
||||
|
||||
class TestAgentAlive(unittest.TestCase):
|
||||
def test_own_pid_is_alive(self):
|
||||
@@ -203,5 +224,54 @@ class TestMain(unittest.TestCase):
|
||||
mock_start.assert_not_called()
|
||||
|
||||
|
||||
class TestOutputFormat(unittest.TestCase):
|
||||
"""Verify output format: no [agent_loop] prefix, URLs in output."""
|
||||
|
||||
def test_output_starts_with_header(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
first_line = buf.getvalue().splitlines()[0]
|
||||
self.assertTrue(first_line.startswith("---------------------- Starting "),
|
||||
f"Unexpected first line: {first_line!r}")
|
||||
|
||||
def test_no_agent_loop_prefix_in_output(self):
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[]), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertNotIn("[agent_loop]", buf.getvalue())
|
||||
|
||||
def test_ci_run_output_contains_url(self):
|
||||
run = {"id": 4145144, "status": "running"}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=run), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/actions/runs/4145144",
|
||||
buf.getvalue())
|
||||
|
||||
def test_issue_output_contains_url_and_title(self):
|
||||
issue = {"number": 128, "title": "Fix something", "body": "", "labels": []}
|
||||
buf = io.StringIO()
|
||||
with patch("agent_loop._read_state", return_value=None), \
|
||||
patch("agent_loop._latest_ci_run", return_value=None), \
|
||||
patch("agent_loop._ready_issues", return_value=[issue]), \
|
||||
patch("agent_loop._set_labels"), \
|
||||
patch("agent_loop._start_agent", return_value=99), \
|
||||
patch("agent_loop._write_state"), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
agent_loop._run_loop()
|
||||
output = buf.getvalue()
|
||||
self.assertIn("https://codeberg.org/guettli/sharedinbox/issues/128", output)
|
||||
self.assertIn("Fix something", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user