forgeo-cli 0.7.1__tar.gz → 0.7.3__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.1 → forgeo_cli-0.7.3}/.gitignore +2 -1
  2. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/CHANGELOG.md +31 -1
  3. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/PKG-INFO +1 -1
  4. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/agent-contract.md +6 -3
  5. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/backlog.md +1 -0
  6. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/configuration.md +36 -0
  7. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/web-console-api.md +9 -4
  8. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/install.sh +1 -1
  9. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/pyproject.toml +1 -1
  10. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/__init__.py +1 -1
  11. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/backlog.py +53 -8
  12. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/backlog_http.py +4 -2
  13. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/forgeo.py +87 -36
  14. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/models.py +30 -2
  15. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.js +7 -0
  16. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/instance.html +10 -0
  17. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/conftest.py +13 -0
  18. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_backlog.py +115 -22
  19. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_backlog_http.py +2 -2
  20. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_factory.py +59 -9
  21. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_install.py +1 -1
  22. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_models.py +5 -0
  23. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_runs.py +12 -12
  24. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_web.py +29 -0
  25. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/.github/workflows/ci.yml +0 -0
  26. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/CONTRIBUTING.md +0 -0
  27. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/LICENSE +0 -0
  28. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/README.md +0 -0
  29. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/config/nginx-forgeo.conf +0 -0
  30. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/cli-reference.md +0 -0
  31. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/getting-started.md +0 -0
  32. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/console.png +0 -0
  33. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/demo.gif +0 -0
  34. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/logo.png +0 -0
  35. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/og.png +0 -0
  36. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/title.svg +0 -0
  37. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/index.md +0 -0
  38. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/forgeo.spec +0 -0
  39. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/mkdocs.yml +0 -0
  40. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/scripts/__init__.py +0 -0
  41. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/scripts/render_homebrew_formula.py +0 -0
  42. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/__main__.py +0 -0
  43. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/agent.py +0 -0
  44. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/central.py +0 -0
  45. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/cli.py +0 -0
  46. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/config.py +0 -0
  47. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/daemon.py +0 -0
  48. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/daemon_control.py +0 -0
  49. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/git.py +0 -0
  50. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/instances.py +0 -0
  51. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/io.py +0 -0
  52. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/notify.py +0 -0
  53. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/oauth.py +0 -0
  54. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/paths.py +0 -0
  55. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/runs.py +0 -0
  56. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/setup.py +0 -0
  57. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/update.py +0 -0
  58. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/validate.py +0 -0
  59. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.css +0 -0
  60. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/index.html +0 -0
  61. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/login.html +0 -0
  62. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/style.css +0 -0
  63. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web_common.py +0 -0
  64. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_agent.py +0 -0
  65. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_cli.py +0 -0
  66. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_daemon.py +0 -0
  67. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_git.py +0 -0
  68. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_instances.py +0 -0
  69. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_io.py +0 -0
  70. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_oauth.py +0 -0
  71. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_paths.py +0 -0
  72. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_remote_backlog_cycle.py +0 -0
  73. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_render_homebrew.py +0 -0
  74. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_setup.py +0 -0
  75. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_update.py +0 -0
  76. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_web_common.py +0 -0
  77. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_web_lock.py +0 -0
  78. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/www/404.html +0 -0
  79. {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/www/index.html +0 -0
@@ -29,6 +29,7 @@ dist/
29
29
  # AI assistant context
30
30
  AGENTS.md
31
31
  CONTEXT.md
32
+ CLAUDE.md
32
33
 
33
34
  # Local deploy script (server-specific paths)
34
- scripts/deploy-docs.sh
35
+ scripts/deploy-docs.sh
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.3] - 2026-08-19
11
+
12
+ ### Added
13
+
14
+ - The agent's stdout/stderr is now persisted on the task as `agent_response`
15
+ when a task transitions (BLOCKED/FAILED/COMPLETED), shown in the web
16
+ console's task modal. A transition that carries no output never wipes a
17
+ previously stored response.
18
+ - `agent_response_lines` config key: how many agent output lines the task's
19
+ `agent_response` keeps on a status transition. Unbounded by default; `0`
20
+ disables persisting agent output on the task.
21
+
22
+ ## [0.7.2] - 2026-08-18
23
+
24
+ ### Added
25
+
26
+ - `no_changes_retry_max` config key: a task whose agent exits `0` without
27
+ producing any code changes is re-run immediately, in the same cycle, up to
28
+ that many extra times before it is marked `BLOCKED` for human review.
29
+
30
+ ### Changed
31
+
32
+ - A silent no-change SUCCESS (agent exits `0` with an unchanged working tree)
33
+ now marks the task `BLOCKED` instead of `FAILED`: the only acceptable
34
+ outcome for a run that ends without code changes is a blocked task awaiting
35
+ human review. An agent that needs no code change must still opt in
36
+ explicitly with `no_changes_exit_code` to complete the task.
37
+
10
38
  ## [0.7.1] - 2026-08-17
