forgeo-cli 0.7.0__tar.gz → 0.7.2__tar.gz
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_cli-0.7.0 → forgeo_cli-0.7.2}/CHANGELOG.md +32 -1
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/PKG-INFO +5 -1
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/README.md +4 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/agent-contract.md +9 -5
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/configuration.md +23 -0
- forgeo_cli-0.7.2/docs/img/demo.gif +0 -0
- forgeo_cli-0.7.2/docs/img/og.png +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/install.sh +1 -1
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/pyproject.toml +1 -1
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/__init__.py +1 -1
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/central.py +26 -35
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/cli.py +25 -15
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/daemon_control.py +44 -34
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/forgeo.py +99 -48
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/models.py +6 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/runs.py +30 -7
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/setup.py +4 -3
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/central.js +1 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_factory.py +57 -7
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_install.py +1 -1
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_models.py +5 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_runs.py +5 -5
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_web.py +1 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/.github/workflows/ci.yml +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/.gitignore +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/CONTRIBUTING.md +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/LICENSE +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/config/nginx-forgeo.conf +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/backlog.md +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/cli-reference.md +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/getting-started.md +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/img/console.png +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/img/logo.png +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/img/title.svg +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/index.md +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/web-console-api.md +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/forgeo.spec +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/mkdocs.yml +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/scripts/__init__.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/scripts/render_homebrew_formula.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/__main__.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/agent.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/backlog.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/backlog_http.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/config.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/daemon.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/git.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/instances.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/io.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/notify.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/oauth.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/paths.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/update.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/validate.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/central.css +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/index.html +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/instance.html +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/login.html +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/style.css +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web_common.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/conftest.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_agent.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_backlog.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_backlog_http.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_cli.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_daemon.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_git.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_instances.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_io.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_oauth.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_paths.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_remote_backlog_cycle.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_render_homebrew.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_setup.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_update.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_web_common.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_web_lock.py +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/www/404.html +0 -0
- {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/www/index.html +0 -0
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.2] - 2026-08-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `no_changes_retry_max` config key: a task whose agent exits `0` without
|
|
15
|
+
producing any code changes is re-run immediately, in the same cycle, up to
|
|
16
|
+
that many extra times before it is marked `BLOCKED` for human review.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- A silent no-change SUCCESS (agent exits `0` with an unchanged working tree)
|
|
21
|
+
now marks the task `BLOCKED` instead of `FAILED`: the only acceptable
|
|
22
|
+
outcome for a run that ends without code changes is a blocked task awaiting
|
|
23
|
+
human review. An agent that needs no code change must still opt in
|
|
24
|
+
explicitly with `no_changes_exit_code` to complete the task.
|
|
25
|
+
|
|
26
|
+
## [0.7.1] - 2026-08-17
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- An animated demo GIF in the README, showing Forgeo running a backlog task
|
|
31
|
+
end to end, plus an Open Graph social-preview image for the docs site.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- The default agent prompt (used by `forgeo init`) now names `AGENTS.md` and
|
|
36
|
+
`CONTEXT.md` explicitly when telling the agent to keep project overview and
|
|
37
|
+
conventions up to date.
|
|
38
|
+
|
|
10
39
|
## [0.7.0] - 2026-08-16
|
|
11
40
|
|
|
12
41
|
### Added
|
|
@@ -308,7 +337,9 @@ Initial release of the scheduled, agent-driven software forgeo.
|
|
|
308
337
|
overlapping-run skipping.
|
|
309
338
|
- Dogfooding docs removed; local configs kept out of the repository.
|
|
310
339
|
|
|
311
|
-
[Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.7.
|
|
340
|
+
[Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.7.2...HEAD
|
|
341
|
+
[0.7.2]: https://github.com/lucaGazzola/forgeo/compare/v0.7.1...v0.7.2
|
|
342
|
+
[0.7.1]: https://github.com/lucaGazzola/forgeo/compare/v0.7.0...v0.7.1
|
|
312
343
|
[0.7.0]: https://github.com/lucaGazzola/forgeo/compare/v0.6.0...v0.7.0
|
|
313
344
|
[0.6.0]: https://github.com/lucaGazzola/forgeo/compare/v0.5.0...v0.6.0
|
|
314
345
|
[0.5.0]: https://github.com/lucaGazzola/forgeo/compare/v0.4.0...v0.5.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: forgeo-cli
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input.
|
|
5
5
|
Project-URL: Homepage, https://forgeo.org
|
|
6
6
|
Project-URL: Documentation, https://forgeo.org
|
|
@@ -64,6 +64,10 @@ Description-Content-Type: text/markdown
|
|
|
64
64
|
[](https://github.com/lucaGazzola/forgeo/actions/workflows/ci.yml)
|
|
65
65
|
[](https://opensource.org/licenses/MIT)
|
|
66
66
|
|
|
67
|
+
<div align="center">
|
|
68
|
+
<img src="docs/img/demo.gif" alt="Forgeo running a backlog task end to end" width="720">
|
|
69
|
+
</div>
|
|
70
|
+
|
|
67
71
|
**Forgeo is a software factory for your coding-agent.**
|
|
68
72
|
You're already working with an AI coding agent, prompting it task by task
|
|
69
73
|
or giving it a goal. Forgeo organizes your work in a structured way with
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
[](https://github.com/lucaGazzola/forgeo/actions/workflows/ci.yml)
|
|
11
11
|
[](https://opensource.org/licenses/MIT)
|
|
12
12
|
|
|
13
|
+
<div align="center">
|
|
14
|
+
<img src="docs/img/demo.gif" alt="Forgeo running a backlog task end to end" width="720">
|
|
15
|
+
</div>
|
|
16
|
+
|
|
13
17
|
**Forgeo is a software factory for your coding-agent.**
|
|
14
18
|
You're already working with an AI coding agent, prompting it task by task
|
|
15
19
|
or giving it a goal. Forgeo organizes your work in a structured way with
|
|
@@ -69,8 +69,11 @@ Forgeo cannot tell "the agent deliberately made no changes" from "the agent
|
|
|
69
69
|
did nothing". A `SUCCESS` exit that produces **no changes is therefore not a
|
|
70
70
|
valid completion for a task**:
|
|
71
71
|
|
|
72
|
-
- exiting `0` while leaving the working tree **unchanged**
|
|
73
|
-
(`
|
|
72
|
+
- exiting `0` while leaving the working tree **unchanged** is retried
|
|
73
|
+
immediately (`no_changes_retry_max`, see [Configuration](configuration.md))
|
|
74
|
+
and, once that budget is spent, the task is marked `BLOCKED` — never
|
|
75
|
+
`FAILED` and never silently `COMPLETED`. The only acceptable outcome for a
|
|
76
|
+
run that ends without code changes is a blocked task awaiting human review;
|
|
74
77
|
- to complete a task **without touching the code**, exit
|
|
75
78
|
`no_changes_exit_code` (default `3`). The working tree must be clean — an
|
|
76
79
|
agent that reports "no changes" while leaving uncommitted work behind fails
|
|
@@ -121,7 +124,7 @@ that, based on the exit code. The working contract is:
|
|
|
121
124
|
- **do not** run `git add`, `git commit`, `git push`, or reset the tree;
|
|
122
125
|
- exit `0` to have your changes committed and pushed as one commit;
|
|
123
126
|
- exit `no_changes_exit_code` when the task needs no code change (never exit
|
|
124
|
-
`0` with an empty tree — that
|
|
127
|
+
`0` with an empty tree — that run is retried and then marked `BLOCKED`);
|
|
125
128
|
- exit `blocked_exit_code` to have partial work preserved and a blocker
|
|
126
129
|
written;
|
|
127
130
|
- exit anything else to have your changes discarded.
|
|
@@ -135,8 +138,9 @@ agent_command: >
|
|
|
135
138
|
Make the code changes requested below and nothing else. Do NOT run
|
|
136
139
|
git commit, git push, or git add -A — Forgeo commits your work.
|
|
137
140
|
Verify with the test suite where applicable.
|
|
138
|
-
Read AGENTS.md
|
|
139
|
-
if your change materially affects the project overview
|
|
141
|
+
Read AGENTS.md at the start of the session, and CONTEXT.md if present;
|
|
142
|
+
if your change materially affects the project overview or conventions,
|
|
143
|
+
update AGENTS.md and CONTEXT.md accordingly.
|
|
140
144
|
$FORGEO_TASK"
|
|
141
145
|
```
|
|
142
146
|
|
|
@@ -42,6 +42,7 @@ paths), so `forgeo restart` is still used for those.
|
|
|
42
42
|
| <span style="white-space: nowrap">`run_output_lines`</span> | `200` | How many agent output lines each run record keeps in `runs.jsonl` (the bounded tail of the agent's stdout/stderr). `0` disables persisting agent output. |
|
|
43
43
|
| <span style="white-space: nowrap">`failed_retry_max`</span> | `0` | How many times a `FAILED` task is retried automatically. `0` (default) = a `FAILED` task stays `FAILED` until a human reopens it, exactly as before. A task may override this budget per-task with `retries_left`. |
|
|
44
44
|
| <span style="white-space: nowrap">`failed_retry_wait_cycles`</span> | `1` | How many cycles a retry-eligible `FAILED` task waits (backoff) before it is moved back to `OPEN`. |
|
|
45
|
+
| <span style="white-space: nowrap">`no_changes_retry_max`</span> | `0` | How many times a task whose agent exits `0` without producing any code changes is re-run immediately, in the same cycle, before the task is marked `BLOCKED` for human review. `0` (default) = a silent no-change SUCCESS is marked `BLOCKED` on the first attempt. |
|
|
45
46
|
| <span style="white-space: nowrap">`git_timeout_seconds`</span> | `120` | Kill a git subprocess after this many seconds. |
|
|
46
47
|
| <span style="white-space: nowrap">`telegram_bot_token`</span> | — | Telegram bot token for blocked-run notifications (disabled unless `telegram_chat_id` is also set). |
|
|
47
48
|
| <span style="white-space: nowrap">`telegram_chat_id`</span> | — | Chat ID that receives blocked-run notifications (disabled unless `telegram_bot_token` is also set). |
|
|
@@ -254,6 +255,28 @@ failed_retry_max: 3 # retry each FAILED task up to 3 times
|
|
|
254
255
|
failed_retry_wait_cycles: 2 # back off 2 cycles before each retry
|
|
255
256
|
```
|
|
256
257
|
|
|
258
|
+
### `no_changes_retry_max`
|
|
259
|
+
|
|
260
|
+
An agent that exits `0` while leaving the working tree unchanged has not
|
|
261
|
+
completed the task — the engine cannot tell "deliberately did nothing" from
|
|
262
|
+
"did nothing". By default (`no_changes_retry_max: 0`) that run is marked
|
|
263
|
+
`BLOCKED` on the first attempt: the only acceptable outcome for a no-change
|
|
264
|
+
run is a blocked task awaiting human review, never a silent completion and
|
|
265
|
+
never a `FAILED` task.
|
|
266
|
+
|
|
267
|
+
Sometimes the agent needs a second chance (a flaky model, a transient context
|
|
268
|
+
issue). Set a positive `no_changes_retry_max` and Forgeo re-runs the agent
|
|
269
|
+
immediately, back-to-back in the **same cycle**, that many extra times; a run
|
|
270
|
+
that finally produces changes completes the task, and one that still produces
|
|
271
|
+
nothing after the budget is spent is marked `BLOCKED`.
|
|
272
|
+
|
|
273
|
+
```yaml
|
|
274
|
+
no_changes_retry_max: 2 # re-run the agent up to 2 extra times on a silent no-change
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`BLOCKED` tasks are never auto-retried — a human decides whether to reopen,
|
|
278
|
+
split, or drop the task (see [Backlog](backlog.md)).
|
|
279
|
+
|
|
257
280
|
### Telegram notifications
|
|
258
281
|
|
|
259
282
|
Both `telegram_bot_token` **and** `telegram_chat_id` must be set for blocked
|
|
Binary file
|
|
Binary file
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forgeo-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -623,10 +623,21 @@ def make_handler(token: str | None = None) -> type[BaseHTTPRequestHandler]:
|
|
|
623
623
|
logger.exception("Web request failed: %s", urlparse(self.path).path)
|
|
624
624
|
self._send_json(500, {"error": "internal server error"})
|
|
625
625
|
|
|
626
|
-
def
|
|
626
|
+
def _dispatch(self, handler: Callable[[], None]) -> None:
|
|
627
|
+
"""Authorize, then run one request handler safely.
|
|
628
|
+
|
|
629
|
+
Shared by the five HTTP verbs: a request is authorized first (a
|
|
630
|
+
missing or wrong bearer token answers ``401`` without reaching the
|
|
631
|
+
handler), then the handler runs under :meth:`_run_safely` so a
|
|
632
|
+
failure is answered with a JSON 500 instead of crashing the
|
|
633
|
+
server thread.
|
|
634
|
+
"""
|
|
627
635
|
if not self._maybe_authorize(self.path):
|
|
628
636
|
return
|
|
629
|
-
self._run_safely(
|
|
637
|
+
self._run_safely(handler)
|
|
638
|
+
|
|
639
|
+
def do_GET(self) -> None:
|
|
640
|
+
self._dispatch(self._do_get)
|
|
630
641
|
|
|
631
642
|
def _do_get(self) -> None:
|
|
632
643
|
parsed = urlparse(self.path)
|
|
@@ -652,9 +663,7 @@ def make_handler(token: str | None = None) -> type[BaseHTTPRequestHandler]:
|
|
|
652
663
|
self._send_not_found()
|
|
653
664
|
|
|
654
665
|
def do_POST(self) -> None:
|
|
655
|
-
|
|
656
|
-
return
|
|
657
|
-
self._run_safely(self._do_post)
|
|
666
|
+
self._dispatch(self._do_post)
|
|
658
667
|
|
|
659
668
|
def _do_post(self) -> None:
|
|
660
669
|
parsed = urlparse(self.path)
|
|
@@ -666,9 +675,7 @@ def make_handler(token: str | None = None) -> type[BaseHTTPRequestHandler]:
|
|
|
666
675
|
self._send_not_found()
|
|
667
676
|
|
|
668
677
|
def do_PATCH(self) -> None:
|
|
669
|
-
|
|
670
|
-
return
|
|
671
|
-
self._run_safely(self._do_patch)
|
|
678
|
+
self._dispatch(self._do_patch)
|
|
672
679
|
|
|
673
680
|
def _do_patch(self) -> None:
|
|
674
681
|
parsed = urlparse(self.path)
|
|
@@ -680,9 +687,7 @@ def make_handler(token: str | None = None) -> type[BaseHTTPRequestHandler]:
|
|
|
680
687
|
self._send_not_found()
|
|
681
688
|
|
|
682
689
|
def do_DELETE(self) -> None:
|
|
683
|
-
|
|
684
|
-
return
|
|
685
|
-
self._run_safely(self._do_delete)
|
|
690
|
+
self._dispatch(self._do_delete)
|
|
686
691
|
|
|
687
692
|
def _do_delete(self) -> None:
|
|
688
693
|
parsed = urlparse(self.path)
|
|
@@ -694,9 +699,7 @@ def make_handler(token: str | None = None) -> type[BaseHTTPRequestHandler]:
|
|
|
694
699
|
self._send_not_found()
|
|
695
700
|
|
|
696
701
|
def do_PUT(self) -> None:
|
|
697
|
-
|
|
698
|
-
return
|
|
699
|
-
self._run_safely(self._do_put)
|
|
702
|
+
self._dispatch(self._do_put)
|
|
700
703
|
|
|
701
704
|
def _do_put(self) -> None:
|
|
702
705
|
parsed = urlparse(self.path)
|
|
@@ -1230,12 +1233,12 @@ def make_handler(token: str | None = None) -> type[BaseHTTPRequestHandler]:
|
|
|
1230
1233
|
)
|
|
1231
1234
|
return
|
|
1232
1235
|
recorder = RunRecorder(runs_path(info.config))
|
|
1233
|
-
records = recorder.
|
|
1236
|
+
records, total = recorder.read_with_total(limit=limit, offset=offset)
|
|
1234
1237
|
self._send_json(
|
|
1235
1238
|
200,
|
|
1236
1239
|
{
|
|
1237
1240
|
"runs": [r.model_dump(mode="json") for r in records],
|
|
1238
|
-
"total":
|
|
1241
|
+
"total": total,
|
|
1239
1242
|
"offset": offset,
|
|
1240
1243
|
"limit": limit,
|
|
1241
1244
|
},
|
|
@@ -1367,26 +1370,14 @@ def stop_web(timeout: float = WEB_STOP_TIMEOUT_SECONDS) -> None:
|
|
|
1367
1370
|
f"The lock file {lock.lock_path} records no PID; find the dashboard "
|
|
1368
1371
|
f"with `pgrep -af forgeo` and stop it manually."
|
|
1369
1372
|
)
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
return
|
|
1377
|
-
raise WebLockError(
|
|
1378
|
-
f"Recorded pid {pid} is gone but the lock file is still held; "
|
|
1379
|
-
f"check with `pgrep -af forgeo`."
|
|
1380
|
-
)
|
|
1381
|
-
except PermissionError:
|
|
1382
|
-
raise WebLockError(f"No permission to stop process {pid}.")
|
|
1383
|
-
if daemon_control.wait_for_lock_release(lock.lock_path, timeout, is_held=lock.is_held):
|
|
1384
|
-
lock.release()
|
|
1385
|
-
logger.info("Central dashboard stopped.")
|
|
1386
|
-
return
|
|
1387
|
-
raise WebLockError(
|
|
1388
|
-
f"Central dashboard is still shutting down after {timeout:.0f}s; giving up."
|
|
1373
|
+
daemon_control.signal_and_wait_for_release(
|
|
1374
|
+
pid,
|
|
1375
|
+
name="central dashboard",
|
|
1376
|
+
is_held=lock.is_held,
|
|
1377
|
+
timeout=timeout,
|
|
1378
|
+
error_cls=WebLockError,
|
|
1389
1379
|
)
|
|
1380
|
+
lock.release()
|
|
1390
1381
|
|
|
1391
1382
|
|
|
1392
1383
|
def start_web_detached(
|
|
@@ -317,9 +317,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
317
317
|
return parser
|
|
318
318
|
|
|
319
319
|
|
|
320
|
-
def _add_config_or_name(
|
|
321
|
-
parser: argparse.ArgumentParser, *, default_config: Path = DEFAULT_CONFIG
|
|
322
|
-
) -> None:
|
|
320
|
+
def _add_config_or_name(parser: argparse.ArgumentParser) -> None:
|
|
323
321
|
"""Add a mutually-exclusive ``--config``/``--name`` option pair.
|
|
324
322
|
|
|
325
323
|
``--config`` keeps its default so plain ``forgeo start`` (etc.) keeps
|
|
@@ -330,7 +328,7 @@ def _add_config_or_name(
|
|
|
330
328
|
group.add_argument(
|
|
331
329
|
"--config",
|
|
332
330
|
type=Path,
|
|
333
|
-
default=
|
|
331
|
+
default=DEFAULT_CONFIG,
|
|
334
332
|
help="Forgeo YAML file (default: forgeo.yaml).",
|
|
335
333
|
)
|
|
336
334
|
group.add_argument(
|
|
@@ -654,20 +652,37 @@ def _run_worker_with_lock(
|
|
|
654
652
|
return result
|
|
655
653
|
|
|
656
654
|
|
|
657
|
-
def
|
|
658
|
-
|
|
655
|
+
def _run_worker_once(
|
|
656
|
+
args: argparse.Namespace,
|
|
657
|
+
runner: Callable[[Forgeo], Coroutine[Any, Any, int]],
|
|
658
|
+
) -> int:
|
|
659
|
+
"""Prepare the worker and run one cycle through ``runner``.
|
|
660
|
+
|
|
661
|
+
Shared plumbing of ``forgeo once`` and ``forgeo run``: resolve the config,
|
|
662
|
+
take the per-forgeo lock, run the optional update check, execute exactly
|
|
663
|
+
one cycle via ``runner``, and release the lock on the way out.
|
|
664
|
+
"""
|
|
659
665
|
prepared = _prepare_worker(args)
|
|
660
666
|
if prepared is None:
|
|
661
667
|
return 1
|
|
662
668
|
_config_path, _config, forgeo, lock = prepared
|
|
663
669
|
|
|
664
|
-
async def
|
|
670
|
+
async def _execute() -> int:
|
|
665
671
|
check_for_update(update_state_path(_config), print_fn=console.print)
|
|
672
|
+
return await runner(forgeo)
|
|
673
|
+
|
|
674
|
+
return _run_worker_with_lock(lock, _execute)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def cmd_once(args: argparse.Namespace) -> int:
|
|
678
|
+
"""Handle ``forgeo once``: run exactly one cycle and exit."""
|
|
679
|
+
|
|
680
|
+
async def _cycle(forgeo: Forgeo) -> int:
|
|
666
681
|
outcome = await forgeo.run_cycle()
|
|
667
682
|
console.print(f"[green]Cycle finished: {outcome}[/green]")
|
|
668
683
|
return 0
|
|
669
684
|
|
|
670
|
-
return
|
|
685
|
+
return _run_worker_once(args, _cycle)
|
|
671
686
|
|
|
672
687
|
|
|
673
688
|
def cmd_run(args: argparse.Namespace) -> int:
|
|
@@ -680,14 +695,9 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|
|
680
695
|
overlaps them; it refuses (exit 1) when the task does not exist or is
|
|
681
696
|
not ``OPEN``.
|
|
682
697
|
"""
|
|
683
|
-
prepared = _prepare_worker(args)
|
|
684
|
-
if prepared is None:
|
|
685
|
-
return 1
|
|
686
|
-
_config_path, _config, forgeo, lock = prepared
|
|
687
698
|
task_id = args.task
|
|
688
699
|
|
|
689
|
-
async def
|
|
690
|
-
check_for_update(update_state_path(_config), print_fn=console.print)
|
|
700
|
+
async def _one(forgeo: Forgeo) -> int:
|
|
691
701
|
try:
|
|
692
702
|
outcome = await forgeo.run_task_id(task_id)
|
|
693
703
|
except TaskNotRunnableError as exc:
|
|
@@ -696,7 +706,7 @@ def cmd_run(args: argparse.Namespace) -> int:
|
|
|
696
706
|
console.print(f"[green]Cycle finished: {outcome}[/green]")
|
|
697
707
|
return 0
|
|
698
708
|
|
|
699
|
-
return
|
|
709
|
+
return _run_worker_once(args, _one)
|
|
700
710
|
|
|
701
711
|
|
|
702
712
|
def cmd_init(args: argparse.Namespace) -> int:
|
|
@@ -14,7 +14,6 @@ Everything here is lock-file driven and never touches the config-save flow.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
import functools
|
|
18
17
|
import logging
|
|
19
18
|
import os
|
|
20
19
|
import signal
|
|
@@ -65,26 +64,49 @@ def wait_for_process_ready(
|
|
|
65
64
|
return None
|
|
66
65
|
|
|
67
66
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
timeout: float,
|
|
67
|
+
def signal_and_wait_for_release(
|
|
68
|
+
pid: int,
|
|
71
69
|
*,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
70
|
+
name: str,
|
|
71
|
+
is_held: Callable[[], bool],
|
|
72
|
+
timeout: float,
|
|
73
|
+
error_cls: type[Exception],
|
|
74
|
+
timeout_hint: str = "",
|
|
75
|
+
) -> None:
|
|
76
|
+
"""SIGTERM ``pid`` and wait for the lock it holds to be released.
|
|
77
|
+
|
|
78
|
+
Shared by the daemon and the central-dashboard stop commands: SIGTERM the
|
|
79
|
+
recorded PID, tolerate a process that already exited (as long as its lock
|
|
80
|
+
is gone too), refuse without permission, and wait for the lock to drop
|
|
81
|
+
within ``timeout`` (a cycle in progress finishes first). Any failure
|
|
82
|
+
raises ``error_cls`` — each caller's own error type — with a user-facing
|
|
83
|
+
message; on success ``name`` is logged as stopped.
|
|
79
84
|
"""
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
logger.info("Stopping %s (pid %s)...", name, pid)
|
|
86
|
+
try:
|
|
87
|
+
os.kill(pid, signal.SIGTERM)
|
|
88
|
+
except ProcessLookupError:
|
|
89
|
+
if not is_held():
|
|
90
|
+
logger.info("%s stopped.", name)
|
|
91
|
+
return
|
|
92
|
+
raise error_cls(
|
|
93
|
+
f"Recorded pid {pid} is gone but the lock is still held; "
|
|
94
|
+
f"check with `pgrep -af forgeo`."
|
|
95
|
+
) from None
|
|
96
|
+
except PermissionError:
|
|
97
|
+
raise error_cls(f"No permission to stop process {pid}.") from None
|
|
82
98
|
deadline = time.monotonic() + timeout
|
|
83
99
|
while time.monotonic() < deadline:
|
|
84
100
|
if not is_held():
|
|
85
|
-
|
|
101
|
+
logger.info("%s stopped.", name)
|
|
102
|
+
return
|
|
86
103
|
time.sleep(_POLL_SECONDS)
|
|
87
|
-
|
|
104
|
+
if not is_held():
|
|
105
|
+
logger.info("%s stopped.", name)
|
|
106
|
+
return
|
|
107
|
+
raise error_cls(
|
|
108
|
+
f"{name} is still shutting down after {timeout:.0f}s{timeout_hint}; giving up."
|
|
109
|
+
)
|
|
88
110
|
|
|
89
111
|
|
|
90
112
|
def stop_daemon(
|
|
@@ -103,25 +125,13 @@ def stop_daemon(
|
|
|
103
125
|
f"The lock file {lock_path} records no PID; find the daemon "
|
|
104
126
|
f"with `pgrep -af forgeo` and stop it manually."
|
|
105
127
|
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
raise DaemonError(
|
|
114
|
-
f"Recorded pid {pid} is gone but the lock is still held; "
|
|
115
|
-
f"check with `pgrep -af forgeo`."
|
|
116
|
-
)
|
|
117
|
-
except PermissionError:
|
|
118
|
-
raise DaemonError(f"No permission to stop process {pid}.")
|
|
119
|
-
if wait_for_lock_release(lock_path, timeout):
|
|
120
|
-
logger.info("Forgeo %r stopped.", config.name)
|
|
121
|
-
return
|
|
122
|
-
raise DaemonError(
|
|
123
|
-
f"Forgeo is still shutting down after {timeout:.0f}s "
|
|
124
|
-
f"(a cycle in progress finishes first); giving up."
|
|
128
|
+
signal_and_wait_for_release(
|
|
129
|
+
pid,
|
|
130
|
+
name=f"forgeo {config.name!r}",
|
|
131
|
+
is_held=lambda: is_lock_held(lock_path),
|
|
132
|
+
timeout=timeout,
|
|
133
|
+
error_cls=DaemonError,
|
|
134
|
+
timeout_hint=" (a cycle in progress finishes first)",
|
|
125
135
|
)
|
|
126
136
|
|
|
127
137
|
|
|
@@ -62,12 +62,12 @@ class TaskNotRunnableError(RuntimeError):
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
def _execution_outcome(status: ExecutionStatus) -> RunOutcome:
|
|
65
|
-
"""Map an agent execution status onto a run record outcome.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
"""Map an agent execution status onto a run record outcome.
|
|
66
|
+
|
|
67
|
+
The two enums share member names for the agent outcomes (SUCCESS, BLOCKED,
|
|
68
|
+
ERROR), so the mapping is a plain name lookup.
|
|
69
|
+
"""
|
|
70
|
+
return RunOutcome[status.name]
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def _subject_label(task: Task, *, is_refactor: bool) -> str:
|
|
@@ -82,7 +82,6 @@ class BlockerEntry:
|
|
|
82
82
|
task: Task
|
|
83
83
|
result: ExecutionResult
|
|
84
84
|
instruction: str
|
|
85
|
-
is_refactor: bool = False
|
|
86
85
|
|
|
87
86
|
|
|
88
87
|
class Forgeo:
|
|
@@ -134,10 +133,7 @@ class Forgeo:
|
|
|
134
133
|
tasks = await self.backlog.list_tasks()
|
|
135
134
|
task = oldest_open_task(tasks)
|
|
136
135
|
if task is not None:
|
|
137
|
-
|
|
138
|
-
return "dirty"
|
|
139
|
-
await self._run_task(task)
|
|
140
|
-
return "task"
|
|
136
|
+
return await self._run_task_clean(task)
|
|
141
137
|
|
|
142
138
|
if self.config.blocker_file.exists():
|
|
143
139
|
logger.info("Blocker file present; forgeo paused until it is resolved.")
|
|
@@ -182,10 +178,7 @@ class Forgeo:
|
|
|
182
178
|
f"Task {task_id!r} is {task.status.value}; only OPEN tasks can "
|
|
183
179
|
f"be run with `forgeo run`."
|
|
184
180
|
)
|
|
185
|
-
|
|
186
|
-
return "dirty"
|
|
187
|
-
await self._run_task(task)
|
|
188
|
-
return "task"
|
|
181
|
+
return await self._run_task_clean(task)
|
|
189
182
|
|
|
190
183
|
async def _begin_run(self) -> list[Task]:
|
|
191
184
|
"""Reset the run state and load the tasks for a new cycle."""
|
|
@@ -212,6 +205,18 @@ class Forgeo:
|
|
|
212
205
|
)
|
|
213
206
|
return False
|
|
214
207
|
|
|
208
|
+
async def _run_task_clean(self, task: Task) -> str:
|
|
209
|
+
"""Run ``task`` when the working tree is clean; returns the outcome label.
|
|
210
|
+
|
|
211
|
+
Shared by the scheduled pick (:meth:`_run_cycle`) and the explicit
|
|
212
|
+
``forgeo run`` (:meth:`_run_task_id`): a dirty tree is refused with a
|
|
213
|
+
``dirty`` outcome, otherwise the task runs and the outcome is ``task``.
|
|
214
|
+
"""
|
|
215
|
+
if not await self._tree_clean_for(task):
|
|
216
|
+
return "dirty"
|
|
217
|
+
await self._run_task(task)
|
|
218
|
+
return "task"
|
|
219
|
+
|
|
215
220
|
# ------------------------------------------------------------------ #
|
|
216
221
|
# Failed-task retries #
|
|
217
222
|
# ------------------------------------------------------------------ #
|
|
@@ -354,31 +359,54 @@ class Forgeo:
|
|
|
354
359
|
# ------------------------------------------------------------------ #
|
|
355
360
|
|
|
356
361
|
async def _run_task(self, task: Task) -> None:
|
|
357
|
-
"""Execute one task: agent run, then commit/push on the main branch.
|
|
362
|
+
"""Execute one task: agent run(s), then commit/push on the main branch.
|
|
363
|
+
|
|
364
|
+
A silent no-change SUCCESS (exit 0 with an empty tree) is re-run up to
|
|
365
|
+
``no_changes_retry_max`` more times in the same cycle; once the retry
|
|
366
|
+
budget is spent the task is marked ``BLOCKED`` for human review — the
|
|
367
|
+
only acceptable outcome for a task that ends without code changes.
|
|
368
|
+
"""
|
|
358
369
|
logger.info("Running task %s (%s)", task.id, task.title)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
370
|
+
max_retries = self.config.no_changes_retry_max
|
|
371
|
+
for attempt in range(max_retries + 1):
|
|
372
|
+
self._last_run_reason = None
|
|
373
|
+
result, ok = await self._run_agent(
|
|
374
|
+
task,
|
|
375
|
+
instruction=task.instruction,
|
|
376
|
+
success_message=task.title,
|
|
377
|
+
blocked_message=f"{task.title} [partial]",
|
|
378
|
+
command=task.agent_command,
|
|
379
|
+
timeout_seconds=task.agent_timeout_seconds,
|
|
380
|
+
)
|
|
367
381
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
382
|
+
if result.status is ExecutionStatus.BLOCKED:
|
|
383
|
+
await self.backlog.set_blocked(task.id, result.reason)
|
|
384
|
+
return
|
|
385
|
+
if result.status is ExecutionStatus.ERROR:
|
|
386
|
+
await self._mark_failed(task, self._failure_reason(result))
|
|
387
|
+
return
|
|
388
|
+
if ok:
|
|
389
|
+
await self.backlog.update_status(task.id, TaskStatus.COMPLETED)
|
|
390
|
+
self.config.blocker_file.unlink(missing_ok=True)
|
|
391
|
+
self._notify_webhook("completed", task, "")
|
|
392
|
+
logger.info("Task %s completed.", task.id)
|
|
393
|
+
return
|
|
394
|
+
# Not ok: either the commit failed (the task is already marked
|
|
395
|
+
# FAILED inside _handle_execution_result) or the agent exited 0
|
|
396
|
+
# without producing changes (only the reason distinguishes them).
|
|
397
|
+
if self._last_run_reason != NO_CHANGES_REASON:
|
|
398
|
+
return
|
|
399
|
+
if attempt < max_retries:
|
|
400
|
+
logger.warning(
|
|
401
|
+
"Task %s exited 0 but produced no changes "
|
|
402
|
+
"(attempt %d/%d); retrying.",
|
|
403
|
+
task.id,
|
|
404
|
+
attempt + 1,
|
|
405
|
+
max_retries,
|
|
406
|
+
)
|
|
407
|
+
continue
|
|
408
|
+
await self._block_no_changes(task)
|
|
377
409
|
return
|
|
378
|
-
await self.backlog.update_status(task.id, TaskStatus.COMPLETED)
|
|
379
|
-
self.config.blocker_file.unlink(missing_ok=True)
|
|
380
|
-
self._notify_webhook("completed", task, "")
|
|
381
|
-
logger.info("Task %s completed.", task.id)
|
|
382
410
|
|
|
383
411
|
async def _run_agent(
|
|
384
412
|
self,
|
|
@@ -486,7 +514,10 @@ class Forgeo:
|
|
|
486
514
|
# Exited 0 but produced no changes: not a completion. The
|
|
487
515
|
# engine cannot tell "deliberately did nothing" from "did
|
|
488
516
|
# nothing", so the agent must opt into no-ops explicitly.
|
|
489
|
-
|
|
517
|
+
# The caller decides between an immediate re-run
|
|
518
|
+
# (``no_changes_retry_max``) and a BLOCKED outcome.
|
|
519
|
+
self._last_run_reason = NO_CHANGES_REASON
|
|
520
|
+
logger.warning("Task %s: %s.", task.id, NO_CHANGES_REASON)
|
|
490
521
|
return False
|
|
491
522
|
return ok
|
|
492
523
|
|
|
@@ -496,7 +527,6 @@ class Forgeo:
|
|
|
496
527
|
task=task,
|
|
497
528
|
result=result,
|
|
498
529
|
instruction=instruction,
|
|
499
|
-
is_refactor=is_refactor,
|
|
500
530
|
)
|
|
501
531
|
if is_refactor:
|
|
502
532
|
await self._write_blocker([entry])
|
|
@@ -541,22 +571,43 @@ class Forgeo:
|
|
|
541
571
|
await self.backlog.set_failed(task.id, reason)
|
|
542
572
|
self._notify_webhook("failed", task, "\n".join(reason))
|
|
543
573
|
|
|
544
|
-
async def
|
|
545
|
-
"""
|
|
574
|
+
async def _block_no_changes(self, task: Task) -> None:
|
|
575
|
+
"""Mark a task whose agent exited 0 without producing any changes as
|
|
576
|
+
BLOCKED: the only acceptable outcome for a no-change run is a blocked
|
|
577
|
+
task awaiting human review.
|
|
546
578
|
|
|
547
579
|
A no-change SUCCESS is indistinguishable from an agent that simply did
|
|
548
|
-
nothing, so it is not a valid completion
|
|
549
|
-
|
|
550
|
-
|
|
580
|
+
nothing, so it is not a valid completion. After the retry budget is
|
|
581
|
+
spent the task is blocked (never FAILED), so the run record surfaces
|
|
582
|
+
the reason and the human decides whether to reopen, split, or drop the
|
|
583
|
+
task.
|
|
551
584
|
"""
|
|
552
585
|
self._last_run_reason = NO_CHANGES_REASON
|
|
553
586
|
logger.warning(
|
|
554
|
-
"Task %s
|
|
587
|
+
"Task %s: %s; marking BLOCKED.", task.id, NO_CHANGES_REASON
|
|
555
588
|
)
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
589
|
+
blocked = ExecutionResult(
|
|
590
|
+
status=ExecutionStatus.BLOCKED,
|
|
591
|
+
output_logs=(
|
|
592
|
+
self._last_agent_result.output_logs
|
|
593
|
+
if self._last_agent_result is not None
|
|
594
|
+
else []
|
|
595
|
+
),
|
|
596
|
+
questions=[NO_CHANGES_REASON],
|
|
597
|
+
exit_code=(
|
|
598
|
+
self._last_agent_result.exit_code
|
|
599
|
+
if self._last_agent_result is not None
|
|
600
|
+
else None
|
|
601
|
+
),
|
|
602
|
+
)
|
|
603
|
+
self._last_agent_result = blocked
|
|
604
|
+
await self.backlog.set_blocked(task.id, [NO_CHANGES_REASON])
|
|
605
|
+
entry = BlockerEntry(
|
|
606
|
+
task=task,
|
|
607
|
+
result=blocked,
|
|
608
|
+
instruction=task.instruction,
|
|
559
609
|
)
|
|
610
|
+
self._notify_blocked(entry)
|
|
560
611
|
|
|
561
612
|
async def _complete_without_changes(
|
|
562
613
|
self, task: Task, *, is_refactor: bool = False
|
|
@@ -415,6 +415,11 @@ class ForgeoConfig(BaseModel):
|
|
|
415
415
|
budget per-task with ``retries_left``.
|
|
416
416
|
failed_retry_wait_cycles: How many cycles a retry-eligible ``FAILED``
|
|
417
417
|
task waits (backoff) before it is moved back to ``OPEN``.
|
|
418
|
+
no_changes_retry_max: How many times a task whose agent exits ``0``
|
|
419
|
+
without producing any code changes is re-run immediately, in the
|
|
420
|
+
same cycle, before the task is marked ``BLOCKED`` for human
|
|
421
|
+
review. ``0`` (default) = a silent no-change SUCCESS is marked
|
|
422
|
+
``BLOCKED`` on the first attempt.
|
|
418
423
|
telegram_bot_token: Telegram bot token for blocked-run
|
|
419
424
|
notifications. Disabled unless ``telegram_chat_id`` is also set.
|
|
420
425
|
telegram_chat_id: Chat ID that receives blocked-run notifications.
|
|
@@ -453,6 +458,7 @@ class ForgeoConfig(BaseModel):
|
|
|
453
458
|
run_output_lines: int = Field(default=DEFAULT_RUN_OUTPUT_LINES, ge=0)
|
|
454
459
|
failed_retry_max: int = Field(default=0, ge=0)
|
|
455
460
|
failed_retry_wait_cycles: int = Field(default=1, ge=1)
|
|
461
|
+
no_changes_retry_max: int = Field(default=0, ge=0)
|
|
456
462
|
telegram_bot_token: str | None = None
|
|
457
463
|
telegram_chat_id: str | None = None
|
|
458
464
|
notify_webhook_url: str | None = None
|
|
@@ -87,10 +87,36 @@ class RunRecorder:
|
|
|
87
87
|
warning. ``limit=None`` returns every readable record after ``offset``
|
|
88
88
|
(``offset=0`` starts at the newest).
|
|
89
89
|
"""
|
|
90
|
+
records, _ = self.read_with_total(limit, offset)
|
|
91
|
+
return records
|
|
92
|
+
|
|
93
|
+
def read_with_total(
|
|
94
|
+
self, limit: int | None = None, offset: int = 0
|
|
95
|
+
) -> tuple[list[RunRecord], int]:
|
|
96
|
+
"""Return ``(records, total)`` from a single pass over the history.
|
|
97
|
+
|
|
98
|
+
``records`` are the newest ``limit`` records after skipping ``offset``
|
|
99
|
+
(same shape as :meth:`read`); ``total`` is the count of every readable
|
|
100
|
+
record, independent of the pagination. This is the pair the web API
|
|
101
|
+
needs, computed without reading and parsing the file twice.
|
|
102
|
+
"""
|
|
103
|
+
records, total = self._read_all()
|
|
104
|
+
if offset > 0:
|
|
105
|
+
records = records[offset:]
|
|
106
|
+
if limit is None:
|
|
107
|
+
return records, total
|
|
108
|
+
return records[: max(0, limit)], total
|
|
109
|
+
|
|
110
|
+
def _read_all(self) -> tuple[list[RunRecord], int]:
|
|
111
|
+
"""Parse the whole history once; returns records (newest first) and count.
|
|
112
|
+
|
|
113
|
+
A missing file yields ``([], 0)``; corrupt lines are skipped with a
|
|
114
|
+
warning, so they never count as records.
|
|
115
|
+
"""
|
|
90
116
|
try:
|
|
91
117
|
text = self.path.read_text(encoding="utf-8", errors="replace")
|
|
92
118
|
except OSError:
|
|
93
|
-
return []
|
|
119
|
+
return [], 0
|
|
94
120
|
records: list[RunRecord] = []
|
|
95
121
|
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
96
122
|
if not line.strip():
|
|
@@ -102,11 +128,7 @@ class RunRecorder:
|
|
|
102
128
|
"Skipping corrupt run record in %s on line %s", self.path, line_no
|
|
103
129
|
)
|
|
104
130
|
records.sort(key=lambda record: record.finished_at, reverse=True)
|
|
105
|
-
|
|
106
|
-
records = records[offset:]
|
|
107
|
-
if limit is None:
|
|
108
|
-
return records
|
|
109
|
-
return records[: max(0, limit)]
|
|
131
|
+
return records, len(records)
|
|
110
132
|
|
|
111
133
|
def total(self) -> int:
|
|
112
134
|
"""The number of readable records in the run history, or ``0``.
|
|
@@ -114,7 +136,8 @@ class RunRecorder:
|
|
|
114
136
|
A missing, empty, or corrupt-only file counts as zero; corrupt lines
|
|
115
137
|
are skipped with a warning, matching :meth:`read`.
|
|
116
138
|
"""
|
|
117
|
-
|
|
139
|
+
_, total = self._read_all()
|
|
140
|
+
return total
|
|
118
141
|
|
|
119
142
|
def read_last(self) -> RunRecord | None:
|
|
120
143
|
"""Return the most recent record, or ``None`` when none exists."""
|
|
@@ -57,9 +57,10 @@ DEFAULT_AGENT_PROMPT = (
|
|
|
57
57
|
"Make the code changes requested below and nothing else. Do NOT run\n"
|
|
58
58
|
"git commit, git push, or git add -A — the forgeo commits your work\n"
|
|
59
59
|
"itself. Verify with the test suite where applicable and, if needed,\n"
|
|
60
|
-
"update readme, docs and landing page. Read AGENTS.md
|
|
61
|
-
"
|
|
62
|
-
"affects the project overview,
|
|
60
|
+
"update readme, docs and landing page. Read AGENTS.md at the start of\n"
|
|
61
|
+
"the session, and CONTEXT.md if present; if your change materially\n"
|
|
62
|
+
"affects the project overview or conventions, update AGENTS.md and\n"
|
|
63
|
+
"CONTEXT.md accordingly.\n"
|
|
63
64
|
"$FORGEO_TASK"
|
|
64
65
|
)
|
|
65
66
|
|
|
@@ -1213,6 +1213,7 @@
|
|
|
1213
1213
|
{ key: "run_output_lines", label: "Run output lines", type: "number", min: 0, step: 1, hint: "How many agent output lines each run record keeps in runs.jsonl (bounded tail, shown in the History tab). 0 = don't persist agent output." },
|
|
1214
1214
|
{ key: "failed_retry_max", label: "Failed retry max", type: "number", min: 0, step: 1, hint: "How many times a FAILED task is retried automatically. 0 = disabled (a FAILED task stays FAILED until a human reopens it). A task can override this with its own retries_left." },
|
|
1215
1215
|
{ key: "failed_retry_wait_cycles", label: "Failed retry wait (cycles)", type: "number", min: 1, step: 1, hint: "How many cycles a FAILED task waits (backoff) before it is retried." },
|
|
1216
|
+
{ key: "no_changes_retry_max", label: "No-change retry max", type: "number", min: 0, step: 1, hint: "How many times a task whose agent exits 0 without producing any code changes is re-run immediately, in the same cycle, before it is marked BLOCKED for human review. 0 = block on the first attempt." },
|
|
1216
1217
|
{ key: "agent_sandbox", label: "Agent sandbox", type: "select", options: ["none", "docker"], hint: "none = run directly on the host; docker = run inside a container." },
|
|
1217
1218
|
{ key: "agent_sandbox_image", label: "Sandbox image", type: "text", optional: true, hint: "Container image used when agent_sandbox is docker. Required in that mode." },
|
|
1218
1219
|
{ key: "agent_sandbox_network", label: "Sandbox network", type: "text", hint: "Docker network for the sandboxed agent (--network). Default none = networking disabled." },
|
|
@@ -57,10 +57,10 @@ async def test_task_success_is_committed_on_main(git_repo, tmp_path):
|
|
|
57
57
|
assert "forgeo" not in git(git_repo, "branch")
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
async def
|
|
61
|
-
"""Exit 0 with an empty tree is not a valid completion:
|
|
62
|
-
a silent no-change SUCCESS can never quietly
|
|
63
|
-
SELF-033 was closed without any work)."""
|
|
60
|
+
async def test_task_success_without_changes_blocks_task(git_repo, tmp_path):
|
|
61
|
+
"""Exit 0 with an empty tree is not a valid completion: the task is
|
|
62
|
+
BLOCKED (needs a human), so a silent no-change SUCCESS can never quietly
|
|
63
|
+
close a task (regression: SELF-033 was closed without any work)."""
|
|
64
64
|
forgeo, agent, backlog = make_forgeo(git_repo, tmp_path)
|
|
65
65
|
await backlog.create_task(make_task())
|
|
66
66
|
agent.result = ExecutionResult(status=ExecutionStatus.SUCCESS)
|
|
@@ -69,12 +69,12 @@ async def test_task_success_without_changes_fails_task(git_repo, tmp_path):
|
|
|
69
69
|
|
|
70
70
|
assert outcome == "task"
|
|
71
71
|
task = await backlog.get_task("TASK-001")
|
|
72
|
-
assert task.status is TaskStatus.
|
|
73
|
-
assert task.
|
|
72
|
+
assert task.status is TaskStatus.BLOCKED
|
|
73
|
+
assert task.blocker_reason == [NO_CHANGES_REASON]
|
|
74
74
|
assert git(git_repo, "rev-list", "--count", "HEAD") == "1"
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
async def
|
|
77
|
+
async def test_no_changes_blocks_logs_warning(git_repo, tmp_path, caplog):
|
|
78
78
|
"""The no-change SUCCESS case is surfaced as a warning, never silent."""
|
|
79
79
|
forgeo, agent, backlog = make_forgeo(git_repo, tmp_path)
|
|
80
80
|
await backlog.create_task(make_task())
|
|
@@ -87,6 +87,52 @@ async def test_no_changes_failure_logs_warning(git_repo, tmp_path, caplog):
|
|
|
87
87
|
assert any("produced no changes" in r.message for r in caplog.records)
|
|
88
88
|
|
|
89
89
|
|
|
90
|
+
async def test_task_no_changes_is_retried_and_can_succeed(git_repo, tmp_path):
|
|
91
|
+
"""With no_changes_retry_max, a silent no-change SUCCESS re-runs the agent
|
|
92
|
+
in the same cycle; a later run that produces changes completes the task."""
|
|
93
|
+
forgeo, agent, backlog = make_forgeo(git_repo, tmp_path, no_changes_retry_max=2)
|
|
94
|
+
await backlog.create_task(make_task())
|
|
95
|
+
agent.result = ExecutionResult(status=ExecutionStatus.SUCCESS)
|
|
96
|
+
|
|
97
|
+
def produce_changes() -> None:
|
|
98
|
+
(git_repo / "app.py").write_text("def answer():\n return 7\n", encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
calls = 0
|
|
101
|
+
|
|
102
|
+
def effect() -> None:
|
|
103
|
+
nonlocal calls
|
|
104
|
+
calls += 1
|
|
105
|
+
if calls >= 3:
|
|
106
|
+
produce_changes()
|
|
107
|
+
|
|
108
|
+
agent.effect = effect
|
|
109
|
+
|
|
110
|
+
outcome = await forgeo.run_cycle()
|
|
111
|
+
|
|
112
|
+
assert outcome == "task"
|
|
113
|
+
task = await backlog.get_task("TASK-001")
|
|
114
|
+
assert task.status is TaskStatus.COMPLETED
|
|
115
|
+
assert calls == 3
|
|
116
|
+
assert git(git_repo, "log", "-1", "--format=%s") == "Do the thing"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def test_task_no_changes_retries_exhausted_blocks_task(git_repo, tmp_path):
|
|
120
|
+
"""Once the retry budget is spent, a silent no-change SUCCESS is marked
|
|
121
|
+
BLOCKED — never FAILED — so a human reviews the task."""
|
|
122
|
+
forgeo, agent, backlog = make_forgeo(git_repo, tmp_path, no_changes_retry_max=2)
|
|
123
|
+
await backlog.create_task(make_task())
|
|
124
|
+
agent.result = ExecutionResult(status=ExecutionStatus.SUCCESS)
|
|
125
|
+
|
|
126
|
+
outcome = await forgeo.run_cycle()
|
|
127
|
+
|
|
128
|
+
assert outcome == "task"
|
|
129
|
+
task = await backlog.get_task("TASK-001")
|
|
130
|
+
assert task.status is TaskStatus.BLOCKED
|
|
131
|
+
assert task.blocker_reason == [NO_CHANGES_REASON]
|
|
132
|
+
assert len(agent.calls) == 3
|
|
133
|
+
assert git(git_repo, "rev-list", "--count", "HEAD") == "1"
|
|
134
|
+
|
|
135
|
+
|
|
90
136
|
async def test_task_explicit_no_changes_completes_task(git_repo, tmp_path):
|
|
91
137
|
"""Exit no_changes_exit_code is the explicit no-op contract: the task is
|
|
92
138
|
COMPLETED without a commit, and the tree stays untouched."""
|
|
@@ -798,6 +844,10 @@ async def test_task_context_refreshed_each_run(git_repo, tmp_path):
|
|
|
798
844
|
context_file.write_text("Version one.\n", encoding="utf-8")
|
|
799
845
|
forgeo, agent, backlog = make_forgeo(git_repo, tmp_path, task_context=context_file)
|
|
800
846
|
await backlog.create_task(make_task())
|
|
847
|
+
agent.result = ExecutionResult(status=ExecutionStatus.SUCCESS)
|
|
848
|
+
agent.effect = lambda: (git_repo / "app.py").write_text(
|
|
849
|
+
"def answer():\n return 7\n", encoding="utf-8"
|
|
850
|
+
)
|
|
801
851
|
await forgeo.run_cycle()
|
|
802
852
|
context_file.write_text("Version two.\n", encoding="utf-8")
|
|
803
853
|
await backlog.create_task(make_task(id="TASK-002", description="Build it again."))
|
|
@@ -23,7 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
24
24
|
INSTALL_SH = REPO_ROOT / "install.sh"
|
|
25
25
|
PYPI_PACKAGE = "forgeo-cli"
|
|
26
|
-
VERSION = "0.7.
|
|
26
|
+
VERSION = "0.7.2"
|
|
27
27
|
SH = shutil.which("sh")
|
|
28
28
|
assert SH, "sh must be available to run install.sh"
|
|
29
29
|
|
|
@@ -142,6 +142,7 @@ def test_config_defaults():
|
|
|
142
142
|
assert config.notify_webhook_events == ["blocked"]
|
|
143
143
|
assert config.failed_retry_max == 0
|
|
144
144
|
assert config.failed_retry_wait_cycles == 1
|
|
145
|
+
assert config.no_changes_retry_max == 0
|
|
145
146
|
|
|
146
147
|
|
|
147
148
|
def test_config_rejects_negative_retry_settings():
|
|
@@ -149,12 +150,16 @@ def test_config_rejects_negative_retry_settings():
|
|
|
149
150
|
ForgeoConfig(agent_command="x", failed_retry_max=-1)
|
|
150
151
|
with pytest.raises(ValidationError):
|
|
151
152
|
ForgeoConfig(agent_command="x", failed_retry_wait_cycles=0)
|
|
153
|
+
with pytest.raises(ValidationError):
|
|
154
|
+
ForgeoConfig(agent_command="x", no_changes_retry_max=-1)
|
|
152
155
|
|
|
153
156
|
|
|
154
157
|
def test_config_accepts_retry_settings():
|
|
155
158
|
config = ForgeoConfig(agent_command="x", failed_retry_max=3, failed_retry_wait_cycles=2)
|
|
156
159
|
assert config.failed_retry_max == 3
|
|
157
160
|
assert config.failed_retry_wait_cycles == 2
|
|
161
|
+
config = ForgeoConfig(agent_command="x", no_changes_retry_max=5)
|
|
162
|
+
assert config.no_changes_retry_max == 5
|
|
158
163
|
|
|
159
164
|
|
|
160
165
|
def test_config_rejects_unknown_webhook_events():
|
|
@@ -52,8 +52,8 @@ async def test_task_success_appends_run_record(git_repo, tmp_path):
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
async def test_task_success_without_changes_has_reason(git_repo, tmp_path):
|
|
55
|
-
"""A no-change SUCCESS is surfaced on the run record:
|
|
56
|
-
|
|
55
|
+
"""A no-change SUCCESS is surfaced on the run record: the run ends BLOCKED
|
|
56
|
+
(the only acceptable no-change outcome) with an explicit reason, never a
|
|
57
57
|
silent null commit_sha."""
|
|
58
58
|
forgeo, agent, backlog = make_forgeo(git_repo, tmp_path)
|
|
59
59
|
await backlog.create_task(make_task())
|
|
@@ -62,10 +62,10 @@ async def test_task_success_without_changes_has_reason(git_repo, tmp_path):
|
|
|
62
62
|
assert await forgeo.run_cycle() == "task"
|
|
63
63
|
|
|
64
64
|
record = read_lines(runs_path(forgeo.config))[0]
|
|
65
|
-
assert record["outcome"] == "
|
|
65
|
+
assert record["outcome"] == "BLOCKED"
|
|
66
66
|
assert record["commit_sha"] is None
|
|
67
67
|
assert record["reason"] == NO_CHANGES_REASON
|
|
68
|
-
assert (await backlog.get_task("TASK-001")).status is TaskStatus.
|
|
68
|
+
assert (await backlog.get_task("TASK-001")).status is TaskStatus.BLOCKED
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
async def test_explicit_no_changes_record_has_reason(git_repo, tmp_path):
|
|
@@ -386,7 +386,7 @@ async def test_corrupt_runs_never_break_a_cycle(git_repo, tmp_path, caplog):
|
|
|
386
386
|
|
|
387
387
|
records = RunRecorder(runs).read()
|
|
388
388
|
assert len(records) == 1
|
|
389
|
-
assert records[0].outcome is RunOutcome.
|
|
389
|
+
assert records[0].outcome is RunOutcome.BLOCKED
|
|
390
390
|
assert records[0].task_id == "TASK-001"
|
|
391
391
|
|
|
392
392
|
|
|
@@ -1524,6 +1524,7 @@ def test_retry_config_fields_editable_in_config_tab(web_env):
|
|
|
1524
1524
|
assert status == 200
|
|
1525
1525
|
assert "failed_retry_max" in body
|
|
1526
1526
|
assert "failed_retry_wait_cycles" in body
|
|
1527
|
+
assert "no_changes_retry_max" in body
|
|
1527
1528
|
|
|
1528
1529
|
|
|
1529
1530
|
def test_tasks_endpoint_surfaces_run_at(registry, central_server):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|