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.
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/.gitignore +2 -1
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/CHANGELOG.md +31 -1
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/PKG-INFO +1 -1
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/agent-contract.md +6 -3
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/backlog.md +1 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/configuration.md +36 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/web-console-api.md +9 -4
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/install.sh +1 -1
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/pyproject.toml +1 -1
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/__init__.py +1 -1
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/backlog.py +53 -8
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/backlog_http.py +4 -2
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/forgeo.py +87 -36
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/models.py +30 -2
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.js +7 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/instance.html +10 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/conftest.py +13 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_backlog.py +115 -22
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_backlog_http.py +2 -2
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_factory.py +59 -9
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_install.py +1 -1
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_models.py +5 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_runs.py +12 -12
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_web.py +29 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/.github/workflows/ci.yml +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/CONTRIBUTING.md +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/LICENSE +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/README.md +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/config/nginx-forgeo.conf +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/cli-reference.md +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/getting-started.md +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/console.png +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/demo.gif +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/logo.png +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/og.png +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/img/title.svg +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/docs/index.md +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/forgeo.spec +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/mkdocs.yml +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/scripts/__init__.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/scripts/render_homebrew_formula.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/__main__.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/agent.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/central.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/cli.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/config.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/daemon.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/daemon_control.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/git.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/instances.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/io.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/notify.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/oauth.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/paths.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/runs.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/setup.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/update.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/validate.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.css +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/index.html +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/central/login.html +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web/style.css +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/src/forgeo/web_common.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_agent.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_cli.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_daemon.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_git.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_instances.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_io.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_oauth.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_paths.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_remote_backlog_cycle.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_render_homebrew.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_setup.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_update.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_web_common.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/tests/test_web_lock.py +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/www/404.html +0 -0
- {forgeo_cli-0.7.1 → forgeo_cli-0.7.3}/www/index.html +0 -0
|
@@ -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.
|
|
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.
|
|
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**
|
|
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.
|
|
@@ -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
|
|
333
|
-
(they are engine-managed — `PATCH` rejects them like it
|
|
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`,
|
|
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
|
|
@@ -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.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"
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
626
|
-
|
|
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.
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
|
549
|
-
"""
|
|
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
|
|
553
|
-
|
|
554
|
-
|
|
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
|
|
587
|
+
"Task %s: %s; marking BLOCKED.", task.id, NO_CHANGES_REASON
|
|
559
588
|
)
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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(
|
|
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``.
|
|
169
|
-
|
|
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
|