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 +10 -0
- forgeo/__main__.py +6 -0
- forgeo/agent.py +332 -0
- forgeo/backlog.py +206 -0
- forgeo/central.py +620 -0
- forgeo/cli.py +759 -0
- forgeo/config.py +36 -0
- forgeo/daemon.py +209 -0
- forgeo/forgeo.py +446 -0
- forgeo/git.py +100 -0
- forgeo/instances.py +187 -0
- forgeo/io.py +30 -0
- forgeo/models.py +252 -0
- forgeo/notify.py +77 -0
- forgeo/runs.py +73 -0
- forgeo/setup.py +185 -0
- forgeo/web/central/central.css +380 -0
- forgeo/web/central/central.js +780 -0
- forgeo/web/central/index.html +41 -0
- forgeo/web/central/instance.html +189 -0
- forgeo/web/style.css +656 -0
- forgeo/web_common.py +92 -0
- forgeo_cli-0.3.0.dist-info/METADATA +146 -0
- forgeo_cli-0.3.0.dist-info/RECORD +27 -0
- forgeo_cli-0.3.0.dist-info/WHEEL +4 -0
- forgeo_cli-0.3.0.dist-info/entry_points.txt +2 -0
- forgeo_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
forgeo/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Project configuration loading: YAML <-> :class:`ForgeoConfig`.
|
|
2
|
+
|
|
3
|
+
Relative paths in the file are resolved against the file's own directory,
|
|
4
|
+
so a config file can live anywhere and still point at sibling directories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from forgeo.models import ForgeoConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config(path: str | Path) -> ForgeoConfig:
|
|
17
|
+
"""Load and validate a Forgeo YAML file.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
FileNotFoundError: If the file does not exist.
|
|
21
|
+
pydantic.ValidationError: If the payload does not match the schema.
|
|
22
|
+
"""
|
|
23
|
+
config_path = Path(path)
|
|
24
|
+
payload = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
|
25
|
+
config = ForgeoConfig.model_validate(payload)
|
|
26
|
+
base = config_path.parent.resolve()
|
|
27
|
+
updates: dict[str, Path | str] = {}
|
|
28
|
+
if not config.repo.is_absolute():
|
|
29
|
+
updates["repo"] = base / config.repo
|
|
30
|
+
if not config.backlog.is_absolute():
|
|
31
|
+
updates["backlog"] = base / config.backlog
|
|
32
|
+
if not config.blocker_file.is_absolute():
|
|
33
|
+
updates["blocker_file"] = base / config.blocker_file
|
|
34
|
+
if not Path(config.log_file).is_absolute():
|
|
35
|
+
updates["log_file"] = str(base / config.log_file)
|
|
36
|
+
return config if not updates else config.model_copy(update=updates)
|
forgeo/daemon.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""The scheduled forgeo daemon.
|
|
2
|
+
|
|
3
|
+
Wakes up every ``interval_minutes``, runs one cycle of the :class:`Forgeo`,
|
|
4
|
+
and sleeps. A lock file prevents two daemons from running on the same
|
|
5
|
+
forgeo. A per-run lock prevents two agents from ever working on the same
|
|
6
|
+
repository at the same time: when a run is still in progress at the next
|
|
7
|
+
wake-up, that iteration is skipped instead of killing the running agent.
|
|
8
|
+
Everything else is logged to the configured log file.
|
|
9
|
+
|
|
10
|
+
Live state (pid, started at, last outcome, next run) is written to a small
|
|
11
|
+
``daemon.state.json`` next to the backlog after every cycle, so external
|
|
12
|
+
observers (the central dashboard, the CLI) can read it without the daemon
|
|
13
|
+
serving any port. The file is written atomically; a crash mid-write never
|
|
14
|
+
corrupts it, and a missing/stale file simply reads as unknown state.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from datetime import UTC, datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from forgeo.forgeo import Forgeo
|
|
30
|
+
from forgeo.io import atomic_write_text
|
|
31
|
+
from forgeo.models import ForgeoConfig
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _take_flock(lock_path: str | Path) -> Any | None:
|
|
37
|
+
"""Open the lock file and take a non-blocking exclusive flock.
|
|
38
|
+
|
|
39
|
+
Uses ``fcntl`` flock so the lock is released automatically when the
|
|
40
|
+
process exits (even on crash). Returns ``None`` when another process
|
|
41
|
+
holds the lock. Falls back to no locking when ``fcntl`` is unavailable.
|
|
42
|
+
The file is opened without truncation so a failed acquire keeps the
|
|
43
|
+
running holder's recorded PID intact (``forgeo stop`` needs it).
|
|
44
|
+
"""
|
|
45
|
+
lock_file = Path(lock_path)
|
|
46
|
+
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
handle = lock_file.open("a+")
|
|
48
|
+
try:
|
|
49
|
+
import fcntl
|
|
50
|
+
except ImportError:
|
|
51
|
+
handle.truncate(0)
|
|
52
|
+
handle.write(f"pid={os.getpid()}\n")
|
|
53
|
+
handle.flush()
|
|
54
|
+
return handle
|
|
55
|
+
try:
|
|
56
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
57
|
+
except OSError:
|
|
58
|
+
handle.close()
|
|
59
|
+
return None
|
|
60
|
+
handle.truncate(0)
|
|
61
|
+
handle.write(f"pid={os.getpid()}\n")
|
|
62
|
+
handle.flush()
|
|
63
|
+
return handle
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def acquire_run_lock(lock_path: str | Path) -> Any:
|
|
67
|
+
"""Take an exclusive, non-blocking lock; returns the handle or ``None``.
|
|
68
|
+
|
|
69
|
+
The lock is released automatically when the process exits (even on
|
|
70
|
+
crash). Returns ``None`` when another daemon holds the lock.
|
|
71
|
+
"""
|
|
72
|
+
return _take_flock(lock_path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_lock_pid(lock_path: str | Path) -> int | None:
|
|
76
|
+
"""Return the PID recorded in the lock file, or ``None`` when unknown."""
|
|
77
|
+
try:
|
|
78
|
+
text = Path(lock_path).read_text(encoding="utf-8", errors="replace")
|
|
79
|
+
except OSError:
|
|
80
|
+
return None
|
|
81
|
+
for line in text.splitlines():
|
|
82
|
+
if line.startswith("pid="):
|
|
83
|
+
try:
|
|
84
|
+
return int(line.removeprefix("pid=").strip())
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_lock_held(lock_path: str | Path) -> bool:
|
|
91
|
+
"""Return True when another process currently holds the exclusive flock.
|
|
92
|
+
|
|
93
|
+
Does not create the lock file when it is missing. A leftover file with
|
|
94
|
+
no live holder counts as not held.
|
|
95
|
+
"""
|
|
96
|
+
lock_file = Path(lock_path)
|
|
97
|
+
if not lock_file.exists():
|
|
98
|
+
return False
|
|
99
|
+
try:
|
|
100
|
+
import fcntl
|
|
101
|
+
except ImportError:
|
|
102
|
+
return False
|
|
103
|
+
try:
|
|
104
|
+
handle = lock_file.open("r")
|
|
105
|
+
except OSError:
|
|
106
|
+
return False
|
|
107
|
+
try:
|
|
108
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
109
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
110
|
+
return False
|
|
111
|
+
except OSError:
|
|
112
|
+
return True
|
|
113
|
+
finally:
|
|
114
|
+
handle.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RunLock:
|
|
118
|
+
"""Per-iteration lock: one agent run at a time per forgeo.
|
|
119
|
+
|
|
120
|
+
Held for the duration of one cycle so that a run still in progress (an
|
|
121
|
+
overlong agent, an orphaned process after a daemon restart) makes the
|
|
122
|
+
next iteration skip instead of starting a second agent on the same
|
|
123
|
+
repository.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, lock_path: str | Path) -> None:
|
|
127
|
+
self.lock_path = Path(lock_path)
|
|
128
|
+
|
|
129
|
+
@contextmanager
|
|
130
|
+
def held(self) -> Iterator[bool]:
|
|
131
|
+
"""Acquire for the duration of the block; yields True when acquired."""
|
|
132
|
+
handle = _take_flock(self.lock_path)
|
|
133
|
+
try:
|
|
134
|
+
yield handle is not None
|
|
135
|
+
finally:
|
|
136
|
+
if handle is not None:
|
|
137
|
+
handle.close()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ForgeoDaemon:
|
|
141
|
+
"""Runs :class:`Forgeo` cycles on a fixed schedule until stopped."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, config: ForgeoConfig, forgeo: Forgeo) -> None:
|
|
144
|
+
self.config = config
|
|
145
|
+
self.forgeo = forgeo
|
|
146
|
+
self.interval_seconds: float = config.interval_minutes * 60.0
|
|
147
|
+
self.run_lock = RunLock(config.backlog.with_suffix(".run"))
|
|
148
|
+
self._stop_event = asyncio.Event()
|
|
149
|
+
self.pid: int = os.getpid()
|
|
150
|
+
self.started_at: datetime = datetime.now(UTC)
|
|
151
|
+
self.last_outcome: str | None = None
|
|
152
|
+
self.next_run_at: datetime | None = None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def state_file(self) -> Path:
|
|
156
|
+
"""The ``daemon.state.json`` path, next to the backlog's lock files."""
|
|
157
|
+
return self.config.backlog.with_suffix(".state.json")
|
|
158
|
+
|
|
159
|
+
def stop(self) -> None:
|
|
160
|
+
"""Request a graceful shutdown after the current cycle."""
|
|
161
|
+
self._stop_event.set()
|
|
162
|
+
|
|
163
|
+
def write_state(self) -> None:
|
|
164
|
+
"""Atomically persist the daemon's live state for external readers.
|
|
165
|
+
|
|
166
|
+
A missing or stale file is fine: readers treat it as unknown state.
|
|
167
|
+
"""
|
|
168
|
+
payload = {
|
|
169
|
+
"pid": self.pid,
|
|
170
|
+
"started_at": self.started_at.isoformat(),
|
|
171
|
+
"last_outcome": self.last_outcome,
|
|
172
|
+
"next_run_at": (
|
|
173
|
+
self.next_run_at.isoformat() if self.next_run_at is not None else None
|
|
174
|
+
),
|
|
175
|
+
}
|
|
176
|
+
path = self.state_file
|
|
177
|
+
atomic_write_text(path, json.dumps(payload, indent=2) + "\n")
|
|
178
|
+
|
|
179
|
+
async def run_forever(self) -> None:
|
|
180
|
+
"""Wake up on the schedule interval until ``stop()`` is called."""
|
|
181
|
+
logger.info(
|
|
182
|
+
"Forgeo %r started (repo=%s, interval=%s min, branch=%s).",
|
|
183
|
+
self.config.name,
|
|
184
|
+
self.config.repo,
|
|
185
|
+
self.config.interval_minutes,
|
|
186
|
+
self.config.branch,
|
|
187
|
+
)
|
|
188
|
+
self.write_state()
|
|
189
|
+
while not self._stop_event.is_set():
|
|
190
|
+
try:
|
|
191
|
+
with self.run_lock.held() as acquired:
|
|
192
|
+
if not acquired:
|
|
193
|
+
logger.info("Previous run still in progress; skipping this iteration.")
|
|
194
|
+
outcome = "skipped"
|
|
195
|
+
else:
|
|
196
|
+
outcome = await self.forgeo.run_cycle()
|
|
197
|
+
self.last_outcome = outcome
|
|
198
|
+
logger.info("Run finished: %s", outcome)
|
|
199
|
+
except Exception:
|
|
200
|
+
self.last_outcome = "error"
|
|
201
|
+
logger.exception("Run crashed; continuing on the next interval.")
|
|
202
|
+
self.next_run_at = datetime.now(UTC) + timedelta(seconds=self.interval_seconds)
|
|
203
|
+
self.write_state()
|
|
204
|
+
try:
|
|
205
|
+
await asyncio.wait_for(self._stop_event.wait(), timeout=self.interval_seconds)
|
|
206
|
+
except TimeoutError:
|
|
207
|
+
pass
|
|
208
|
+
self.write_state()
|
|
209
|
+
logger.info("Forgeo stopped.")
|
forgeo/forgeo.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""Forgeo: one scheduled run of one repository.
|
|
2
|
+
|
|
3
|
+
Each run does exactly one of three things:
|
|
4
|
+
|
|
5
|
+
1. A ``BLOCKED`` task exists -> write the blocker file (the detailed
|
|
6
|
+
explanation of what the human must do) and pause.
|
|
7
|
+
2. An ``OPEN`` task exists -> execute it with the agent, commit and push the
|
|
8
|
+
result on the main branch.
|
|
9
|
+
3. The backlog has nothing runnable -> run the agent in refactoring mode on
|
|
10
|
+
the same branch, committing and pushing whatever it improves.
|
|
11
|
+
|
|
12
|
+
Whenever the agent signals BLOCKED, its partial work is committed and pushed
|
|
13
|
+
(no branches, nothing lost) and a ``BLOCKER.md`` file is written outside the
|
|
14
|
+
repository with exactly what the human needs to do. Forgeo stays paused
|
|
15
|
+
while that file exists.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
|
|
24
|
+
from forgeo.agent import BaseAgent
|
|
25
|
+
from forgeo.backlog import JSONBacklog
|
|
26
|
+
from forgeo.git import GitError, GitManager
|
|
27
|
+
from forgeo.models import (
|
|
28
|
+
ExecutionResult,
|
|
29
|
+
ExecutionStatus,
|
|
30
|
+
ForgeoConfig,
|
|
31
|
+
RepoContext,
|
|
32
|
+
RunKind,
|
|
33
|
+
RunOutcome,
|
|
34
|
+
RunRecord,
|
|
35
|
+
Task,
|
|
36
|
+
TaskStatus,
|
|
37
|
+
)
|
|
38
|
+
from forgeo.notify import BlockedNotice, send_blocked_notice
|
|
39
|
+
from forgeo.runs import RunRecorder, runs_path_for
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _execution_outcome(status: ExecutionStatus) -> RunOutcome:
|
|
45
|
+
"""Map an agent execution status onto a run record outcome."""
|
|
46
|
+
return {
|
|
47
|
+
ExecutionStatus.SUCCESS: RunOutcome.SUCCESS,
|
|
48
|
+
ExecutionStatus.BLOCKED: RunOutcome.BLOCKED,
|
|
49
|
+
ExecutionStatus.ERROR: RunOutcome.ERROR,
|
|
50
|
+
}[status]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _subject_label(task: Task, *, is_refactor: bool) -> str:
|
|
54
|
+
"""Log subject naming the actor of a message."""
|
|
55
|
+
return "Refactoring pass" if is_refactor else f"Task {task.id}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class BlockerEntry:
|
|
60
|
+
"""One blocked run to explain in the blocker file."""
|
|
61
|
+
|
|
62
|
+
task: Task
|
|
63
|
+
result: ExecutionResult
|
|
64
|
+
instruction: str
|
|
65
|
+
is_refactor: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Forgeo:
|
|
69
|
+
"""Executes one scheduled run against a single repository."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
config: ForgeoConfig,
|
|
74
|
+
backlog: JSONBacklog,
|
|
75
|
+
agent: BaseAgent,
|
|
76
|
+
git: GitManager,
|
|
77
|
+
) -> None:
|
|
78
|
+
self.config = config
|
|
79
|
+
self.backlog = backlog
|
|
80
|
+
self.agent = agent
|
|
81
|
+
self.git = git
|
|
82
|
+
self.recorder = RunRecorder(runs_path_for(config.backlog))
|
|
83
|
+
self._last_task: Task | None = None
|
|
84
|
+
self._last_agent_result: ExecutionResult | None = None
|
|
85
|
+
self._last_commit_sha: str | None = None
|
|
86
|
+
self._blocked_tasks: list[Task] = []
|
|
87
|
+
|
|
88
|
+
async def run_cycle(self) -> str:
|
|
89
|
+
"""Execute one run; returns a short outcome label.
|
|
90
|
+
|
|
91
|
+
Outcomes: ``blocked``, ``task``, ``paused``, ``refactor``, ``dirty``.
|
|
92
|
+
Every completed cycle appends exactly one run record.
|
|
93
|
+
"""
|
|
94
|
+
started_at = datetime.now(UTC)
|
|
95
|
+
outcome = await self._run_cycle()
|
|
96
|
+
self._record_run(outcome, started_at)
|
|
97
|
+
return outcome
|
|
98
|
+
|
|
99
|
+
async def _run_cycle(self) -> str:
|
|
100
|
+
"""Execute one cycle without recording; returns its outcome label."""
|
|
101
|
+
self._last_task = None
|
|
102
|
+
self._last_agent_result = None
|
|
103
|
+
self._last_commit_sha = None
|
|
104
|
+
self._blocked_tasks = []
|
|
105
|
+
await self.git.a_ensure_branch(self.config.branch)
|
|
106
|
+
tasks = await self.backlog.list_tasks()
|
|
107
|
+
blocked = [t for t in tasks if t.status is TaskStatus.BLOCKED]
|
|
108
|
+
if blocked:
|
|
109
|
+
self._blocked_tasks = blocked
|
|
110
|
+
await self._write_blocker(
|
|
111
|
+
[
|
|
112
|
+
BlockerEntry(
|
|
113
|
+
task=task,
|
|
114
|
+
result=ExecutionResult(
|
|
115
|
+
status=ExecutionStatus.BLOCKED,
|
|
116
|
+
questions=[f"Task {task.id} is blocked; see the backlog."],
|
|
117
|
+
),
|
|
118
|
+
instruction=task.instruction,
|
|
119
|
+
)
|
|
120
|
+
for task in blocked
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
return "blocked"
|
|
124
|
+
|
|
125
|
+
task = await self.backlog.fetch_next_task()
|
|
126
|
+
if task is not None:
|
|
127
|
+
if not await self.git.a_is_clean():
|
|
128
|
+
logger.error(
|
|
129
|
+
"Working tree of %s is dirty; refusing to run task %s.",
|
|
130
|
+
self.config.repo,
|
|
131
|
+
task.id,
|
|
132
|
+
)
|
|
133
|
+
return "dirty"
|
|
134
|
+
await self._run_task(task)
|
|
135
|
+
return "task"
|
|
136
|
+
|
|
137
|
+
if self.config.blocker_file.exists():
|
|
138
|
+
logger.info("Blocker file present; forgeo paused until it is resolved.")
|
|
139
|
+
return "paused"
|
|
140
|
+
|
|
141
|
+
await self._refactor()
|
|
142
|
+
return "refactor"
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------ #
|
|
145
|
+
# Run history #
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
|
|
148
|
+
def _record_run(self, outcome: str, started_at: datetime) -> None:
|
|
149
|
+
"""Build and append the run record for a completed cycle."""
|
|
150
|
+
finished_at = datetime.now(UTC)
|
|
151
|
+
duration = round((finished_at - started_at).total_seconds(), 3)
|
|
152
|
+
self.recorder.append(
|
|
153
|
+
RunRecord(
|
|
154
|
+
started_at=started_at,
|
|
155
|
+
finished_at=finished_at,
|
|
156
|
+
kind=self._run_kind(outcome),
|
|
157
|
+
task_id=self._run_task_id(outcome),
|
|
158
|
+
task_title=self._run_task_title(outcome),
|
|
159
|
+
outcome=self._run_outcome(outcome),
|
|
160
|
+
agent_exit_code=self._run_exit_code(outcome),
|
|
161
|
+
commit_sha=self._last_commit_sha if outcome in ("task", "refactor") else None,
|
|
162
|
+
duration_seconds=duration,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _run_kind(self, outcome: str) -> RunKind | None:
|
|
167
|
+
if outcome in ("task", "blocked"):
|
|
168
|
+
return RunKind.TASK
|
|
169
|
+
if outcome == "refactor":
|
|
170
|
+
return RunKind.REFACTOR
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def _cycle_task(self, outcome: str) -> Task | None:
|
|
174
|
+
"""The task a finished cycle ran, when there is one to record."""
|
|
175
|
+
if outcome in ("task", "refactor"):
|
|
176
|
+
return self._last_task
|
|
177
|
+
if outcome == "blocked":
|
|
178
|
+
return self._blocked_tasks[0] if self._blocked_tasks else None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def _run_task_id(self, outcome: str) -> str | None:
|
|
182
|
+
task = self._cycle_task(outcome)
|
|
183
|
+
return task.id if task is not None else None
|
|
184
|
+
|
|
185
|
+
def _run_task_title(self, outcome: str) -> str | None:
|
|
186
|
+
task = self._cycle_task(outcome)
|
|
187
|
+
return task.title if task is not None else None
|
|
188
|
+
|
|
189
|
+
def _run_outcome(self, outcome: str) -> RunOutcome:
|
|
190
|
+
if outcome in ("task", "refactor"):
|
|
191
|
+
result = self._last_agent_result
|
|
192
|
+
if result is None:
|
|
193
|
+
return RunOutcome.ERROR
|
|
194
|
+
return _execution_outcome(result.status)
|
|
195
|
+
return {
|
|
196
|
+
"blocked": RunOutcome.BLOCKED,
|
|
197
|
+
"paused": RunOutcome.PAUSED,
|
|
198
|
+
"dirty": RunOutcome.DIRTY,
|
|
199
|
+
"skipped": RunOutcome.SKIPPED,
|
|
200
|
+
"error": RunOutcome.ERROR,
|
|
201
|
+
}.get(outcome, RunOutcome.ERROR)
|
|
202
|
+
|
|
203
|
+
def _run_exit_code(self, outcome: str) -> int | None:
|
|
204
|
+
if outcome not in ("task", "refactor"):
|
|
205
|
+
return None
|
|
206
|
+
result = self._last_agent_result
|
|
207
|
+
return result.exit_code if result is not None else None
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ #
|
|
210
|
+
# Task execution #
|
|
211
|
+
# ------------------------------------------------------------------ #
|
|
212
|
+
|
|
213
|
+
async def _run_task(self, task: Task) -> None:
|
|
214
|
+
"""Execute one task: agent run, then commit/push on the main branch."""
|
|
215
|
+
logger.info("Running task %s (%s)", task.id, task.title)
|
|
216
|
+
result, ok = await self._run_agent(
|
|
217
|
+
task,
|
|
218
|
+
instruction=task.instruction,
|
|
219
|
+
success_message=f"forgeo: {task.title} (#{task.id})",
|
|
220
|
+
blocked_message=f"forgeo: {task.title} (#{task.id}) [partial]",
|
|
221
|
+
command=task.agent_command,
|
|
222
|
+
timeout_seconds=task.agent_timeout_seconds,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if result.status is ExecutionStatus.BLOCKED:
|
|
226
|
+
await self.backlog.update_status(task.id, TaskStatus.BLOCKED)
|
|
227
|
+
if result.status is ExecutionStatus.SUCCESS and ok:
|
|
228
|
+
await self.backlog.update_status(task.id, TaskStatus.COMPLETED)
|
|
229
|
+
self.config.blocker_file.unlink(missing_ok=True)
|
|
230
|
+
logger.info("Task %s completed.", task.id)
|
|
231
|
+
elif result.status is ExecutionStatus.ERROR:
|
|
232
|
+
await self.backlog.update_status(task.id, TaskStatus.FAILED)
|
|
233
|
+
|
|
234
|
+
async def _run_agent(
|
|
235
|
+
self,
|
|
236
|
+
task: Task,
|
|
237
|
+
*,
|
|
238
|
+
instruction: str,
|
|
239
|
+
success_message: str,
|
|
240
|
+
blocked_message: str,
|
|
241
|
+
command: str | list[str] | None = None,
|
|
242
|
+
timeout_seconds: float | None = None,
|
|
243
|
+
is_refactor: bool = False,
|
|
244
|
+
) -> tuple[ExecutionResult, bool]:
|
|
245
|
+
"""Run the agent for one task or refactoring pass and apply the
|
|
246
|
+
shared SUCCESS / BLOCKED / ERROR side effects (see
|
|
247
|
+
:meth:`_handle_execution_result`).
|
|
248
|
+
|
|
249
|
+
Returns the execution result and whether the SUCCESS commit path
|
|
250
|
+
succeeded, so callers can apply backlog status transitions.
|
|
251
|
+
"""
|
|
252
|
+
self._last_task = task
|
|
253
|
+
result = await self.agent.run_task(
|
|
254
|
+
task,
|
|
255
|
+
RepoContext(repo_path=self.config.repo, branch=self.config.branch),
|
|
256
|
+
command=command,
|
|
257
|
+
timeout_seconds=timeout_seconds,
|
|
258
|
+
)
|
|
259
|
+
self._last_agent_result = result
|
|
260
|
+
ok = await self._handle_execution_result(
|
|
261
|
+
result,
|
|
262
|
+
task=task,
|
|
263
|
+
success_message=success_message,
|
|
264
|
+
blocked_message=blocked_message,
|
|
265
|
+
instruction=instruction,
|
|
266
|
+
is_refactor=is_refactor,
|
|
267
|
+
)
|
|
268
|
+
return result, ok
|
|
269
|
+
|
|
270
|
+
async def _handle_execution_result(
|
|
271
|
+
self,
|
|
272
|
+
result: ExecutionResult,
|
|
273
|
+
*,
|
|
274
|
+
task: Task,
|
|
275
|
+
success_message: str,
|
|
276
|
+
blocked_message: str,
|
|
277
|
+
instruction: str,
|
|
278
|
+
is_refactor: bool = False,
|
|
279
|
+
) -> bool:
|
|
280
|
+
"""Apply shared SUCCESS / BLOCKED / ERROR side effects for an agent run.
|
|
281
|
+
|
|
282
|
+
* SUCCESS — commit and push ``success_message``.
|
|
283
|
+
* BLOCKED — commit partial work under ``blocked_message``, then write
|
|
284
|
+
the blocker file explaining what the human must do.
|
|
285
|
+
* ERROR — hard-reset the working tree and log the failure.
|
|
286
|
+
|
|
287
|
+
Returns ``True`` only when the result was SUCCESS and the commit path
|
|
288
|
+
succeeded, so callers can apply backlog status transitions. This
|
|
289
|
+
method does not update task status itself (except indirectly when
|
|
290
|
+
``_commit_and_push`` fails and marks the task FAILED).
|
|
291
|
+
"""
|
|
292
|
+
if result.status is ExecutionStatus.SUCCESS:
|
|
293
|
+
return await self._commit_and_push(success_message, task=task)
|
|
294
|
+
|
|
295
|
+
if result.status is ExecutionStatus.BLOCKED:
|
|
296
|
+
if await self._commit_and_push(blocked_message, task=task):
|
|
297
|
+
entry = BlockerEntry(
|
|
298
|
+
task=task,
|
|
299
|
+
result=result,
|
|
300
|
+
instruction=instruction,
|
|
301
|
+
is_refactor=is_refactor,
|
|
302
|
+
)
|
|
303
|
+
await self._write_blocker([entry])
|
|
304
|
+
self._notify_blocked(entry)
|
|
305
|
+
logger.warning(
|
|
306
|
+
"%s is BLOCKED; blocker file written.",
|
|
307
|
+
_subject_label(task, is_refactor=is_refactor),
|
|
308
|
+
)
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
await self._discard_failed_work(task, result, is_refactor=is_refactor)
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
async def _discard_failed_work(
|
|
315
|
+
self,
|
|
316
|
+
task: Task,
|
|
317
|
+
result: ExecutionResult,
|
|
318
|
+
*,
|
|
319
|
+
is_refactor: bool = False,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Hard-reset agent changes after ERROR and log the failure detail."""
|
|
322
|
+
label = _subject_label(task, is_refactor=is_refactor)
|
|
323
|
+
try:
|
|
324
|
+
await self.git.a_reset_hard()
|
|
325
|
+
except GitError as exc:
|
|
326
|
+
logger.error("Could not discard work for %s: %s", label, exc)
|
|
327
|
+
detail = result.error or "no error detail provided"
|
|
328
|
+
logger.error("%s FAILED: %s", label, detail)
|
|
329
|
+
|
|
330
|
+
async def _fail(self, task: Task, result: ExecutionResult) -> None:
|
|
331
|
+
"""Discard the agent's work, mark the task FAILED, and log the error."""
|
|
332
|
+
await self._discard_failed_work(task, result)
|
|
333
|
+
await self.backlog.update_status(task.id, TaskStatus.FAILED)
|
|
334
|
+
|
|
335
|
+
async def _commit_and_push(self, message: str, *, task: Task) -> bool:
|
|
336
|
+
"""Commit everything on the main branch and push when a remote is set.
|
|
337
|
+
|
|
338
|
+
Returns ``False`` (and marks the task FAILED) when git refuses to
|
|
339
|
+
cooperate.
|
|
340
|
+
"""
|
|
341
|
+
try:
|
|
342
|
+
sha = await self.git.a_commit_all(message)
|
|
343
|
+
except GitError as exc:
|
|
344
|
+
self._last_commit_sha = None
|
|
345
|
+
await self._fail(
|
|
346
|
+
task,
|
|
347
|
+
ExecutionResult(status=ExecutionStatus.ERROR, error=f"git: {exc}"),
|
|
348
|
+
)
|
|
349
|
+
return False
|
|
350
|
+
if sha is None:
|
|
351
|
+
logger.info("No changes produced; nothing committed.")
|
|
352
|
+
return True
|
|
353
|
+
self._last_commit_sha = sha
|
|
354
|
+
logger.info("Committed %s: %s", sha, message)
|
|
355
|
+
if self.config.remote:
|
|
356
|
+
try:
|
|
357
|
+
await self.git.a_push(self.config.remote, self.config.branch)
|
|
358
|
+
logger.info("Pushed %s to %s/%s", sha, self.config.remote, self.config.branch)
|
|
359
|
+
except GitError as exc:
|
|
360
|
+
logger.error("Push failed (work stays committed locally): %s", exc)
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
# ------------------------------------------------------------------ #
|
|
364
|
+
# Refactoring pass #
|
|
365
|
+
# ------------------------------------------------------------------ #
|
|
366
|
+
|
|
367
|
+
async def _refactor(self) -> None:
|
|
368
|
+
"""Run the agent in refactoring mode; commit and push its changes."""
|
|
369
|
+
refactor_task = Task(
|
|
370
|
+
id="REFACTOR",
|
|
371
|
+
title="Refactoring pass",
|
|
372
|
+
description=self.config.refactor_prompt,
|
|
373
|
+
)
|
|
374
|
+
logger.info("Backlog empty; running refactoring pass.")
|
|
375
|
+
await self._run_agent(
|
|
376
|
+
refactor_task,
|
|
377
|
+
instruction=self.config.refactor_prompt,
|
|
378
|
+
success_message="forgeo: refactoring pass",
|
|
379
|
+
blocked_message="forgeo: refactoring pass [partial]",
|
|
380
|
+
is_refactor=True,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------------------------------ #
|
|
384
|
+
# Blocker file #
|
|
385
|
+
# ------------------------------------------------------------------ #
|
|
386
|
+
|
|
387
|
+
def _notify_blocked(self, entry: BlockerEntry) -> None:
|
|
388
|
+
"""Send a Telegram notification for a newly blocked task or refactor pass.
|
|
389
|
+
|
|
390
|
+
The message contains Forgeo name, the task id and title, and the
|
|
391
|
+
first lines of the blocker reason. Notifications are optional and never
|
|
392
|
+
change the outcome of the cycle: a failure is only logged.
|
|
393
|
+
"""
|
|
394
|
+
reason = entry.result.questions or entry.result.output_logs
|
|
395
|
+
notice = BlockedNotice(
|
|
396
|
+
task_id=entry.task.id,
|
|
397
|
+
task_title=entry.task.title,
|
|
398
|
+
reason="\n".join(reason) if reason else "The agent did not explain what it needs.",
|
|
399
|
+
)
|
|
400
|
+
send_blocked_notice(self.config, notice)
|
|
401
|
+
|
|
402
|
+
async def _write_blocker(self, entries: list[BlockerEntry]) -> None:
|
|
403
|
+
"""Write the blocker file with a detailed explanation of every block."""
|
|
404
|
+
sections: list[str] = [
|
|
405
|
+
"# BLOCKER: Forgeo needs your input",
|
|
406
|
+
"",
|
|
407
|
+
"The coding agent could not finish without a human decision. The",
|
|
408
|
+
f"forgeo is paused until this is resolved. Backlog: `{self.config.backlog}`.",
|
|
409
|
+
"",
|
|
410
|
+
]
|
|
411
|
+
for entry in entries:
|
|
412
|
+
sections.append(self._render_entry(entry))
|
|
413
|
+
sections.append("")
|
|
414
|
+
self.config.blocker_file.parent.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
self.config.blocker_file.write_text("\n".join(sections), encoding="utf-8")
|
|
416
|
+
logger.info("Blocker file written to %s", self.config.blocker_file)
|
|
417
|
+
|
|
418
|
+
def _render_entry(self, entry: BlockerEntry) -> str:
|
|
419
|
+
"""Render the explanation and the required human action for one block."""
|
|
420
|
+
task = entry.task
|
|
421
|
+
lines = [f"## {task.id}: {task.title}", "", "### What the agent was asked to do", ""]
|
|
422
|
+
lines += [f"> {line}" for line in entry.instruction.splitlines()]
|
|
423
|
+
lines += ["", "### What the agent says it needs", ""]
|
|
424
|
+
questions = entry.result.questions or entry.result.output_logs
|
|
425
|
+
if not questions:
|
|
426
|
+
questions = ["The agent did not explain what it needs."]
|
|
427
|
+
lines += [f"> {line}" for line in questions[-10:]]
|
|
428
|
+
if entry.is_refactor:
|
|
429
|
+
lines += [
|
|
430
|
+
"",
|
|
431
|
+
"### What you must do",
|
|
432
|
+
"",
|
|
433
|
+
"Decide how to handle this refactoring question, then delete this file.",
|
|
434
|
+
"Forgeo will continue on the next scheduled run.",
|
|
435
|
+
]
|
|
436
|
+
else:
|
|
437
|
+
lines += [
|
|
438
|
+
"",
|
|
439
|
+
"### What you must do",
|
|
440
|
+
"",
|
|
441
|
+
"1. Decide what the agent needs (edit the repository directly if required).",
|
|
442
|
+
f"2. Open `{self.config.backlog}` and set the status of `{task.id}` back to `OPEN`",
|
|
443
|
+
" so Forgeo retries it — or delete the task if it should not be done.",
|
|
444
|
+
"3. Forgeo will retry on the next scheduled run.",
|
|
445
|
+
]
|
|
446
|
+
return "\n".join(lines)
|