forgeo-cli 0.3.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.
forgeo/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Forgeo: a scheduled software forgeo that executes backlog tasks
2
+ on the main branch and refactors when idle."""
3
+
4
+ try:
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _pkg_version
7
+
8
+ __version__ = _pkg_version("forgeo-cli")
9
+ except PackageNotFoundError: # standalone binary: no installed package metadata
10
+ __version__ = "0.3.0"
forgeo/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running Forgeo as a module: ``python -m forgeo``."""
2
+
3
+ from forgeo.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
forgeo/agent.py ADDED
@@ -0,0 +1,332 @@
1
+ """The coding agent: any shell command that implements the task.
2
+
3
+ The configured command is run with the repository as its working directory.
4
+ The task is delivered to the agent as the ``FORGEO_TASK`` environment
5
+ variable (title, description, acceptance criteria) so any CLI coding tool
6
+ (aider, claude, a custom script, ...) can consume it. The exit code decides
7
+ the outcome:
8
+
9
+ * ``0`` — SUCCESS, the work is committed and pushed,
10
+ * ``blocked_exit_code`` (default ``2``) — BLOCKED, the agent needs human
11
+ input; its output ends up in the blocker file,
12
+ * anything else — ERROR, the task fails and the agent's changes are
13
+ discarded.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import os
20
+ import shutil
21
+ import signal
22
+ from abc import ABC, abstractmethod
23
+ from collections import deque
24
+
25
+ from forgeo.models import ExecutionResult, ExecutionStatus, RepoContext, Task
26
+
27
+ # Keep only the most recent process output lines so a chatty agent cannot
28
+ # blow memory; header/footer status lines are added around this window.
29
+ _MAX_OUTPUT_LINES = 1000
30
+
31
+ # Hard cap on how long we wait for the output streams to reach EOF after the
32
+ # process is done (or killed). Grandchildren that escaped the process group
33
+ # (daemonized agents, docker containers) may hold the pipes open forever;
34
+ # Forgeo must never hang on that.
35
+ _DEFAULT_DRAIN_TIMEOUT_SECONDS = 30.0
36
+
37
+
38
+ def _kill_process_group(proc: asyncio.subprocess.Process) -> None:
39
+ """SIGKILL the whole process group of a spawned agent.
40
+
41
+ ``proc.kill()`` only kills the direct child (for a string command, the
42
+ shell); the real agent often runs as a grandchild, which would survive
43
+ as an orphan and keep the output pipes open. The agent is spawned in its
44
+ own session (``start_new_session=True``), so ``proc.pid`` is its process
45
+ group id and killing the group reaps the entire process tree. Falls back
46
+ to killing just the direct child when the group is already gone.
47
+ """
48
+ try:
49
+ os.killpg(proc.pid, signal.SIGKILL)
50
+ except ProcessLookupError:
51
+ proc.kill()
52
+ except PermissionError:
53
+ proc.kill()
54
+
55
+
56
+ class SandboxUnavailableError(RuntimeError):
57
+ """The configured sandbox backend cannot run on this host."""
58
+
59
+
60
+ class BaseAgent(ABC):
61
+ """Uniform interface for invoking a coding agent on a task."""
62
+
63
+ name: str = "base"
64
+
65
+ @abstractmethod
66
+ async def run_task(
67
+ self,
68
+ task: Task,
69
+ context: RepoContext,
70
+ *,
71
+ command: str | list[str] | None = None,
72
+ timeout_seconds: float | None = None,
73
+ ) -> ExecutionResult:
74
+ """Execute one task and return its result.
75
+
76
+ ``command`` and ``timeout_seconds`` optionally override the agent's
77
+ configured values for this single task; ``None`` means "use the
78
+ configured default".
79
+
80
+ Implementations must never raise for expected agent failures — they
81
+ should encode them as ``ExecutionResult(status=ERROR)``.
82
+ """
83
+
84
+
85
+ class ShellAgent(BaseAgent):
86
+ """Runs ``ForgeoConfig.agent_command`` in a subprocess."""
87
+
88
+ name = "shell"
89
+
90
+ def __init__(
91
+ self,
92
+ command: str | list[str],
93
+ *,
94
+ timeout_seconds: float | None = None,
95
+ drain_timeout_seconds: float = _DEFAULT_DRAIN_TIMEOUT_SECONDS,
96
+ env: dict[str, str] | None = None,
97
+ blocked_exit_code: int = 2,
98
+ ) -> None:
99
+ self.command = command
100
+ self.timeout_seconds = timeout_seconds
101
+ self.drain_timeout_seconds = drain_timeout_seconds
102
+ self.env = dict(env or {})
103
+ self.blocked_exit_code = blocked_exit_code
104
+
105
+ @staticmethod
106
+ async def _drain_stream(
107
+ stream: asyncio.StreamReader | None,
108
+ prefix: str,
109
+ lines: deque[str],
110
+ ) -> None:
111
+ """Read ``stream`` line-by-line into ``lines`` until EOF."""
112
+ if stream is None:
113
+ return
114
+ while True:
115
+ raw = await stream.readline()
116
+ if not raw:
117
+ break
118
+ text = raw.decode(errors="replace").rstrip("\r\n")
119
+ lines.append(f"[{prefix}] {text}")
120
+
121
+ async def _spawn(
122
+ self,
123
+ command: str | list[str],
124
+ cwd: str,
125
+ env: dict[str, str],
126
+ ) -> asyncio.subprocess.Process:
127
+ """Start the agent process; returns a handle to it.
128
+
129
+ Subclasses (e.g. sandboxes) override this to change *how* the command
130
+ runs; the exit-code contract and output handling stay in ``run_task``.
131
+ """
132
+ if isinstance(command, str):
133
+ return await asyncio.create_subprocess_shell(
134
+ command,
135
+ cwd=cwd,
136
+ env=env,
137
+ stdout=asyncio.subprocess.PIPE,
138
+ stderr=asyncio.subprocess.PIPE,
139
+ start_new_session=True,
140
+ )
141
+ return await asyncio.create_subprocess_exec(
142
+ *command,
143
+ cwd=cwd,
144
+ env=env,
145
+ stdout=asyncio.subprocess.PIPE,
146
+ stderr=asyncio.subprocess.PIPE,
147
+ start_new_session=True,
148
+ )
149
+
150
+ async def run_task(
151
+ self,
152
+ task: Task,
153
+ context: RepoContext,
154
+ *,
155
+ command: str | list[str] | None = None,
156
+ timeout_seconds: float | None = None,
157
+ ) -> ExecutionResult:
158
+ """Run the configured command once for the task.
159
+
160
+ ``command`` and ``timeout_seconds`` override this agent's configured
161
+ values for this run when given; otherwise the configured defaults are
162
+ used.
163
+ """
164
+ command = command if command is not None else self.command
165
+ timeout = timeout_seconds if timeout_seconds is not None else self.timeout_seconds
166
+ logs: list[str] = [f"[{self.name}] Running task {task.id} ({task.title})"]
167
+ env = {
168
+ **os.environ,
169
+ **self.env,
170
+ "FORGEO_TASK": task.instruction,
171
+ "FORGEO_REPO": str(context.repo_path),
172
+ "FORGEO_BRANCH": context.branch,
173
+ }
174
+
175
+ try:
176
+ proc = await self._spawn(command, str(context.repo_path), env)
177
+ except FileNotFoundError as exc:
178
+ logs.append(f"[{self.name}] Command not found: {exc}")
179
+ return ExecutionResult(
180
+ status=ExecutionStatus.ERROR,
181
+ output_logs=logs,
182
+ error=f"command not found: {exc}",
183
+ )
184
+
185
+ stream_lines: deque[str] = deque(maxlen=_MAX_OUTPUT_LINES)
186
+ readers = asyncio.gather(
187
+ self._drain_stream(proc.stdout, "stdout", stream_lines),
188
+ self._drain_stream(proc.stderr, "stderr", stream_lines),
189
+ )
190
+
191
+ timed_out = False
192
+ try:
193
+ await asyncio.wait_for(proc.wait(), timeout=timeout)
194
+ except TimeoutError:
195
+ timed_out = True
196
+ _kill_process_group(proc)
197
+ await proc.wait()
198
+ # Always finish draining so lines already written (and any residual
199
+ # after kill) are captured before we build the result. Bounded: a
200
+ # grandchild that escaped the process group (daemonized agent, docker
201
+ # container) may hold the pipes open, and Forgeo must never hang
202
+ # waiting for EOF that will not come.
203
+ try:
204
+ await asyncio.wait_for(readers, timeout=self.drain_timeout_seconds)
205
+ except TimeoutError:
206
+ logs.append(
207
+ f"[{self.name}] Output streams stayed open beyond "
208
+ f"{self.drain_timeout_seconds:g}s; proceeding without them."
209
+ )
210
+ logs.extend(stream_lines)
211
+
212
+ if timed_out:
213
+ label = f" after {timeout:g}s" if timeout is not None else ""
214
+ logs.append(f"[{self.name}] Execution timed out{label}; process killed.")
215
+ return ExecutionResult(
216
+ status=ExecutionStatus.ERROR,
217
+ output_logs=logs,
218
+ error=f"timed out{label}",
219
+ )
220
+
221
+ if proc.returncode == 0:
222
+ logs.append(f"[{self.name}] Task {task.id} finished successfully (exit 0).")
223
+ return ExecutionResult(
224
+ status=ExecutionStatus.SUCCESS,
225
+ output_logs=logs,
226
+ exit_code=proc.returncode,
227
+ )
228
+
229
+ if proc.returncode == self.blocked_exit_code:
230
+ logs.append(f"[{self.name}] Task {task.id} needs human input (exit {proc.returncode}).")
231
+ return ExecutionResult(
232
+ status=ExecutionStatus.BLOCKED,
233
+ output_logs=logs,
234
+ questions=[line for line in logs if line.startswith(("[stdout]", "[stderr]"))],
235
+ exit_code=proc.returncode,
236
+ )
237
+
238
+ logs.append(f"[{self.name}] Task {task.id} failed with exit code {proc.returncode}.")
239
+ return ExecutionResult(
240
+ status=ExecutionStatus.ERROR,
241
+ output_logs=logs,
242
+ error=f"exit code {proc.returncode}",
243
+ exit_code=proc.returncode,
244
+ )
245
+
246
+
247
+ # Environment variables set by ``run_task`` and forwarded into the container.
248
+ _SANDBOX_FORWARDED_ENV = ("FORGEO_TASK", "FORGEO_REPO", "FORGEO_BRANCH")
249
+
250
+
251
+ class DockerSandboxAgent(ShellAgent):
252
+ """Runs the agent command inside a ``docker run --rm`` container.
253
+
254
+ The repository is bind-mounted at its absolute path (so the agent's edits
255
+ land on the host checkout), ``FORGEO_TASK`` and ``agent_env`` are passed
256
+ through as environment variables, and networking is disabled
257
+ (``--network none``) unless a network is configured explicitly. Agent
258
+ credentials/config are only visible inside the container when listed in
259
+ ``mounts`` (mounted read-only at the same path). The container exit code
260
+ is mapped exactly like the shell agent's: ``0`` success,
261
+ ``blocked_exit_code`` needs human input, anything else is an error.
262
+
263
+ The image is expected to contain the agent CLI (e.g. ``claude``) and a
264
+ POSIX shell (``sh``) for string commands.
265
+ """
266
+
267
+ name = "docker"
268
+
269
+ def __init__(
270
+ self,
271
+ command: str | list[str],
272
+ *,
273
+ image: str,
274
+ network: str = "none",
275
+ mounts: list[str] | None = None,
276
+ timeout_seconds: float | None = None,
277
+ env: dict[str, str] | None = None,
278
+ blocked_exit_code: int = 2,
279
+ ) -> None:
280
+ if not (image or "").strip():
281
+ raise ValueError("docker sandbox requires an image")
282
+ if shutil.which("docker") is None:
283
+ raise SandboxUnavailableError(
284
+ "agent_sandbox: docker is configured but the `docker` binary was "
285
+ "not found on PATH."
286
+ )
287
+ super().__init__(
288
+ command,
289
+ timeout_seconds=timeout_seconds,
290
+ env=env,
291
+ blocked_exit_code=blocked_exit_code,
292
+ )
293
+ self.image = image
294
+ self.network = network
295
+ self.mounts = [mount for mount in (mounts or []) if mount]
296
+
297
+ def _docker_args(
298
+ self,
299
+ command: str | list[str],
300
+ cwd: str,
301
+ env: dict[str, str],
302
+ ) -> list[str]:
303
+ """Build the ``docker run`` argv for one agent execution."""
304
+ args = ["docker", "run", "--rm", "--network", self.network, "-w", cwd]
305
+ args += ["-v", f"{cwd}:{cwd}"]
306
+ forwarded = set(_SANDBOX_FORWARDED_ENV) | set(self.env)
307
+ for key in sorted(forwarded):
308
+ if key in env:
309
+ args += ["-e", f"{key}={env[key]}"]
310
+ for mount in self.mounts:
311
+ args += ["-v", f"{mount}:{mount}:ro"]
312
+ args.append(self.image)
313
+ if isinstance(command, str):
314
+ args += ["sh", "-c", command]
315
+ else:
316
+ args += list(command)
317
+ return args
318
+
319
+ async def _spawn(
320
+ self,
321
+ command: str | list[str],
322
+ cwd: str,
323
+ env: dict[str, str],
324
+ ) -> asyncio.subprocess.Process:
325
+ return await asyncio.create_subprocess_exec(
326
+ *self._docker_args(command, cwd, env),
327
+ cwd=cwd,
328
+ env=env,
329
+ stdout=asyncio.subprocess.PIPE,
330
+ stderr=asyncio.subprocess.PIPE,
331
+ start_new_session=True,
332
+ )
forgeo/backlog.py ADDED
@@ -0,0 +1,206 @@
1
+ """The backlog: a single human-readable JSON file of tasks.
2
+
3
+ Forgeo pulls the oldest ``OPEN`` task from here. Edit this file
4
+ directly to add, remove, or reopen tasks (e.g. set a ``BLOCKED`` task back
5
+ to ``OPEN`` once the human input has been provided). Writes are atomic and
6
+ serialized through an asyncio lock.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ from collections import Counter
15
+ from datetime import UTC, datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from pydantic import ValidationError
20
+
21
+ from forgeo.io import atomic_write_text
22
+ from forgeo.models import Task, TaskStatus
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ #: The task fields the web console may edit through ``update_task``.
27
+ EDITABLE_TASK_FIELDS = frozenset(
28
+ {
29
+ "title",
30
+ "description",
31
+ "acceptance_criteria",
32
+ "dependencies",
33
+ "files_to_modify",
34
+ "agent_command",
35
+ "agent_timeout_seconds",
36
+ }
37
+ )
38
+
39
+
40
+ def oldest_open_task(tasks: list[Task]) -> Task | None:
41
+ """Return the oldest OPEN task (smallest ``created_at``), or ``None``."""
42
+ open_tasks = [task for task in tasks if task.status is TaskStatus.OPEN]
43
+ if not open_tasks:
44
+ return None
45
+ return min(open_tasks, key=lambda task: task.created_at)
46
+
47
+
48
+ def backlog_status_counts(tasks: list[Task]) -> dict[str, int]:
49
+ """Count tasks by status; always includes every known status key."""
50
+ counts = {status.value: 0 for status in TaskStatus}
51
+ counts.update(Counter(task.status.value for task in tasks))
52
+ return counts
53
+
54
+
55
+ class JSONBacklog:
56
+ """A backlog stored in a single JSON document on disk."""
57
+
58
+ def __init__(self, path: str | Path) -> None:
59
+ self.path = Path(path)
60
+ self._lock = asyncio.Lock()
61
+
62
+ async def list_tasks(self) -> list[Task]:
63
+ """Return all tasks, in the order they were created."""
64
+ store = await self._read()
65
+ return [self._to_task(entry) for entry in store["tasks"]]
66
+
67
+ async def fetch_next_task(self) -> Task | None:
68
+ """Return the oldest OPEN task, or ``None`` when there is none."""
69
+ return oldest_open_task(await self.list_tasks())
70
+
71
+ async def get_task(self, task_id: str) -> Task | None:
72
+ """Return a task by id, or ``None`` if it does not exist."""
73
+ store = await self._read()
74
+ for entry in store["tasks"]:
75
+ if entry["id"] == task_id:
76
+ return self._to_task(entry)
77
+ return None
78
+
79
+ async def create_task(self, task: Task) -> Task:
80
+ """Persist ``task``, rejecting duplicate ids with a ``ValueError``."""
81
+ async with self._lock:
82
+ store = await self._read()
83
+ if any(entry["id"] == task.id for entry in store["tasks"]):
84
+ raise ValueError(f"Task id already exists in backlog: {task.id!r}")
85
+ store["tasks"].append(task.model_dump(mode="json"))
86
+ await self._write(store)
87
+ return task
88
+
89
+ async def update_status(self, task_id: str, status: TaskStatus) -> Task | None:
90
+ """Transition a task's status, bumping its ``updated_at`` timestamp."""
91
+ async with self._lock:
92
+ store = await self._read()
93
+ updated: Task | None = None
94
+ for entry in store["tasks"]:
95
+ if entry["id"] == task_id:
96
+ entry["status"] = status.value
97
+ entry["updated_at"] = datetime.now(UTC).isoformat()
98
+ updated = self._to_task(entry)
99
+ break
100
+ if updated is not None:
101
+ await self._write(store)
102
+ return updated
103
+
104
+ async def update_task(
105
+ self, task_id: str, updates: dict[str, Any]
106
+ ) -> Task | None:
107
+ """Update a task's editable fields, bumping its ``updated_at``.
108
+
109
+ ``updates`` may contain any of :data:`EDITABLE_TASK_FIELDS`; ``id``,
110
+ ``status``, ``created_at`` and ``updated_at`` are never replaced
111
+ (except ``updated_at``, bumped to now). Unknown fields and invalid
112
+ values raise a ``ValueError``; an unknown ``task_id`` returns ``None``
113
+ (mirroring :meth:`update_status`). Writes go through the same lock
114
+ and atomic-replace path as the other mutators.
115
+ """
116
+ if not isinstance(updates, dict):
117
+ raise TypeError("updates must be a dict of task fields")
118
+ unknown = set(updates) - EDITABLE_TASK_FIELDS
119
+ if unknown:
120
+ raise ValueError(
121
+ f"unknown task field(s): {', '.join(sorted(unknown))}"
122
+ )
123
+ for field in ("title", "description"):
124
+ if field in updates and not isinstance(updates[field], str):
125
+ raise ValueError(f"{field} must be a string")
126
+ if "title" in updates and not updates["title"].strip():
127
+ raise ValueError("title must be a non-blank string")
128
+ if "description" in updates and not updates["description"].strip():
129
+ raise ValueError("description must be a non-blank string")
130
+ for field in ("acceptance_criteria", "dependencies", "files_to_modify"):
131
+ if field in updates and (
132
+ not isinstance(updates[field], list)
133
+ or not all(isinstance(item, str) for item in updates[field])
134
+ ):
135
+ raise ValueError(f"{field} must be a list of strings")
136
+
137
+ async with self._lock:
138
+ store = await self._read()
139
+ for entry in store["tasks"]:
140
+ if entry["id"] != task_id:
141
+ continue
142
+ candidate = dict(entry)
143
+ candidate.update(updates)
144
+ candidate["updated_at"] = datetime.now(UTC).isoformat()
145
+ try:
146
+ task = Task.model_validate(candidate)
147
+ except ValidationError as exc:
148
+ raise ValueError(f"invalid task field(s): {exc}") from exc
149
+ normalized = task.model_dump(mode="json")
150
+ for field in updates:
151
+ entry[field] = normalized[field]
152
+ entry["updated_at"] = normalized["updated_at"]
153
+ await self._write(store)
154
+ return task
155
+ return None
156
+
157
+ # ------------------------------------------------------------------ #
158
+ # Internal persistence helpers #
159
+ # ------------------------------------------------------------------ #
160
+
161
+ async def _read(self) -> dict[str, Any]:
162
+ """Load the store from disk, tolerating a missing or corrupt file."""
163
+ if not self.path.exists():
164
+ return {"tasks": []}
165
+ try:
166
+ data = json.loads(self.path.read_text(encoding="utf-8"))
167
+ except (json.JSONDecodeError, OSError):
168
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%f")
169
+ corrupt_path = self.path.with_name(f"{self.path.name}.corrupt-{timestamp}")
170
+ try:
171
+ self.path.rename(corrupt_path)
172
+ logger.warning(
173
+ "Corrupt backlog at %s renamed to %s; starting with empty store",
174
+ self.path,
175
+ corrupt_path,
176
+ )
177
+ except OSError:
178
+ logger.warning(
179
+ "Corrupt backlog at %s could not be preserved; starting with empty store",
180
+ self.path,
181
+ )
182
+ return {"tasks": []}
183
+ if not isinstance(data, dict):
184
+ return {"tasks": []}
185
+ tasks = data.get("tasks")
186
+ return {"tasks": tasks if isinstance(tasks, list) else []}
187
+
188
+ async def _write(self, store: dict[str, Any]) -> None:
189
+ """Atomically persist the store (temp file + rename)."""
190
+ atomic_write_text(
191
+ self.path,
192
+ json.dumps(store, indent=2, ensure_ascii=False) + "\n",
193
+ )
194
+
195
+ @staticmethod
196
+ def _to_task(entry: dict[str, Any]) -> Task:
197
+ """Validate a stored dictionary back into a Task, skipping corrupt rows."""
198
+ try:
199
+ return Task.model_validate(entry)
200
+ except ValidationError:
201
+ return Task(
202
+ id=str(entry.get("id", "<unknown>")),
203
+ title="<unparsable task>",
204
+ description="<unparsable task>",
205
+ status=TaskStatus.FAILED,
206
+ )