11
39
 
12
40
  ### Added
@@ -321,7 +349,9 @@ Initial release of the scheduled, agent-driven software forgeo.
321
349
  overlapping-run skipping.
322
350
  - Dogfooding docs removed; local configs kept out of the repository.
323
351
 
324
- [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.7.1...HEAD
352
+ [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.7.3...HEAD
353
+ [0.7.3]: https://github.com/lucaGazzola/forgeo/compare/v0.7.2...v0.7.3
354
+ [0.7.2]: https://github.com/lucaGazzola/forgeo/compare/v0.7.1...v0.7.2
325
355
  [0.7.1]: https://github.com/lucaGazzola/forgeo/compare/v0.7.0...v0.7.1
326
356
  [0.7.0]: https://github.com/lucaGazzola/forgeo/compare/v0.6.0...v0.7.0
327
357
  [0.6.0]: https://github.com/lucaGazzola/forgeo/compare/v0.5.0...v0.6.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: forgeo-cli
3
- Version: 0.7.1
3
+ Version: 0.7.3
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
@@ -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.
@@ -43,6 +43,7 @@ Each entry in `tasks` is a task object:
43
43
  | <span style="white-space: nowrap">`blocker_reason`</span> | list[string] | `[]` | Engine-managed: the agent's explanation (its questions, falling back to captured output) when the task becomes `BLOCKED`. Cleared on reopen; not editable via `PATCH`. |
44
44
  | <span style="white-space: nowrap">`blocked_count`</span> | integer | `0` | Engine-managed: how many times the task has transitioned into `BLOCKED`. Kept as history when the task is reopened, so you can see a task that keeps blocking needs splitting or rewriting rather than a blind retry. Not editable via `PATCH`. |
45
45
  | <span style="white-space: nowrap">`failure_reason`</span> | list[string] | `[]` | Engine-managed: the agent's error when the task becomes `FAILED` (e.g. a timeout message or a non-zero exit code). Shown in the web console's task modal so you can see why a task failed without opening the logs. Cleared when the task leaves the `FAILED` state; not editable via `PATCH`. |
46
+ | <span style="white-space: nowrap">`agent_response`</span> | string / `null` | `null` | Engine-managed: the agent's last stdout/stderr, stripped of its stream prefixes and persisted on the task's status transitions (bounded by `agent_response_lines` when set). Shown in the web console's task modal; not editable via `PATCH`. |
46
47
  | <span style="white-space: nowrap">`retries_left`</span> | integer / `null` | `null` | Per-task override of the automatic-retry budget (`failed_retry_max` in the config): how many times this task may be retried after a failure. `null` falls back to the config; `0` disables retries for this task. Editable via `PATCH`. |
47
48
  | <span style="white-space: nowrap">`retry_count`</span> | integer | `0` | Engine-managed: how many times this task has already been retried. Shown in `runs.jsonl` and the web console; reset when a human reopens a `FAILED` task. Not editable via `PATCH`. |
48
49
  | <span style="white-space: nowrap">`failed_wait_cycles`</span> | integer | `0` | Engine-managed: how many cycles this task has been `FAILED` awaiting a retry (backed off by `failed_retry_wait_cycles`). Reset when the task leaves `FAILED`. Not editable via `PATCH`. |
@@ -40,8 +40,10 @@ paths), so `forgeo restart` is still used for those.
40
40
  | <span style="white-space: nowrap">`log_file`</span> | `forgeo.log` | Where the daemon writes its log. |
41
41
  | <span style="white-space: nowrap">`run_history_keep`</span> | `2000` | How many finished runs `runs.jsonl` keeps (oldest trimmed atomically on append). `0` disables retention (file grows forever). |
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
+ | <span style="white-space: nowrap">`agent_response_lines`</span> | — (unbounded) | How many agent output lines the task's `agent_response` keeps on a status transition (the bounded tail of the agent's stdout/stderr, shown in the task modal / available to a backlog consumer). Omit = unbounded; `0` disables persisting agent output on the task. |
43
44
  | <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
