forgeo-cli 0.7.2__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.2 → forgeo_cli-0.7.3}/.gitignore +2 -1
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/CHANGELOG.md +14 -1
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/PKG-INFO +1 -1
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/backlog.md +1 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/configuration.md +13 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/web-console-api.md +9 -4
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/install.sh +1 -1
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/pyproject.toml +1 -1
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/__init__.py +1 -1
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/backlog.py +53 -8
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/backlog_http.py +4 -2
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/forgeo.py +12 -8
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/models.py +24 -2
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.js +6 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/instance.html +10 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/conftest.py +13 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_backlog.py +115 -22
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_backlog_http.py +2 -2
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_factory.py +2 -2
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_runs.py +7 -7
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_web.py +28 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/.github/workflows/ci.yml +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/CONTRIBUTING.md +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/LICENSE +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/README.md +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/config/nginx-forgeo.conf +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/agent-contract.md +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/cli-reference.md +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/getting-started.md +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/console.png +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/demo.gif +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/logo.png +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/og.png +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/title.svg +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/index.md +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/forgeo.spec +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/mkdocs.yml +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/scripts/__init__.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/scripts/render_homebrew_formula.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/__main__.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/agent.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/central.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/cli.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/config.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/daemon.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/daemon_control.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/git.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/instances.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/io.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/notify.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/oauth.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/paths.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/runs.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/setup.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/update.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/validate.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.css +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/index.html +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/login.html +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/style.css +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web_common.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_agent.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_cli.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_daemon.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_git.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_install.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_instances.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_io.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_models.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_oauth.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_paths.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_remote_backlog_cycle.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_render_homebrew.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_setup.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_update.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_web_common.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_web_lock.py +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/www/404.html +0 -0
- {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/www/index.html +0 -0
|
@@ -7,6 +7,18 @@ 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
|
+
|
|
10
22
|
## [0.7.2] - 2026-08-18
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -337,7 +349,8 @@ Initial release of the scheduled, agent-driven software forgeo.
|
|
|
337
349
|
overlapping-run skipping.
|
|
338
350
|
- Dogfooding docs removed; local configs kept out of the repository.
|
|
339
351
|
|
|
340
|
-
[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
|
|
341
354
|
[0.7.2]: https://github.com/lucaGazzola/forgeo/compare/v0.7.1...v0.7.2
|
|
342
355
|
[0.7.1]: https://github.com/lucaGazzola/forgeo/compare/v0.7.0...v0.7.1
|
|
343
356
|
[0.7.0]: https://github.com/lucaGazzola/forgeo/compare/v0.6.0...v0.7.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
|
|
@@ -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,6 +40,7 @@ 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`. |
|
|
45
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. |
|
|
@@ -226,6 +227,18 @@ Set `0` to stop persisting agent output entirely (run records stay small and
|
|
|
226
227
|
the History tab shows nothing for them). Old run records written before this
|
|
227
228
|
field existed simply have no output.
|
|
228
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
|
+
|
|
229
242
|
### `failed_retry_max` / `failed_retry_wait_cycles`
|
|
230
243
|
|
|
231
244
|
Some failures are transient — a network blip, a flaky test, a dependency
|
|
@@ -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
|
# ------------------------------------------------------------------ #
|
|
@@ -380,13 +380,13 @@ class Forgeo:
|
|
|
380
380
|
)
|
|
381
381
|
|
|
382
382
|
if result.status is ExecutionStatus.BLOCKED:
|
|
383
|
-
await self.backlog.set_blocked(task.id, result.reason)
|
|
383
|
+
await self.backlog.set_blocked(task.id, result.reason, result)
|
|
384
384
|
return
|
|
385
385
|
if result.status is ExecutionStatus.ERROR:
|
|
386
|
-
await self._mark_failed(task, self._failure_reason(result))
|
|
386
|
+
await self._mark_failed(task, self._failure_reason(result), result)
|
|
387
387
|
return
|
|
388
388
|
if ok:
|
|
389
|
-
await self.backlog.update_status(task.id, TaskStatus.COMPLETED)
|
|
389
|
+
await self.backlog.update_status(task.id, TaskStatus.COMPLETED, result)
|
|
390
390
|
self.config.blocker_file.unlink(missing_ok=True)
|
|
391
391
|
self._notify_webhook("completed", task, "")
|
|
392
392
|
logger.info("Task %s completed.", task.id)
|
|
@@ -559,16 +559,16 @@ class Forgeo:
|
|
|
559
559
|
async def _fail(self, task: Task, result: ExecutionResult) -> None:
|
|
560
560
|
"""Discard the agent's work, mark the task FAILED, and log the error."""
|
|
561
561
|
await self._discard_failed_work(task, result)
|
|
562
|
-
await self._mark_failed(task, self._failure_reason(result))
|
|
562
|
+
await self._mark_failed(task, self._failure_reason(result), result)
|
|
563
563
|
|
|
564
|
-
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:
|
|
565
565
|
"""Persist ``reason`` on ``task`` and send the ``failed`` webhook notice.
|
|
566
566
|
|
|
567
567
|
Shared by the direct ERROR path (the work was already discarded in
|
|
568
568
|
:meth:`_handle_execution_result`) and the FAILED transitions from
|
|
569
569
|
:meth:`_fail` and :meth:`_complete_without_changes`.
|
|
570
570
|
"""
|
|
571
|
-
await self.backlog.set_failed(task.id, reason)
|
|
571
|
+
await self.backlog.set_failed(task.id, reason, result)
|
|
572
572
|
self._notify_webhook("failed", task, "\n".join(reason))
|
|
573
573
|
|
|
574
574
|
async def _block_no_changes(self, task: Task) -> None:
|
|
@@ -601,7 +601,7 @@ class Forgeo:
|
|
|
601
601
|
),
|
|
602
602
|
)
|
|
603
603
|
self._last_agent_result = blocked
|
|
604
|
-
await self.backlog.set_blocked(task.id, [NO_CHANGES_REASON])
|
|
604
|
+
await self.backlog.set_blocked(task.id, [NO_CHANGES_REASON], blocked)
|
|
605
605
|
entry = BlockerEntry(
|
|
606
606
|
task=task,
|
|
607
607
|
result=blocked,
|
|
@@ -631,7 +631,11 @@ class Forgeo:
|
|
|
631
631
|
is_refactor=is_refactor,
|
|
632
632
|
)
|
|
633
633
|
if not is_refactor:
|
|
634
|
-
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
|
+
)
|
|
635
639
|
return False
|
|
636
640
|
self._last_run_reason = NO_CHANGES_REPORTED_REASON
|
|
637
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,6 +419,10 @@ 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
|
|
@@ -456,6 +470,14 @@ class ForgeoConfig(BaseModel):
|
|
|
456
470
|
log_file: str = "forgeo.log"
|
|
457
471
|
run_history_keep: int = Field(default=DEFAULT_RUN_HISTORY_KEEP, ge=0)
|
|
458
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
|
+
)
|
|
459
481
|
failed_retry_max: int = Field(default=0, ge=0)
|
|
460
482
|
failed_retry_wait_cycles: int = Field(default=1, ge=1)
|
|
461
483
|
no_changes_retry_max: int = Field(default=0, ge=0)
|
|
@@ -626,6 +626,10 @@
|
|
|
626
626
|
}
|
|
627
627
|
|
|
628
628
|
setText("task-modal-description", task.description || "");
|
|
629
|
+
var agentResponse = document.getElementById("task-modal-agent-response");
|
|
630
|
+
if (agentResponse) {
|
|
631
|
+
agentResponse.textContent = task.agent_response || "";
|
|
632
|
+
}
|
|
629
633
|
var acceptance = document.getElementById("task-modal-acceptance");
|
|
630
634
|
if (acceptance) {
|
|
631
635
|
acceptance.textContent = "";
|
|
@@ -696,6 +700,7 @@
|
|
|
696
700
|
task.files_to_modify && task.files_to_modify.length > 0
|
|
697
701
|
);
|
|
698
702
|
showModalSection("task-modal-command-section", Boolean(task.agent_command));
|
|
703
|
+
showModalSection("task-modal-agent-response-section", Boolean(task.agent_response));
|
|
699
704
|
showModalSection(
|
|
700
705
|
"task-modal-retries-section",
|
|
701
706
|
Boolean(retries && retries.textContent)
|
|
@@ -1211,6 +1216,7 @@
|
|
|
1211
1216
|
{ key: "blocker_file", label: "Blocker file", type: "text", hint: "Where BLOCKER.md is written when the agent needs human input. Keep it outside the repository." },
|
|
1212
1217
|
{ key: "log_file", label: "Log file", type: "text" },
|
|
1213
1218
|
{ 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." },
|
|
1219
|
+
{ key: "agent_response_lines", label: "Agent response lines", type: "number", min: 0, step: 1, optional: true, hint: "How many agent output lines the task's agent_response keeps on a status transition (shown in the task modal). Empty = unbounded. 0 = don't persist agent output on the task." },
|
|
1214
1220
|
{ 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
1221
|
{ 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
1222
|
{ 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." },
|
|
@@ -187,6 +187,16 @@
|
|
|
187
187
|
full output.
|
|
188
188
|
</p>
|
|
189
189
|
</section>
|
|
190
|
+
<section class="modal__section" id="task-modal-agent-response-section" hidden>
|
|
191
|
+
<h3>Agent output</h3>
|
|
192
|
+
<pre class="modal__command" id="task-modal-agent-response"></pre>
|
|
193
|
+
<p class="modal__blocked-note">
|
|
194
|
+
The agent's last stdout/stderr for this task, persisted on its most
|
|
195
|
+
recent status transition (bounded by the configured
|
|
196
|
+
<code>agent_response_lines</code>). The full per-run output is in the
|
|
197
|
+
History tab.
|
|
198
|
+
</p>
|
|
199
|
+
</section>
|
|
190
200
|
<div>
|
|
191
201
|
<span class="badge" id="task-modal-status"></span>
|
|
192
202
|
</div>
|
|
@@ -207,6 +207,19 @@ def make_task(**overrides) -> Task:
|
|
|
207
207
|
return Task(**defaults)
|
|
208
208
|
|
|
209
209
|
|
|
210
|
+
def make_result(**overrides) -> ExecutionResult:
|
|
211
|
+
"""An agent result carrying no output, for transitions under test directly.
|
|
212
|
+
|
|
213
|
+
The backlog mutators require the result that drove the transition, but a
|
|
214
|
+
test exercising only the status bookkeeping has no agent run behind it.
|
|
215
|
+
This is that "nothing was captured" result: the transition records no
|
|
216
|
+
``agent_response`` and leaves any previously stored one untouched.
|
|
217
|
+
"""
|
|
218
|
+
defaults = {"status": ExecutionStatus.SUCCESS}
|
|
219
|
+
defaults.update(overrides)
|
|
220
|
+
return ExecutionResult(**defaults)
|
|
221
|
+
|
|
222
|
+
|
|
210
223
|
def make_forgeo(
|
|
211
224
|
git_repo: Path, tmp_path: Path, **overrides
|
|
212
225
|
) -> tuple[Forgeo, FakeAgent, JSONBacklog]:
|
|
@@ -12,10 +12,11 @@ from forgeo.backlog import (
|
|
|
12
12
|
JSONBacklog,
|
|
13
13
|
next_due_run_at,
|
|
14
14
|
oldest_open_task,
|
|
15
|
+
open_backlog,
|
|
15
16
|
unsatisfied_dependencies,
|
|
16
17
|
)
|
|
17
18
|
from forgeo.models import Task, TaskStatus
|
|
18
|
-
from tests.conftest import make_task
|
|
19
|
+
from tests.conftest import make_config, make_result, make_task
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def test_oldest_open_task_picks_oldest():
|
|
@@ -292,7 +293,7 @@ async def test_fetch_prefers_open_over_blocked(tmp_path):
|
|
|
292
293
|
async def test_update_status_persists_and_bumps_timestamp(tmp_path):
|
|
293
294
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
294
295
|
task = await backlog.create_task(make_task())
|
|
295
|
-
updated = await backlog.update_status(task.id, TaskStatus.COMPLETED)
|
|
296
|
+
updated = await backlog.update_status(task.id, TaskStatus.COMPLETED, make_result())
|
|
296
297
|
assert updated.status is TaskStatus.COMPLETED
|
|
297
298
|
stored = await backlog.get_task(task.id)
|
|
298
299
|
assert stored.status is TaskStatus.COMPLETED
|
|
@@ -302,18 +303,18 @@ async def test_update_status_persists_and_bumps_timestamp(tmp_path):
|
|
|
302
303
|
async def test_update_status_unknown_id_returns_none(tmp_path):
|
|
303
304
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
304
305
|
await backlog.create_task(make_task())
|
|
305
|
-
assert await backlog.update_status("MISSING", TaskStatus.COMPLETED) is None
|
|
306
|
+
assert await backlog.update_status("MISSING", TaskStatus.COMPLETED, make_result()) is None
|
|
306
307
|
|
|
307
308
|
|
|
308
309
|
async def test_set_blocked_persists_reason_and_increments_count(tmp_path):
|
|
309
310
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
310
311
|
task = await backlog.create_task(make_task())
|
|
311
|
-
blocked = await backlog.set_blocked(task.id, ["I need a decision"])
|
|
312
|
+
blocked = await backlog.set_blocked(task.id, ["I need a decision"], make_result())
|
|
312
313
|
assert blocked.status is TaskStatus.BLOCKED
|
|
313
314
|
assert blocked.blocker_reason == ["I need a decision"]
|
|
314
315
|
assert blocked.blocked_count == 1
|
|
315
316
|
|
|
316
|
-
blocked = await backlog.set_blocked(task.id, ["Another decision"])
|
|
317
|
+
blocked = await backlog.set_blocked(task.id, ["Another decision"], make_result())
|
|
317
318
|
assert blocked.blocked_count == 2
|
|
318
319
|
assert blocked.blocker_reason == ["Another decision"]
|
|
319
320
|
|
|
@@ -332,17 +333,17 @@ async def test_set_blocked_persists_reason_and_increments_count(tmp_path):
|
|
|
332
333
|
async def test_set_blocked_unknown_id_returns_none(tmp_path):
|
|
333
334
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
334
335
|
await backlog.create_task(make_task())
|
|
335
|
-
assert await backlog.set_blocked("MISSING", ["?"]) is None
|
|
336
|
+
assert await backlog.set_blocked("MISSING", ["?"], make_result()) is None
|
|
336
337
|
|
|
337
338
|
|
|
338
339
|
async def test_set_failed_persists_reason(tmp_path):
|
|
339
340
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
340
341
|
task = await backlog.create_task(make_task())
|
|
341
|
-
failed = await backlog.set_failed(task.id, ["timed out after 60s"])
|
|
342
|
+
failed = await backlog.set_failed(task.id, ["timed out after 60s"], make_result())
|
|
342
343
|
assert failed.status is TaskStatus.FAILED
|
|
343
344
|
assert failed.failure_reason == ["timed out after 60s"]
|
|
344
345
|
|
|
345
|
-
failed = await backlog.set_failed(task.id, ["exit code 3"])
|
|
346
|
+
failed = await backlog.set_failed(task.id, ["exit code 3"], make_result())
|
|
346
347
|
assert failed.status is TaskStatus.FAILED
|
|
347
348
|
assert failed.failure_reason == ["exit code 3"]
|
|
348
349
|
|
|
@@ -359,14 +360,106 @@ async def test_set_failed_persists_reason(tmp_path):
|
|
|
359
360
|
async def test_set_failed_unknown_id_returns_none(tmp_path):
|
|
360
361
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
361
362
|
await backlog.create_task(make_task())
|
|
362
|
-
assert await backlog.set_failed("MISSING", ["?"]) is None
|
|
363
|
+
assert await backlog.set_failed("MISSING", ["?"], make_result()) is None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def test_agent_response_persists_output_on_transitions(tmp_path):
|
|
367
|
+
"""Status transitions persist the agent output, stream prefixes stripped."""
|
|
368
|
+
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
369
|
+
task = await backlog.create_task(make_task())
|
|
370
|
+
result = make_result(
|
|
371
|
+
output_logs=["[stdout] first line", "[stderr] warning", "untagged line"]
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
failed = await backlog.set_failed(task.id, ["boom"], result)
|
|
375
|
+
assert failed.agent_response == "first line\nwarning"
|
|
376
|
+
assert (await backlog.get_task(task.id)).agent_response == "first line\nwarning"
|
|
377
|
+
|
|
378
|
+
blocked = await backlog.set_blocked(task.id, ["blocked"], result)
|
|
379
|
+
assert blocked.agent_response == "first line\nwarning"
|
|
380
|
+
|
|
381
|
+
completed = await backlog.update_status(task.id, TaskStatus.COMPLETED, result)
|
|
382
|
+
assert completed.agent_response == "first line\nwarning"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def test_agent_response_none_without_output(tmp_path):
|
|
386
|
+
"""A transition carrying no output records no agent_response."""
|
|
387
|
+
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
388
|
+
task = await backlog.create_task(make_task())
|
|
389
|
+
|
|
390
|
+
failed = await backlog.set_failed(task.id, ["boom"], make_result())
|
|
391
|
+
assert failed.agent_response is None
|
|
392
|
+
assert (await backlog.get_task(task.id)).agent_response is None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def test_agent_response_preserves_previous_when_nothing_to_record(tmp_path):
|
|
396
|
+
"""A transition with no output never wipes a previously stored response."""
|
|
397
|
+
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
398
|
+
task = await backlog.create_task(make_task())
|
|
399
|
+
await backlog.set_failed(
|
|
400
|
+
task.id, ["boom"], make_result(output_logs=["[stderr] original failure"])
|
|
401
|
+
)
|
|
402
|
+
assert (await backlog.get_task(task.id)).agent_response == "original failure"
|
|
403
|
+
|
|
404
|
+
await backlog.update_status(task.id, TaskStatus.OPEN, make_result())
|
|
405
|
+
stored = await backlog.get_task(task.id)
|
|
406
|
+
assert stored.status is TaskStatus.OPEN
|
|
407
|
+
assert stored.agent_response == "original failure"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
async def test_agent_response_capped(tmp_path):
|
|
411
|
+
"""agent_response keeps at most ``output_cap`` lines, the latest ones."""
|
|
412
|
+
backlog = JSONBacklog(tmp_path / "backlog.json", output_cap=2)
|
|
413
|
+
task = await backlog.create_task(make_task())
|
|
414
|
+
result = make_result(
|
|
415
|
+
output_logs=[f"[stdout] line {index}" for index in range(4)]
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
failed = await backlog.set_failed(task.id, ["boom"], result)
|
|
419
|
+
assert failed.agent_response == "line 2\nline 3"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def test_agent_response_disabled_with_zero_cap(tmp_path):
|
|
423
|
+
"""output_cap=0 mirrors agent_response_lines=0: nothing is persisted."""
|
|
424
|
+
backlog = JSONBacklog(tmp_path / "backlog.json", output_cap=0)
|
|
425
|
+
task = await backlog.create_task(make_task())
|
|
426
|
+
result = make_result(output_logs=["[stdout] line"])
|
|
427
|
+
|
|
428
|
+
failed = await backlog.set_failed(task.id, ["boom"], result)
|
|
429
|
+
assert failed.agent_response is None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
async def test_agent_response_unbounded_by_default(tmp_path):
|
|
433
|
+
"""Without agent_response_lines every output line is persisted."""
|
|
434
|
+
config = make_config(tmp_path, tmp_path)
|
|
435
|
+
assert config.agent_response_lines is None
|
|
436
|
+
backlog = open_backlog(config)
|
|
437
|
+
task = await backlog.create_task(make_task())
|
|
438
|
+
result = make_result(
|
|
439
|
+
output_logs=[f"[stdout] line {index}" for index in range(500)]
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
failed = await backlog.set_failed(task.id, ["boom"], result)
|
|
443
|
+
assert failed.agent_response == "\n".join(f"line {index}" for index in range(500))
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def test_open_backlog_applies_agent_response_lines_cap(tmp_path):
|
|
447
|
+
"""open_backlog wires the config's agent_response_lines into the store."""
|
|
448
|
+
config = make_config(tmp_path, tmp_path)
|
|
449
|
+
config.agent_response_lines = 1
|
|
450
|
+
backlog = open_backlog(config)
|
|
451
|
+
task = await backlog.create_task(make_task())
|
|
452
|
+
result = make_result(output_logs=["[stdout] first", "[stdout] second"])
|
|
453
|
+
|
|
454
|
+
failed = await backlog.set_failed(task.id, ["boom"], result)
|
|
455
|
+
assert failed.agent_response == "second"
|
|
363
456
|
|
|
364
457
|
|
|
365
458
|
async def test_update_status_clears_failure_reason(tmp_path):
|
|
366
459
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
367
460
|
task = await backlog.create_task(make_task())
|
|
368
|
-
await backlog.set_failed(task.id, ["timed out after 60s"])
|
|
369
|
-
updated = await backlog.update_status(task.id, TaskStatus.OPEN)
|
|
461
|
+
await backlog.set_failed(task.id, ["timed out after 60s"], make_result())
|
|
462
|
+
updated = await backlog.update_status(task.id, TaskStatus.OPEN, make_result())
|
|
370
463
|
assert updated.status is TaskStatus.OPEN
|
|
371
464
|
assert updated.failure_reason == []
|
|
372
465
|
stored = await backlog.get_task(task.id)
|
|
@@ -376,8 +469,8 @@ async def test_update_status_clears_failure_reason(tmp_path):
|
|
|
376
469
|
async def test_set_blocked_clears_failure_reason(tmp_path):
|
|
377
470
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
378
471
|
task = await backlog.create_task(make_task())
|
|
379
|
-
await backlog.set_failed(task.id, ["timed out after 60s"])
|
|
380
|
-
blocked = await backlog.set_blocked(task.id, ["I need a decision"])
|
|
472
|
+
await backlog.set_failed(task.id, ["timed out after 60s"], make_result())
|
|
473
|
+
blocked = await backlog.set_blocked(task.id, ["I need a decision"], make_result())
|
|
381
474
|
assert blocked.status is TaskStatus.BLOCKED
|
|
382
475
|
assert blocked.failure_reason == []
|
|
383
476
|
assert blocked.blocker_reason == ["I need a decision"]
|
|
@@ -386,7 +479,7 @@ async def test_set_blocked_clears_failure_reason(tmp_path):
|
|
|
386
479
|
async def test_reopen_task_clears_failure_reason(tmp_path):
|
|
387
480
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
388
481
|
task = await backlog.create_task(make_task())
|
|
389
|
-
await backlog.set_failed(task.id, ["timed out after 60s"])
|
|
482
|
+
await backlog.set_failed(task.id, ["timed out after 60s"], make_result())
|
|
390
483
|
reopened = await backlog.reopen_task(task.id)
|
|
391
484
|
assert reopened.status is TaskStatus.OPEN
|
|
392
485
|
assert reopened.failure_reason == []
|
|
@@ -395,7 +488,7 @@ async def test_reopen_task_clears_failure_reason(tmp_path):
|
|
|
395
488
|
async def test_reopen_task_clears_reason_keeps_count(tmp_path):
|
|
396
489
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
397
490
|
task = await backlog.create_task(make_task())
|
|
398
|
-
await backlog.set_blocked(task.id, ["I need a decision"])
|
|
491
|
+
await backlog.set_blocked(task.id, ["I need a decision"], make_result())
|
|
399
492
|
reopened = await backlog.reopen_task(task.id)
|
|
400
493
|
assert reopened.status is TaskStatus.OPEN
|
|
401
494
|
assert reopened.blocker_reason == []
|
|
@@ -708,11 +801,11 @@ async def test_set_failed_resets_failed_wait_cycles(tmp_path):
|
|
|
708
801
|
"""A fresh FAILED transition restarts the retry backoff."""
|
|
709
802
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
710
803
|
task = await backlog.create_task(make_task())
|
|
711
|
-
await backlog.set_failed(task.id, ["boom"])
|
|
804
|
+
await backlog.set_failed(task.id, ["boom"], make_result())
|
|
712
805
|
await backlog.bump_failed_wait(task.id)
|
|
713
806
|
assert (await backlog.get_task(task.id)).failed_wait_cycles == 1
|
|
714
807
|
|
|
715
|
-
refailed = await backlog.set_failed(task.id, ["boom again"])
|
|
808
|
+
refailed = await backlog.set_failed(task.id, ["boom again"], make_result())
|
|
716
809
|
assert refailed.failed_wait_cycles == 0
|
|
717
810
|
assert refailed.failure_reason == ["boom again"]
|
|
718
811
|
stored = await backlog.get_task(task.id)
|
|
@@ -723,7 +816,7 @@ async def test_set_failed_resets_failed_wait_cycles(tmp_path):
|
|
|
723
816
|
async def test_bump_failed_wait_increments(tmp_path):
|
|
724
817
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
725
818
|
task = await backlog.create_task(make_task())
|
|
726
|
-
await backlog.set_failed(task.id, ["boom"])
|
|
819
|
+
await backlog.set_failed(task.id, ["boom"], make_result())
|
|
727
820
|
bumped = await backlog.bump_failed_wait(task.id)
|
|
728
821
|
assert bumped.status is TaskStatus.FAILED
|
|
729
822
|
assert bumped.failed_wait_cycles == 1
|
|
@@ -735,7 +828,7 @@ async def test_bump_failed_wait_increments(tmp_path):
|
|
|
735
828
|
async def test_retry_task_reopens_and_increments_retry_count(tmp_path):
|
|
736
829
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
737
830
|
task = await backlog.create_task(make_task())
|
|
738
|
-
await backlog.set_failed(task.id, ["boom"])
|
|
831
|
+
await backlog.set_failed(task.id, ["boom"], make_result())
|
|
739
832
|
retried = await backlog.retry_task(task.id)
|
|
740
833
|
assert retried.status is TaskStatus.OPEN
|
|
741
834
|
assert retried.retry_count == 1
|
|
@@ -764,15 +857,15 @@ async def test_update_status_leaving_failed_resets_retry_state(tmp_path):
|
|
|
764
857
|
the human's retry gets a fresh failed_retry_max."""
|
|
765
858
|
backlog = JSONBacklog(tmp_path / "backlog.json")
|
|
766
859
|
task = await backlog.create_task(make_task())
|
|
767
|
-
await backlog.set_failed(task.id, ["boom"])
|
|
860
|
+
await backlog.set_failed(task.id, ["boom"], make_result())
|
|
768
861
|
await backlog.retry_task(task.id)
|
|
769
|
-
await backlog.set_failed(task.id, ["boom again"])
|
|
862
|
+
await backlog.set_failed(task.id, ["boom again"], make_result())
|
|
770
863
|
await backlog.bump_failed_wait(task.id)
|
|
771
864
|
stored = await backlog.get_task(task.id)
|
|
772
865
|
assert stored.retry_count == 1
|
|
773
866
|
assert stored.failed_wait_cycles == 1
|
|
774
867
|
|
|
775
|
-
reopened = await backlog.update_status(task.id, TaskStatus.OPEN)
|
|
868
|
+
reopened = await backlog.update_status(task.id, TaskStatus.OPEN, make_result())
|
|
776
869
|
assert reopened.status is TaskStatus.OPEN
|
|
777
870
|
assert reopened.retry_count == 0
|
|
778
871
|
assert reopened.failed_wait_cycles == 0
|
|
@@ -16,7 +16,7 @@ import pytest
|
|
|
16
16
|
from forgeo.backlog import open_backlog
|
|
17
17
|
from forgeo.backlog_http import BacklogUnavailableError, HttpBacklog
|
|
18
18
|
from forgeo.models import BacklogAuth, ForgeoConfig, TaskStatus
|
|
19
|
-
from tests.conftest import make_task
|
|
19
|
+
from tests.conftest import make_result, make_task
|
|
20
20
|
|
|
21
21
|
URL = "https://api.example.com/api/forgeo/backlog"
|
|
22
22
|
SECRET_ENV = "FORGEO_TEST_CLIENT_SECRET"
|
|
@@ -104,7 +104,7 @@ async def test_status_transitions_round_trip_through_the_endpoint(
|
|
|
104
104
|
backlog = HttpBacklog(URL)
|
|
105
105
|
await backlog.create_task(make_task(id="TASK-001"))
|
|
106
106
|
|
|
107
|
-
await backlog.set_blocked("TASK-001", ["needs a decision"])
|
|
107
|
+
await backlog.set_blocked("TASK-001", ["needs a decision"], make_result())
|
|
108
108
|
stored = endpoint.document["tasks"][0]
|
|
109
109
|
assert stored["status"] == "BLOCKED"
|
|
110
110
|
assert stored["blocker_reason"] == ["needs a decision"]
|
|
@@ -20,7 +20,7 @@ from forgeo.models import (
|
|
|
20
20
|
ExecutionStatus,
|
|
21
21
|
TaskStatus,
|
|
22
22
|
)
|
|
23
|
-
from tests.conftest import git, make_forgeo, make_task
|
|
23
|
+
from tests.conftest import git, make_forgeo, make_result, make_task
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class FakeResponse:
|
|
@@ -1126,7 +1126,7 @@ async def test_manual_reopen_of_failed_task_resets_retry_budget(git_repo, tmp_pa
|
|
|
1126
1126
|
assert task.status is TaskStatus.FAILED
|
|
1127
1127
|
assert task.retry_count == 1
|
|
1128
1128
|
|
|
1129
|
-
await backlog.update_status("TASK-001", TaskStatus.OPEN) # manual reopen
|
|
1129
|
+
await backlog.update_status("TASK-001", TaskStatus.OPEN, make_result()) # manual reopen
|
|
1130
1130
|
task = await backlog.get_task("TASK-001")
|
|
1131
1131
|
assert task.status is TaskStatus.OPEN
|
|
1132
1132
|
assert task.retry_count == 0
|
|
@@ -20,7 +20,7 @@ from forgeo.models import (
|
|
|
20
20
|
)
|
|
21
21
|
from forgeo.paths import runs_path
|
|
22
22
|
from forgeo.runs import RunRecorder
|
|
23
|
-
from tests.conftest import git, make_forgeo, make_task
|
|
23
|
+
from tests.conftest import git, make_forgeo, make_result, make_task
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
def read_lines(path) -> list[dict]:
|
|
@@ -221,12 +221,12 @@ async def test_every_cycle_appends_exactly_one_line(git_repo, tmp_path):
|
|
|
221
221
|
assert await forgeo.run_cycle() == "task"
|
|
222
222
|
assert len(read_lines(runs)) == 1
|
|
223
223
|
|
|
224
|
-
await backlog.update_status("TASK-001", TaskStatus.OPEN)
|
|
224
|
+
await backlog.update_status("TASK-001", TaskStatus.OPEN, make_result())
|
|
225
225
|
agent.result = ExecutionResult(status=ExecutionStatus.ERROR, error="boom", exit_code=3)
|
|
226
226
|
assert await forgeo.run_cycle() == "task"
|
|
227
227
|
assert len(read_lines(runs)) == 2
|
|
228
228
|
|
|
229
|
-
await backlog.update_status("TASK-001", TaskStatus.OPEN)
|
|
229
|
+
await backlog.update_status("TASK-001", TaskStatus.OPEN, make_result())
|
|
230
230
|
agent.result = ExecutionResult(status=ExecutionStatus.BLOCKED, questions=["?"], exit_code=2)
|
|
231
231
|
assert await forgeo.run_cycle() == "task"
|
|
232
232
|
assert len(read_lines(runs)) == 3
|
|
@@ -234,18 +234,18 @@ async def test_every_cycle_appends_exactly_one_line(git_repo, tmp_path):
|
|
|
234
234
|
assert await forgeo.run_cycle() == "blocked"
|
|
235
235
|
assert len(read_lines(runs)) == 4
|
|
236
236
|
|
|
237
|
-
await backlog.update_status("TASK-001", TaskStatus.COMPLETED)
|
|
237
|
+
await backlog.update_status("TASK-001", TaskStatus.COMPLETED, make_result())
|
|
238
238
|
forgeo.config.blocker_file.write_text("stale", encoding="utf-8")
|
|
239
239
|
assert await forgeo.run_cycle() == "paused"
|
|
240
240
|
assert len(read_lines(runs)) == 5
|
|
241
241
|
|
|
242
242
|
forgeo.config.blocker_file.unlink()
|
|
243
|
-
await backlog.update_status("TASK-001", TaskStatus.OPEN)
|
|
243
|
+
await backlog.update_status("TASK-001", TaskStatus.OPEN, make_result())
|
|
244
244
|
(git_repo / "manual.txt").write_text("wip\n", encoding="utf-8")
|
|
245
245
|
assert await forgeo.run_cycle() == "dirty"
|
|
246
246
|
assert len(read_lines(runs)) == 6
|
|
247
247
|
|
|
248
|
-
await backlog.update_status("TASK-001", TaskStatus.COMPLETED)
|
|
248
|
+
await backlog.update_status("TASK-001", TaskStatus.COMPLETED, make_result())
|
|
249
249
|
git(git_repo, "clean", "-fd")
|
|
250
250
|
agent.result = ExecutionResult(status=ExecutionStatus.SUCCESS, exit_code=0)
|
|
251
251
|
assert await forgeo.run_cycle() == "refactor"
|
|
@@ -473,7 +473,7 @@ async def test_cycle_applies_configured_retention(git_repo, tmp_path):
|
|
|
473
473
|
for _ in range(4):
|
|
474
474
|
agent.result = ExecutionResult(status=ExecutionStatus.SUCCESS, exit_code=0)
|
|
475
475
|
assert await forgeo.run_cycle() == "task"
|
|
476
|
-
await backlog.update_status("TASK-001", TaskStatus.OPEN)
|
|
476
|
+
await backlog.update_status("TASK-001", TaskStatus.OPEN, make_result())
|
|
477
477
|
|
|
478
478
|
lines = read_lines(runs)
|
|
479
479
|
assert len(lines) == 2
|
|
@@ -463,6 +463,7 @@ def test_instance_page_has_task_edit_modal(web_env):
|
|
|
463
463
|
assert 'id="task-modal-reopen"' in body
|
|
464
464
|
assert 'id="task-modal-blocker-section"' in body
|
|
465
465
|
assert 'id="task-modal-failure-section"' in body
|
|
466
|
+
assert 'id="task-modal-agent-response-section"' in body
|
|
466
467
|
assert 'id="task-modal-delete"' in body
|
|
467
468
|
assert 'id="task-modal-edit-form"' in body
|
|
468
469
|
assert 'id="task-modal-save"' in body
|
|
@@ -1138,6 +1139,33 @@ def test_task_json_exposes_failure_reason(web_env, registry):
|
|
|
1138
1139
|
assert tasks[0]["failure_reason"] == ["timed out after 60s"]
|
|
1139
1140
|
|
|
1140
1141
|
|
|
1142
|
+
def agent_response_task_json(task_id: str = "TASK-005") -> dict:
|
|
1143
|
+
return make_task(
|
|
1144
|
+
id=task_id,
|
|
1145
|
+
title="Task with agent output",
|
|
1146
|
+
agent_response="line one\nline two",
|
|
1147
|
+
).model_dump(mode="json")
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def test_task_json_exposes_agent_response(web_env, registry):
|
|
1151
|
+
server, registry = web_env
|
|
1152
|
+
write_instance(
|
|
1153
|
+
registry,
|
|
1154
|
+
"withoutput",
|
|
1155
|
+
repo=str(registry / "repos" / "withoutput"),
|
|
1156
|
+
tasks=[agent_response_task_json("R-1")],
|
|
1157
|
+
)
|
|
1158
|
+
status, task = _get(
|
|
1159
|
+
f"http://127.0.0.1:{server.port}/api/instances/withoutput/tasks/R-1"
|
|
1160
|
+
)
|
|
1161
|
+
assert status == 200
|
|
1162
|
+
assert task["agent_response"] == "line one\nline two"
|
|
1163
|
+
|
|
1164
|
+
status, tasks = _get(f"http://127.0.0.1:{server.port}/api/instances/withoutput/tasks")
|
|
1165
|
+
assert status == 200
|
|
1166
|
+
assert tasks[0]["agent_response"] == "line one\nline two"
|
|
1167
|
+
|
|
1168
|
+
|
|
1141
1169
|
def test_reopen_blocked_task(web_env, registry):
|
|
1142
1170
|
server, registry = web_env
|
|
1143
1171
|
write_instance(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|