Skip to content

Commit 21cd1ab

Browse files
authored
Merge pull request #10 from callowayproject/phase5
Enhance error visibility and fix polling issues in core tasks
2 parents 64a1e71 + ff63ae3 commit 21cd1ab

13 files changed

Lines changed: 1212 additions & 17 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ celerybeat.pid
116116

117117
# Environments
118118
.env
119+
.api-env
119120
.venv
120121
env/
121122
venv/

config.example.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ polling:
1515
interval_seconds: 60 # how often to poll GitHub (seconds)
1616

1717
repos:
18-
- owner: my-org
19-
name: my-repo
18+
- owner: callowayproject
19+
name: bump-my-version
2020
agents:
2121
- type: issue-triage
2222
allow_close: false # set to true to allow the agent to close issues

foreman/__main__.py

Lines changed: 208 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,213 @@
1-
"""Foreman CLI entrypoint."""
1+
"""Foreman CLI entrypoint.
22
3+
Usage::
34
4-
def main() -> None:
5-
"""Run the Foreman harness."""
6-
raise NotImplementedError("Entrypoint not yet implemented — see Task 12.")
5+
foreman start --config config.yaml
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import asyncio
12+
import sys
13+
from pathlib import Path
14+
from typing import TYPE_CHECKING, Any
15+
16+
import structlog
17+
import uvicorn
18+
19+
from foreman.config import ConfigError, load_config
20+
from foreman.memory import MemoryStore
21+
from foreman.poller import GitHubPoller
22+
from foreman.routers import Router, RoutingError
23+
from foreman.server import Dispatcher, app
24+
25+
if TYPE_CHECKING:
26+
from foreman.config import ForemanConfig, RepoConfig
27+
28+
logger = structlog.get_logger(__name__)
29+
30+
#: Default memory DB path.
31+
_DEFAULT_DB_PATH = Path.home() / ".agent-harness" / "memory.db"
32+
33+
34+
def _build_parser() -> argparse.ArgumentParser:
35+
"""Build the argument parser for the Foreman CLI.
36+
37+
Returns:
38+
Configured :class:`argparse.ArgumentParser`.
39+
"""
40+
parser = argparse.ArgumentParser(
41+
prog="foreman",
42+
description="Foreman — AI OSS co-maintainer harness",
43+
)
44+
subparsers = parser.add_subparsers(dest="command")
45+
46+
start = subparsers.add_parser("start", help="Start the Foreman harness")
47+
start.add_argument(
48+
"--config",
49+
required=True,
50+
metavar="CONFIG",
51+
help="Path to the YAML configuration file",
52+
)
53+
start.add_argument(
54+
"--db",
55+
default=str(_DEFAULT_DB_PATH),
56+
metavar="DB_PATH",
57+
help="Path to the SQLite memory database (default: ~/.agent-harness/memory.db)",
58+
)
59+
start.add_argument(
60+
"--host",
61+
default="0.0.0.0",
62+
metavar="HOST",
63+
help="Host to bind the HTTP server to (default: 0.0.0.0)",
64+
)
65+
start.add_argument(
66+
"--port",
67+
type=int,
68+
default=8000,
69+
metavar="PORT",
70+
help="Port for the HTTP server (default: 8000)",
71+
)
72+
return parser
73+
74+
75+
def main(argv: list[str] | None = None) -> None:
76+
"""Parse CLI arguments and run Foreman.
77+
78+
Args:
79+
argv: Argument list (defaults to ``sys.argv[1:]`` when ``None``).
80+
81+
Raises:
82+
SystemExit: On invalid arguments or configuration errors.
83+
"""
84+
parser = _build_parser()
85+
args = parser.parse_args(argv)
86+
87+
if args.command is None:
88+
parser.print_help(sys.stderr)
89+
sys.exit(2)
90+
91+
if args.command == "start":
92+
_run_start(args)
93+
94+
95+
def _run_start(args: Any) -> None:
96+
"""Execute the ``start`` sub-command.
97+
98+
Validates config, initialises the memory DB, then runs the poller and
99+
HTTP server concurrently inside a single asyncio event loop.
100+
101+
Args:
102+
args: Parsed namespace from argparse.
103+
104+
Raises:
105+
SystemExit: On config validation failure.
106+
"""
107+
# 1. Load and validate config — fail fast with a clear message.
108+
try:
109+
config = load_config(args.config)
110+
except ConfigError as exc:
111+
print(f"Error: {exc}", file=sys.stderr)
112+
sys.exit(1)
113+
114+
# 2. Initialise memory DB.
115+
db_path = Path(args.db)
116+
memory = MemoryStore(db_path)
117+
118+
# 3. Create core components.
119+
poller = GitHubPoller(token=str(config.identity.github_token), memory=memory)
120+
dispatcher = Dispatcher(config=config, memory=memory)
121+
122+
logger.info(
123+
"Foreman initialised",
124+
config=args.config,
125+
db=str(db_path),
126+
repos=[f"{r.owner}/{r.name}" for r in config.repos],
127+
poll_interval_seconds=config.polling.interval_seconds,
128+
)
129+
130+
# 4. Run the poller and HTTP server concurrently.
131+
asyncio.run(_run_loop(config, memory, poller, dispatcher, args.host, args.port))
132+
133+
134+
async def _run_loop(
135+
config: ForemanConfig,
136+
memory: MemoryStore,
137+
poller: GitHubPoller,
138+
dispatcher: Dispatcher,
139+
host: str,
140+
port: int,
141+
) -> None:
142+
"""Run the poll loop and HTTP server concurrently.
143+
144+
The poller is started as an asyncio task alongside the uvicorn server.
145+
On shutdown (SIGINT/SIGTERM), the poller task is cancelled cleanly.
146+
147+
Args:
148+
config: Validated runtime configuration.
149+
memory: Open memory store (passed through for context).
150+
poller: Initialised :class:`~foreman.poller.GitHubPoller`.
151+
dispatcher: Initialised :class:`~foreman.server.Dispatcher`.
152+
host: Bind address for the HTTP server.
153+
port: Port for the HTTP server.
154+
"""
155+
router = Router(config)
156+
157+
async def on_event(repo_config: RepoConfig, event: dict[str, Any]) -> None:
158+
"""Handle one poller event: route it and dispatch to the appropriate agent.
159+
160+
Args:
161+
repo_config: The repo configuration that produced this event.
162+
event: Event dict with ``repo``, ``issue_number``, and ``payload``.
163+
"""
164+
repo = event["repo"]
165+
issue_number = event["issue_number"]
166+
logger.info("Issue event", repo=repo, issue_number=issue_number)
167+
try:
168+
route_target = router.route("issue.triage", repo)
169+
except RoutingError:
170+
logger.warning("Repo not in config — skipping", repo=repo)
171+
return
172+
if route_target is None:
173+
logger.debug("No agent handles issue.triage for this repo", repo=repo)
174+
return
175+
logger.info(
176+
"Routing to agent",
177+
repo=repo,
178+
issue_number=issue_number,
179+
agent_url=route_target.url,
180+
)
181+
await dispatcher.dispatch(event, route_target)
182+
183+
uv_server = uvicorn.Server(uvicorn.Config(app, host=host, port=port, log_config=None))
184+
185+
logger.info(
186+
"Foreman started — polling every %d seconds, server on %s:%d",
187+
config.polling.interval_seconds,
188+
host,
189+
port,
190+
)
191+
192+
poller_task = asyncio.create_task(poller.run(config.repos, config.polling.interval_seconds, on_event))
193+
194+
def _on_poller_done(task: asyncio.Task) -> None:
195+
"""Log unexpected poller task termination."""
196+
if not task.cancelled():
197+
exc = task.exception()
198+
if exc is not None:
199+
logger.critical("Poller task crashed unexpectedly", exc_info=exc)
200+
201+
poller_task.add_done_callback(_on_poller_done)
202+
203+
try:
204+
await uv_server.serve()
205+
finally:
206+
poller_task.cancel()
207+
try:
208+
await poller_task
209+
except asyncio.CancelledError:
210+
logger.info("Poller stopped cleanly")
7211

8212

9213
if __name__ == "__main__":

foreman/memory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class MemoryStore:
6060
def __init__(self, db_path: Path) -> None:
6161
self.db_path = db_path
6262
db_path.parent.mkdir(parents=True, exist_ok=True)
63-
self._conn = sqlite3.connect(str(db_path))
63+
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
6464
self._conn.executescript(_SCHEMA)
6565
self._conn.commit()
6666

foreman/poller.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ def poll_repo(self, repo_config: RepoConfig) -> list[dict[str, Any]]:
8181

8282
issues = list(gh_repo.get_issues(**get_issues_kwargs))
8383

84+
logger.info(
85+
"Polled repo",
86+
repo=repo_name,
87+
issues_found=len(issues),
88+
since=last_polled.isoformat() if last_polled else "beginning",
89+
)
90+
8491
events: list[dict[str, Any]] = []
8592
for issue in issues:
8693
if issue.user.login in collaborator_logins:
@@ -171,7 +178,20 @@ async def _poll_with_backoff(
171178
)
172179
return
173180
else:
174-
raise
181+
if exc.status == 401:
182+
logger.critical(
183+
"Bad GitHub credentials — check GITHUB_TOKEN; skipping repo this cycle",
184+
repo=f"{repo_cfg.owner}/{repo_cfg.name}",
185+
status=exc.status,
186+
)
187+
else:
188+
logger.error(
189+
"GitHub API error polling repo; skipping this cycle",
190+
repo=f"{repo_cfg.owner}/{repo_cfg.name}",
191+
status=exc.status,
192+
error=str(exc),
193+
)
194+
return
175195

176196
# ------------------------------------------------------------------
177197
# Continuous polling loop

foreman/routers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""Router submodule."""
2+
3+
from foreman.routers.agent import Router, RouteTarget, RoutingError
4+
5+
__all__ = ["RouteTarget", "Router", "RoutingError"]

0 commit comments

Comments
 (0)