45
  | <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`. |
46
+ | <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
47
  | <span style="white-space: nowrap">`git_timeout_seconds`</span> | `120` | Kill a git subprocess after this many seconds. |
46
48
  | <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
49
  | <span style="white-space: nowrap">`telegram_chat_id`</span> | — | Chat ID that receives blocked-run notifications (disabled unless `telegram_bot_token` is also set). |
@@ -225,6 +227,18 @@ Set `0` to stop persisting agent output entirely (run records stay small and
225
227
  the History tab shows nothing for them). Old run records written before this
226
228
  field existed simply have no output.
227
229
 
230
+ ### `agent_response_lines`
231
+
232
+ Alongside the per-run record above, Forgeo persists the agent's output on the
233
+ task itself (`agent_response`, shown in the task modal and available to a
234
+ backlog consumer served over HTTP). Unlike `run_output_lines` it is **unbounded
235
+ by default**: the whole stdout/stderr is stored, overwritten on each status
236
+ transition (a transition that carries no output never wipes a previously
237
+ stored response).
238
+
239
+ Set a positive value to keep only the last that many lines; set `0` to stop
240
+ persisting agent output on the task entirely.
241
+
228
242
  ### `failed_retry_max` / `failed_retry_wait_cycles`
229
243
 
230
244
  Some failures are transient — a network blip, a flaky test, a dependency
@@ -254,6 +268,28 @@ failed_retry_max: 3 # retry each FAILED task up to 3 times
254
268
  failed_retry_wait_cycles: 2 # back off 2 cycles before each retry
255
269
  ```
256
270
 
271
+ ### `no_changes_retry_max`
272
+
273
+ An agent that exits `0` while leaving the working tree unchanged has not
274
+ completed the task — the engine cannot tell "deliberately did nothing" from
275
+ "did nothing". By default (`no_changes_retry_max: 0`) that run is marked
276
+ `BLOCKED` on the first attempt: the only acceptable outcome for a no-change
277
+ run is a blocked task awaiting human review, never a silent completion and
278
+ never a `FAILED` task.
279
+
280
+ Sometimes the agent needs a second chance (a flaky model, a transient context
281
+ issue). Set a positive `no_changes_retry_max` and Forgeo re-runs the agent
282
+ immediately, back-to-back in the **same cycle**, that many extra times; a run
283
+ that finally produces changes completes the task, and one that still produces
284
+ nothing after the budget is spent is marked `BLOCKED`.
285
+
286
+ ```yaml
287
+ no_changes_retry_max: 2 # re-run the agent up to 2 extra times on a silent no-change
288
+ ```
289
+
290
+ `BLOCKED` tasks are never auto-retried — a human decides whether to reopen,
291
+ split, or drop the task (see [Backlog](backlog.md)).
292
+
257
293
  ### Telegram notifications
258
294
 
259
295
  Both `telegram_bot_token` **and** `telegram_chat_id` must be set for blocked
@@ -177,6 +177,7 @@ curl http://127.0.0.1:8790/api/instances/my-repo/tasks
177
177
  "blocker_reason": [],
178
178
  "blocked_count": 0,
179
179
  "failure_reason": [],
180
+ "agent_response": null,
180
181
  "created_at": "2026-07-31T10:00:00Z",
181
182
  "updated_at": "2026-07-31T10:00:00Z",
182
183
  "run_at": null,
@@ -258,6 +259,7 @@ curl -X POST http://127.0.0.1:8790/api/instances/my-repo/tasks \
258
259
  "blocker_reason": [],
