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/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)