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.
Files changed (79) hide show
  1. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/CHANGELOG.md +32 -1
  2. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/PKG-INFO +5 -1
  3. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/README.md +4 -0
  4. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/agent-contract.md +9 -5
  5. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/configuration.md +23 -0
  6. forgeo_cli-0.7.2/docs/img/demo.gif +0 -0
  7. forgeo_cli-0.7.2/docs/img/og.png +0 -0
  8. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/install.sh +1 -1
  9. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/pyproject.toml +1 -1
  10. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/__init__.py +1 -1
  11. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/central.py +26 -35
  12. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/cli.py +25 -15
  13. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/daemon_control.py +44 -34
  14. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/forgeo.py +99 -48
  15. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/models.py +6 -0
  16. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/runs.py +30 -7
  17. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/setup.py +4 -3
  18. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/central.js +1 -0
  19. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_factory.py +57 -7
  20. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_install.py +1 -1
  21. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_models.py +5 -0
  22. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_runs.py +5 -5
  23. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_web.py +1 -0
  24. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/.github/workflows/ci.yml +0 -0
  25. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/.gitignore +0 -0
  26. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/CONTRIBUTING.md +0 -0
  27. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/LICENSE +0 -0
  28. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/config/nginx-forgeo.conf +0 -0
  29. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/backlog.md +0 -0
  30. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/cli-reference.md +0 -0
  31. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/getting-started.md +0 -0
  32. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/img/console.png +0 -0
  33. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/img/logo.png +0 -0
  34. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/img/title.svg +0 -0
  35. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/index.md +0 -0
  36. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/docs/web-console-api.md +0 -0
  37. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/forgeo.spec +0 -0
  38. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/mkdocs.yml +0 -0
  39. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/scripts/__init__.py +0 -0
  40. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/scripts/render_homebrew_formula.py +0 -0
  41. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/__main__.py +0 -0
  42. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/agent.py +0 -0
  43. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/backlog.py +0 -0
  44. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/backlog_http.py +0 -0
  45. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/config.py +0 -0
  46. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/daemon.py +0 -0
  47. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/git.py +0 -0
  48. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/instances.py +0 -0
  49. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/io.py +0 -0
  50. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/notify.py +0 -0
  51. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/oauth.py +0 -0
  52. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/paths.py +0 -0
  53. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/update.py +0 -0
  54. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/validate.py +0 -0
  55. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/central.css +0 -0
  56. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/index.html +0 -0
  57. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/instance.html +0 -0
  58. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/central/login.html +0 -0
  59. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web/style.css +0 -0
  60. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/src/forgeo/web_common.py +0 -0
  61. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/conftest.py +0 -0
  62. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_agent.py +0 -0
  63. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_backlog.py +0 -0
  64. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_backlog_http.py +0 -0
  65. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_cli.py +0 -0
  66. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_daemon.py +0 -0
  67. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_git.py +0 -0
  68. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_instances.py +0 -0
  69. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_io.py +0 -0
  70. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_oauth.py +0 -0
  71. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_paths.py +0 -0
  72. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_remote_backlog_cycle.py +0 -0
  73. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_render_homebrew.py +0 -0
  74. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_setup.py +0 -0
  75. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_update.py +0 -0
  76. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_web_common.py +0 -0
  77. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/tests/test_web_lock.py +0 -0
  78. {forgeo_cli-0.7.0 → forgeo_cli-0.7.2}/www/404.html +0 -0
  79. {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.0...HEAD
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.0
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
  [![CI](https://github.com/lucaGazzola/forgeo/actions/workflows/ci.yml/badge.svg)](https://github.com/lucaGazzola/forgeo/actions/workflows/ci.yml)
65
65
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
  [![CI](https://github.com/lucaGazzola/forgeo/actions/workflows/ci.yml/badge.svg)](https://github.com/lucaGazzola/forgeo/actions/workflows/ci.yml)
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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** fails the task
73
- (`FAILED`, reason: *"Agent exited 0 but produced no changes"*);
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 fails the task);
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 (and CONTEXT.md if present) at the start of the session;
139
- if your change materially affects the project overview, keep them updated.
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
@@ -14,7 +14,7 @@ set -eu
14
14
 
15
15
  REPO_OWNER="lucaGazzola"
16
16
  REPO_NAME="forgeo"
17
- DEFAULT_VERSION="0.7.0"
17
+ DEFAULT_VERSION="0.7.2"
18
18
  MIN_PYTHON="3.11"
19
19
  PYPI_PACKAGE="forgeo-cli"
20
20
  PREFIX="${FORGEO_PREFIX:-${HOME:-}/.local}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forgeo-cli"
7
- version = "0.7.0"
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"
@@ -7,4 +7,4 @@ try:
7
7
 
8
8
  __version__ = _pkg_version("forgeo-cli")
9
9
  except PackageNotFoundError: # standalone binary: no installed package metadata
10
- __version__ = "0.7.0"
10
+ __version__ = "0.7.2"
@@ -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 do_GET(self) -> None:
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(self._do_get)
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
- if not self._maybe_authorize(self.path):
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
- if not self._maybe_authorize(self.path):
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
- if not self._maybe_authorize(self.path):
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
- if not self._maybe_authorize(self.path):
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.read(limit=limit, offset=offset)
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": recorder.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
- logger.info("Stopping central dashboard (pid %s)...", pid)
1371
- try:
1372
- os.kill(pid, signal.SIGTERM)
1373
- except ProcessLookupError:
1374
- if not lock.is_held():
1375
- logger.info("Central dashboard stopped.")
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=default_config,
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 cmd_once(args: argparse.Namespace) -> int:
658
- """Handle ``forgeo once``: run exactly one cycle and exit."""
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 _run_once() -> int:
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 _run_worker_with_lock(lock, _run_once)
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 _run_one() -> int:
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 _run_worker_with_lock(lock, _run_one)
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 wait_for_lock_release(
69
- lock_path: Path,
70
- timeout: float,
67
+ def signal_and_wait_for_release(
68
+ pid: int,
71
69
  *,
72
- is_held: Callable[[], bool] | None = None,
73
- ) -> bool:
74
- """Poll until the lock is released; False on timeout.
75
-
76
- ``is_held`` defaults to the flock-based :func:`is_lock_held`; pass a
77
- custom predicate (e.g. a PID-alive check for the web-dashboard lock) to
78
- reuse the same wait loop for other lock kinds.
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
- if is_held is None:
81
- is_held = functools.partial(is_lock_held, lock_path)
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
- return True
101
+ logger.info("%s stopped.", name)
102
+ return
86
103
  time.sleep(_POLL_SECONDS)
87
- return not is_held()
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
- logger.info("Stopping forgeo %r (pid %s)...", config.name, pid)
107
- try:
108
- os.kill(pid, signal.SIGTERM)
109
- except ProcessLookupError:
110
- if not is_lock_held(lock_path):
111
- logger.info("Forgeo %r stopped.", config.name)
112
- return
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
- return {
67
- ExecutionStatus.SUCCESS: RunOutcome.SUCCESS,
68
- ExecutionStatus.BLOCKED: RunOutcome.BLOCKED,
69
- ExecutionStatus.ERROR: RunOutcome.ERROR,
70
- }[status]
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
- if not await self._tree_clean_for(task):
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
- if not await self._tree_clean_for(task):
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
- result, ok = await self._run_agent(
360
- task,
361
- instruction=task.instruction,
362
- success_message=task.title,
363
- blocked_message=f"{task.title} [partial]",
364
- command=task.agent_command,
365
- timeout_seconds=task.agent_timeout_seconds,
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
- if result.status is ExecutionStatus.BLOCKED:
369
- await self.backlog.set_blocked(task.id, result.reason)
370
- return
371
- if result.status is ExecutionStatus.ERROR:
372
- await self._mark_failed(task, self._failure_reason(result))
373
- return
374
- if not ok:
375
- # SUCCESS whose commit (or no-change) path failed: already marked
376
- # FAILED inside _handle_execution_result.
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
- await self._fail_no_changes(task)
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 _fail_no_changes(self, task: Task) -> None:
545
- """Fail a task whose agent exited 0 without producing any changes.
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: the task is marked FAILED
549
- and the run record surfaces the reason instead of a silent null
550
- commit.
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 exited 0 but produced no changes; marking FAILED.", task.id
587
+ "Task %s: %s; marking BLOCKED.", task.id, NO_CHANGES_REASON
555
588
  )
556
- await self._fail(
557
- task,
558
- ExecutionResult(status=ExecutionStatus.ERROR, error=NO_CHANGES_REASON),
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
- if offset > 0:
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
- return len(self.read())
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 (and CONTEXT.md\n"
61
- "if present) at the start of the session; if your change materially\n"
62
- "affects the project overview, keep them updated.\n"
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 test_task_success_without_changes_fails_task(git_repo, tmp_path):
61
- """Exit 0 with an empty tree is not a valid completion: it is FAILED, so
62
- a silent no-change SUCCESS can never quietly close a task (regression:
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.FAILED
73
- assert task.failure_reason == [NO_CHANGES_REASON]
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 test_no_changes_failure_logs_warning(git_repo, tmp_path, caplog):
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.0"
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: outcome stays
56
- SUCCESS (the agent did exit 0) but with an explicit reason, never a
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"] == "SUCCESS"
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.FAILED
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.SUCCESS
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