259
260
  "blocked_count": 0,
260
261
  "failure_reason": [],
262
+ "agent_response": null,
261
263
  "created_at": "2026-08-01T12:00:00Z",
262
264
  "updated_at": "2026-08-01T12:00:00Z",
263
265
  "run_at": "2026-08-20T12:30:00Z",
@@ -300,6 +302,7 @@ curl -X POST http://127.0.0.1:8790/api/instances/my-repo/tasks/TASK-001/reopen
300
302
  "blocker_reason": [],
301
303
  "blocked_count": 1,
302
304
  "failure_reason": [],
305
+ "agent_response": null,
303
306
  "created_at": "2026-07-31T10:00:00Z",
304
307
  "updated_at": "2026-08-01T12:00:00Z",
305
308
  "dependencies": [],
@@ -329,8 +332,9 @@ budget override; a non-negative integer or `null`), and `run_at` (the
329
332
  optional one-shot schedule; an ISO-8601 datetime string or `null` to clear
330
333
  it). The request body is a JSON object; omitted fields are left unchanged
331
334
  and `id`, `status`, `blocker_reason`, `blocked_count`, `failure_reason`,
332
- `retry_count`, `failed_wait_cycles`, and `created_at` are always preserved
333
- (they are engine-managed — `PATCH` rejects them like it rejects `status`).
335
+ `agent_response`, `retry_count`, `failed_wait_cycles`, and `created_at` are
336
+ always preserved (they are engine-managed — `PATCH` rejects them like it
337
+ rejects `status`).
334
338
  `agent_command` may be a string, an array, or `null` (clear the per-task
335
339
  override); `agent_timeout_seconds` may be a positive number or `null`.
336
340
  `updated_at` is bumped to the current time.
@@ -350,6 +354,7 @@ curl -X PATCH http://127.0.0.1:8790/api/instances/my-repo/tasks/TASK-001 \
350
354
  "blocker_reason": [],
351
355
  "blocked_count": 0,
352
356
  "failure_reason": [],
357
+ "agent_response": null,
353
358
  "created_at": "2026-07-31T10:00:00Z",
354
359
  "updated_at": "2026-08-01T12:00:00Z",
355
360
  "dependencies": [],
@@ -364,8 +369,8 @@ Errors:
364
369
 
365
370
  - `400` with `{"error": "..."}` — unparseable or non-object body, an empty
366
371
  body, an unknown field (e.g. `status`, `blocker_reason`, `blocked_count`,
367
- `failure_reason`), or an invalid value (blank `title`, wrong field types, a
368
- non-positive `agent_timeout_seconds`).
372
+ `failure_reason`, `agent_response`), or an invalid value (blank `title`,
373
+ wrong field types, a non-positive `agent_timeout_seconds`).
369
374
  - `404` with `{"error": "not found"}` — the task id does not exist in that
370
375
  instance's backlog.
371
376
  - `404` with `{"error": "unknown instance"}` — the instance is not
@@ -14,7 +14,7 @@ set -eu
14
14
 
15
15
  REPO_OWNER="lucaGazzola"
16
16
  REPO_NAME="forgeo"
17
- DEFAULT_VERSION="0.7.1"
17
+ DEFAULT_VERSION="0.7.3"
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.1"
7
+ version = "0.7.3"
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.1"
10
+ __version__ = "0.7.3"
@@ -36,7 +36,7 @@ from typing import Any
36
36
  from pydantic import ValidationError
37
37
 
38
38
  from forgeo.io import atomic_write_text
39
- from forgeo.models import ForgeoConfig, Task, TaskStatus
39
+ from forgeo.models import ExecutionResult, ForgeoConfig, Task, TaskStatus
40
40
 
41
41
  logger = logging.getLogger(__name__)
42
42
 
@@ -59,6 +59,32 @@ EDITABLE_TASK_FIELDS = frozenset(
59
59
  )
60
60
 
61
61
 
62
+ def _join_output_logs(result: ExecutionResult, cap: int | None = None) -> str | None:
63
+ """The agent's output as one newline-joined string, ``None`` when empty.
64
+
65
+ ``BacklogStore`` persists agent output as a single string field, so the
66
+ agent's ``list[str]`` (its ``[stdout]``/``[stderr]``-prefixed lines) is
67
+ flattened here, prefixes stripped. An empty result means "nothing to
68
+ record" and must stay ``None`` — the caller then leaves any previously
69
+ stored response untouched rather than wiping it. ``cap`` bounds the number
70
+ of kept lines, mirroring ``agent_response_lines`` (``None`` = unbounded;
71
+ ``0``/negative = persist nothing).
72
+ """
73
+
74
+ prefixes = ("[stdout]", "[stderr]")
75
+ out = [
76
+ line[len(prefix) + 1 :]
77
+ for line in result.output_logs
78
+ for prefix in prefixes
79
+ if line.startswith(prefix)
80
+ ]
81
+ if cap is not None:
82
+ if cap <= 0:
83
+ return None
84
+ out = out[-cap:]
85
+ return "\n".join(out) if out else None
86
+
87
+
62
88
  def unsatisfied_dependencies(tasks: list[Task], task: Task) -> list[dict[str, str]]:
63
89
  """The dependencies of ``task`` that are not yet satisfied.
64
90
 
@@ -208,8 +234,9 @@ class BacklogStore(ABC):
208
234
  concurrent mutations within one process — lives here.
209
235
  """
210
236
 
211
- def __init__(self) -> None:
237
+ def __init__(self, *, output_cap: int | None = None) -> None:
212
238
  self._lock = asyncio.Lock()
239
+ self._output_cap = output_cap
213
240
 
214
241
  @abstractmethod
215
242
  async def _read(self) -> dict[str, Any]:
@@ -253,7 +280,9 @@ class BacklogStore(ABC):
253
280
  await self._write(store)
254
281
  return task
255
282
 
256
- async def update_status(self, task_id: str, status: TaskStatus) -> Task | None:
283
+ async def update_status(
284
+ self, task_id: str, status: TaskStatus, result: ExecutionResult
285
+ ) -> Task | None:
257
286
  """Transition a task's status, bumping its ``updated_at`` timestamp.
258
287
 
259
288
  Any transition away from ``FAILED`` clears the persisted
@@ -270,6 +299,9 @@ class BacklogStore(ABC):
270
299
  and status is not TaskStatus.FAILED
271
300
  )
272
301
  entry["status"] = status.value
302
+ joined = _join_output_logs(result, self._output_cap)
303
+ if joined is not None:
304
+ entry["agent_response"] = joined
273
305
  if status is not TaskStatus.FAILED:
274
306
  entry["failure_reason"] = []
275
307
  if leaving_failed:
@@ -278,7 +310,9 @@ class BacklogStore(ABC):
278
310
 
279
311
  return await self._update_entry(task_id, mutate)
280
312
 
281
- async def set_blocked(self, task_id: str, reason: list[str]) -> Task | None:
313
+ async def set_blocked(
314
+ self, task_id: str, reason: list[str], result: ExecutionResult
315
+ ) -> Task | None:
282
316
  """Mark a task ``BLOCKED``, persisting the agent's blocker reason.
283
317
 
284
318
  ``reason`` is stored on the task as ``blocker_reason`` (the source
@@ -291,10 +325,15 @@ class BacklogStore(ABC):
291
325
  entry["blocker_reason"] = list(reason)
292
326
  entry["blocked_count"] = int(entry.get("blocked_count", 0)) + 1
293
327
  entry["failure_reason"] = []
328
+ joined = _join_output_logs(result, self._output_cap)
329
+ if joined is not None:
330
+ entry["agent_response"] = joined
294
331
 
295
332
  return await self._update_entry(task_id, mutate)
296
333
 
297
- async def set_failed(self, task_id: str, reason: list[str]) -> Task | None:
334
+ async def set_failed(
335
+ self, task_id: str, reason: list[str], result: ExecutionResult
336
+ ) -> Task | None:
298
337
  """Mark a task ``FAILED``, persisting the failure reason.
299
338
 
300
339
  ``reason`` is stored on the task as ``failure_reason`` (shown in the
@@ -307,6 +346,9 @@ class BacklogStore(ABC):
307
346
  entry["status"] = TaskStatus.FAILED.value
308
347
  entry["failure_reason"] = list(reason)
309
348
  entry["failed_wait_cycles"] = 0
349
+ joined = _join_output_logs(result, self._output_cap)
350
+ if joined is not None:
351
+ entry["agent_response"] = joined
310
352
 
311
353
  return await self._update_entry(task_id, mutate)
312
354
 
@@ -490,8 +532,9 @@ class JSONBacklog(BacklogStore):
490
532
  path: str | Path,
491
533
  *,
492
534
  snapshot_count: int = DEFAULT_SNAPSHOT_COUNT,
535
+ output_cap: int | None = None,
493
536
  ) -> None:
494
- super().__init__()
537
+ super().__init__(output_cap=output_cap)
495
538
  self.path = Path(path)
496
539
  self.snapshot_count = max(0, snapshot_count)
497
540
 
@@ -622,5 +665,7 @@ def open_backlog(config: ForgeoConfig) -> BacklogStore:
622
665
  # it at module level would close an import cycle.
623
666
  from forgeo.backlog_http import HttpBacklog
624
667
 
625
- return HttpBacklog(str(config.backlog), auth=config.backlog_auth)
626
- return JSONBacklog(Path(config.backlog))
668
+ return HttpBacklog(
669
+ str(config.backlog), auth=config.backlog_auth, output_cap=config.agent_response_lines
670
+ )
671
+ return JSONBacklog(Path(config.backlog), output_cap=config.agent_response_lines)
@@ -55,8 +55,9 @@ class HttpBacklog(BacklogStore):
55
55
  *,
56
56
  auth: BacklogAuth | None = None,
57
57
  timeout_seconds: float = REQUEST_TIMEOUT_SECONDS,
58
+ output_cap: int | None = None,
58
59
  ) -> None:
59
- super().__init__()
60
+ super().__init__(output_cap=output_cap)
60
61
  self.url = url
61
62
  self.timeout_seconds = timeout_seconds
62
63
  self._tokens = (
@@ -69,7 +70,7 @@ class HttpBacklog(BacklogStore):
69
70
  async def _read(self) -> dict[str, Any]:
70
71
  """GET the document; anything but a usable response raises."""
71
72
  body = await asyncio.to_thread(self._request, "GET", None)
72
- logger.info("GET %s returned body: %s", self.url, body)
73
+ logger.debug("GET %s returned body: %s", self.url, body)
73
74
  try:
74
75
  data = json.loads(body) if body.strip() else {}
75
76
  except json.JSONDecodeError as exc:
@@ -81,6 +82,7 @@ class HttpBacklog(BacklogStore):
81
82
  async def _write(self, store: dict[str, Any]) -> None:
82
83
  """POST the whole document back."""
83
84
  payload = json.dumps(store, indent=2, ensure_ascii=False).encode("utf-8")
85
+ logger.debug("POST %s %s", self.url, payload)
84
86
  await asyncio.to_thread(self._request, "POST", payload)
85
87
 
86
88
  # ------------------------------------------------------------------ #
@@ -359,31 +359,54 @@ class Forgeo:
359
359
  # ------------------------------------------------------------------ #
360
360
 
361
361
  async def _run_task(self, task: Task) -> None:
362
- """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
+ """
363
369
  logger.info("Running task %s (%s)", task.id, task.title)
364
- result, ok = await self._run_agent(
365
- task,
366
- instruction=task.instruction,
367
- success_message=task.title,
368
- blocked_message=f"{task.title} [partial]",
369
- command=task.agent_command,
370
- timeout_seconds=task.agent_timeout_seconds,
371
- )
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
+ )
372
381
 
373
- if result.status is ExecutionStatus.BLOCKED:
374
- await self.backlog.set_blocked(task.id, result.reason)
375
- return
376
- if result.status is ExecutionStatus.ERROR:
377
- await self._mark_failed(task, self._failure_reason(result))
378
- return
379
- if not ok:
380
- # SUCCESS whose commit (or no-change) path failed: already marked
381
- # FAILED inside _handle_execution_result.
382
+ if result.status is ExecutionStatus.BLOCKED:
383
+ await self.backlog.set_blocked(task.id, result.reason, result)
384
+ return
385
+ if result.status is ExecutionStatus.ERROR:
386
+ await self._mark_failed(task, self._failure_reason(result), result)
387
+ return
388
+ if ok:
389
+ await self.backlog.update_status(task.id, TaskStatus.COMPLETED, result)
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)
382
409
  return
383
- await self.backlog.update_status(task.id, TaskStatus.COMPLETED)
384
- self.config.blocker_file.unlink(missing_ok=True)
385
- self._notify_webhook("completed", task, "")
386
- logger.info("Task %s completed.", task.id)
387
410
 
388
411
  async def _run_agent(
389
412
  self,
@@ -491,7 +514,10 @@ class Forgeo:
491
514
  # Exited 0 but produced no changes: not a completion. The
492
515
  # engine cannot tell "deliberately did nothing" from "did
493
516
  # nothing", so the agent must opt into no-ops explicitly.
494
- 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)
495
521
  return False
496
522
  return ok
497
523
 
@@ -533,34 +559,55 @@ class Forgeo:
533
559
  async def _fail(self, task: Task, result: ExecutionResult) -> None:
534
560
  """Discard the agent's work, mark the task FAILED, and log the error."""
535
561
  await self._discard_failed_work(task, result)
536
- await self._mark_failed(task, self._failure_reason(result))
562
+ await self._mark_failed(task, self._failure_reason(result), result)
537
563
 
538
- async def _mark_failed(self, task: Task, reason: list[str]) -> None:
564
+ async def _mark_failed(self, task: Task, reason: list[str], result: ExecutionResult) -> None:
539
565
  """Persist ``reason`` on ``task`` and send the ``failed`` webhook notice.
540
566
 
541
567
  Shared by the direct ERROR path (the work was already discarded in
542
568
  :meth:`_handle_execution_result`) and the FAILED transitions from
543
569
  :meth:`_fail` and :meth:`_complete_without_changes`.
544
570
  """
545
- await self.backlog.set_failed(task.id, reason)
571
+ await self.backlog.set_failed(task.id, reason, result)
546
572
  self._notify_webhook("failed", task, "\n".join(reason))
547
573
 
548
- async def _fail_no_changes(self, task: Task) -> None:
549
- """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.
550
578
 
551
579
  A no-change SUCCESS is indistinguishable from an agent that simply did
552
- nothing, so it is not a valid completion: the task is marked FAILED
553
- and the run record surfaces the reason instead of a silent null
554
- 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.
555
584
  """
556
585
  self._last_run_reason = NO_CHANGES_REASON
557
586
  logger.warning(
558
- "Task %s exited 0 but produced no changes; marking FAILED.", task.id
587
+ "Task %s: %s; marking BLOCKED.", task.id, NO_CHANGES_REASON
559
588
  )
560
- await self._fail(
561
- task,
562
- 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
+ ),
563
602
  )
603
+ self._last_agent_result = blocked
604
+ await self.backlog.set_blocked(task.id, [NO_CHANGES_REASON], blocked)
605
+ entry = BlockerEntry(
606
+ task=task,
607
+ result=blocked,
608
+ instruction=task.instruction,
609
+ )
610
+ self._notify_blocked(entry)
564
611
 
565
612
  async def _complete_without_changes(
566
613
  self, task: Task, *, is_refactor: bool = False
@@ -584,7 +631,11 @@ class Forgeo:
584
631
  is_refactor=is_refactor,
585
632
  )
586
633
  if not is_refactor:
587
- await self._mark_failed(task, [NO_CHANGES_DIRTY_REASON])
634
+ await self._mark_failed(
635
+ task,
636
+ [NO_CHANGES_DIRTY_REASON],
637
+ self._last_agent_result or ExecutionResult(status=ExecutionStatus.ERROR),
638
+ )
588
639
  return False
589
640
  self._last_run_reason = NO_CHANGES_REPORTED_REASON
590
641
  logger.info(
@@ -165,8 +165,10 @@ class Task(BaseModel):
165
165
  ``blocker_reason`` and ``blocked_count`` are engine-managed: they record
166
166
  the last agent explanation when the task becomes ``BLOCKED`` and how many
167
167
  times that happened, respectively. ``failure_reason`` is likewise
168
- engine-managed: the agent's error when the task becomes ``FAILED``. None
169
- of them are editable through the web console's ``PATCH`` endpoint.
168
+ engine-managed: the agent's error when the task becomes ``FAILED``.
169
+ ``agent_response`` is likewise engine-managed: the last agent
170
+ stdout/stderr output persisted on a status transition. None of them are
171
+ editable through the web console's ``PATCH`` endpoint.
170
172
  ``retries_left`` is a human-set per-task override of the retry budget
171
173
  (``failed_retry_max`` in the config); ``retry_count`` and
172
174
  ``failed_wait_cycles`` are engine-managed retry state.
@@ -194,6 +196,14 @@ class Task(BaseModel):
194
196
  blocker_reason: list[str] = Field(default_factory=list)
195
197
  blocked_count: int = Field(default=0, ge=0)
196
198
  failure_reason: list[str] = Field(default_factory=list)
199
+ agent_response: str | None = Field(
200
+ default=None,
201
+ description="Engine-managed: the agent's last stdout/stderr output, "
202
+ "stripped of its stream prefixes and persisted on status transitions "
203
+ "(capped at ``agent_response_lines`` lines when set). ``None`` when "
204
+ "the transition carried no output. Intended for the backlog consumer; "
205
+ "not editable via ``PATCH``.",
206
+ )
197
207
  retries_left: int | None = Field(
198
208
  default=None,
199
209
  ge=0,
@@ -409,12 +419,21 @@ class ForgeoConfig(BaseModel):
409
419
  run_output_lines: How many agent output lines each run record keeps
410
420
  in ``runs.jsonl`` (the bounded tail of the agent's stdout/stderr).
411
421
  ``0`` disables persisting agent output entirely.
422
+ agent_response_lines: How many agent output lines the task's
423
+ ``agent_response`` keeps when it transitions (the bounded tail of
424
+ the agent's stdout/stderr). ``None`` (default) = unbounded;
425
+ ``0`` disables persisting agent output on the task.
412
426
  failed_retry_max: How many times a ``FAILED`` task is retried
413
427
  automatically (``0`` = disabled: a task stays ``FAILED`` until a
414
428
  human reopens it, exactly as before). A task may override this
415
429
  budget per-task with ``retries_left``.
416
430
  failed_retry_wait_cycles: How many cycles a retry-eligible ``FAILED``
417
431
  task waits (backoff) before it is moved back to ``OPEN``.
432
+ no_changes_retry_max: How many times a task whose agent exits ``0``
433
+ without producing any code changes is re-run immediately, in the
434
+ same cycle, before the task is marked ``BLOCKED`` for human
435
+ review. ``0`` (default) = a silent no-change SUCCESS is marked
436
+ ``BLOCKED`` on the first attempt.
418
437
  telegram_bot_token: Telegram bot token for blocked-run
419
438
  notifications. Disabled unless ``telegram_chat_id`` is also set.
420
439
  telegram_chat_id: Chat ID that receives blocked-run notifications.
@@ -451,8 +470,17 @@ class ForgeoConfig(BaseModel):
451
470
  log_file: str = "forgeo.log"
452
471
  run_history_keep: int = Field(default=DEFAULT_RUN_HISTORY_KEEP, ge=0)
453
472
  run_output_lines: int = Field(default=DEFAULT_RUN_OUTPUT_LINES, ge=0)
473
+ agent_response_lines: int | None = Field(
474
+ default=None,
475
+ ge=0,
476
+ description="How many agent output lines the task's ``agent_response`` "
477
+ "keeps on a status transition (the bounded tail of the agent's "
478
+ "stdout/stderr). ``None`` (default) = unbounded; ``0`` disables "
479
+ "persisting agent output on the task.",
480
+ )
454
481
  failed_retry_max: int = Field(default=0, ge=0)
455
482
  failed_retry_wait_cycles: int = Field(default=1, ge=1)
483
+ no_changes_retry_max: int = Field(default=0, ge=0)
456
484
  telegram_bot_token: str | None = None
457
485
  telegram_chat_id: str | None = None
458
486
  notify_webhook_url: str | None = None