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.
Files changed (79) hide show
  1. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/.gitignore +2 -1
  2. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/CHANGELOG.md +14 -1
  3. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/PKG-INFO +1 -1
  4. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/backlog.md +1 -0
  5. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/configuration.md +13 -0
  6. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/web-console-api.md +9 -4
  7. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/install.sh +1 -1
  8. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/pyproject.toml +1 -1
  9. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/__init__.py +1 -1
  10. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/backlog.py +53 -8
  11. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/backlog_http.py +4 -2
  12. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/forgeo.py +12 -8
  13. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/models.py +24 -2
  14. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.js +6 -0
  15. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/instance.html +10 -0
  16. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/conftest.py +13 -0
  17. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_backlog.py +115 -22
  18. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_backlog_http.py +2 -2
  19. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_factory.py +2 -2
  20. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_runs.py +7 -7
  21. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_web.py +28 -0
  22. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/.github/workflows/ci.yml +0 -0
  23. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/CONTRIBUTING.md +0 -0
  24. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/LICENSE +0 -0
  25. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/README.md +0 -0
  26. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/config/nginx-forgeo.conf +0 -0
  27. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/agent-contract.md +0 -0
  28. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/cli-reference.md +0 -0
  29. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/getting-started.md +0 -0
  30. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/console.png +0 -0
  31. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/demo.gif +0 -0
  32. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/logo.png +0 -0
  33. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/og.png +0 -0
  34. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/img/title.svg +0 -0
  35. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/docs/index.md +0 -0
  36. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/forgeo.spec +0 -0
  37. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/mkdocs.yml +0 -0
  38. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/scripts/__init__.py +0 -0
  39. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/scripts/render_homebrew_formula.py +0 -0
  40. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/__main__.py +0 -0
  41. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/agent.py +0 -0
  42. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/central.py +0 -0
  43. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/cli.py +0 -0
  44. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/config.py +0 -0
  45. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/daemon.py +0 -0
  46. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/daemon_control.py +0 -0
  47. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/git.py +0 -0
  48. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/instances.py +0 -0
  49. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/io.py +0 -0
  50. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/notify.py +0 -0
  51. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/oauth.py +0 -0
  52. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/paths.py +0 -0
  53. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/runs.py +0 -0
  54. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/setup.py +0 -0
  55. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/update.py +0 -0
  56. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/validate.py +0 -0
  57. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/central.css +0 -0
  58. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/index.html +0 -0
  59. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/central/login.html +0 -0
  60. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web/style.css +0 -0
  61. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/src/forgeo/web_common.py +0 -0
  62. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_agent.py +0 -0
  63. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_cli.py +0 -0
  64. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_daemon.py +0 -0
  65. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_git.py +0 -0
  66. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_install.py +0 -0
  67. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_instances.py +0 -0
  68. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_io.py +0 -0
  69. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_models.py +0 -0
  70. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_oauth.py +0 -0
  71. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_paths.py +0 -0
  72. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_remote_backlog_cycle.py +0 -0
  73. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_render_homebrew.py +0 -0
  74. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_setup.py +0 -0
  75. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_update.py +0 -0
  76. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_web_common.py +0 -0
  77. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/tests/test_web_lock.py +0 -0
  78. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/www/404.html +0 -0
  79. {forgeo_cli-0.7.2 → forgeo_cli-0.7.3}/www/index.html +0 -0
@@ -29,6 +29,7 @@ dist/
29
29
  # AI assistant context
30
30
  AGENTS.md
31
31
  CONTEXT.md
32
+ CLAUDE.md
32
33
 
33
34
  # Local deploy script (server-specific paths)
34
- scripts/deploy-docs.sh
35
+ scripts/deploy-docs.sh
@@ -7,6 +7,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.2...HEAD
352
+ [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.7.3...HEAD
353
+ [0.7.3]: https://github.com/lucaGazzola/forgeo/compare/v0.7.2...v0.7.3
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.2
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 always preserved
333
- (they are engine-managed — `PATCH` rejects them like it rejects `status`).
335
+ `agent_response`, `retry_count`, `failed_wait_cycles`, and `created_at` are
336
+ always preserved (they are engine-managed — `PATCH` rejects them like it
337
+ rejects `status`).
334
338
  `agent_command` may be a string, an array, or `null` (clear the per-task
335
339
  override); `agent_timeout_seconds` may be a positive number or `null`.
336
340
  `updated_at` is bumped to the current time.
@@ -350,6 +354,7 @@ curl -X PATCH http://127.0.0.1:8790/api/instances/my-repo/tasks/TASK-001 \
350
354
  "blocker_reason": [],
351
355
  "blocked_count": 0,
352
356
  "failure_reason": [],
357
+ "agent_response": null,
353
358
  "created_at": "2026-07-31T10:00:00Z",
354
359
  "updated_at": "2026-08-01T12:00:00Z",
355
360
  "dependencies": [],
@@ -364,8 +369,8 @@ Errors:
364
369
 
365
370
  - `400` with `{"error": "..."}` — unparseable or non-object body, an empty
366
371
  body, an unknown field (e.g. `status`, `blocker_reason`, `blocked_count`,
367
- `failure_reason`), or an invalid value (blank `title`, wrong field types, a
368
- non-positive `agent_timeout_seconds`).
372
+ `failure_reason`, `agent_response`), or an invalid value (blank `title`,
373
+ wrong field types, a non-positive `agent_timeout_seconds`).
369
374
  - `404` with `{"error": "not found"}` — the task id does not exist in that
370
375
  instance's backlog.
371
376
  - `404` with `{"error": "unknown instance"}` — the instance is not
@@ -14,7 +14,7 @@ set -eu
14
14
 
15
15
  REPO_OWNER="lucaGazzola"
16
16
  REPO_NAME="forgeo"
17
- DEFAULT_VERSION="0.7.2"
17
+ DEFAULT_VERSION="0.7.3"
18
18
  MIN_PYTHON="3.11"
19
19
  PYPI_PACKAGE="forgeo-cli"
20
20
  PREFIX="${FORGEO_PREFIX:-${HOME:-}/.local}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forgeo-cli"
7
- version = "0.7.2"
7
+ version = "0.7.3"
8
8
  description = "A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,4 +7,4 @@ try:
7
7
 
8
8
  __version__ = _pkg_version("forgeo-cli")
9
9
  except PackageNotFoundError: # standalone binary: no installed package metadata
10
- __version__ = "0.7.2"
10
+ __version__ = "0.7.3"
@@ -36,7 +36,7 @@ from typing import Any
36
36
  from pydantic import ValidationError
37
37
 
38
38
  from forgeo.io import atomic_write_text
39
- from forgeo.models import ForgeoConfig, Task, TaskStatus
39
+ from forgeo.models import ExecutionResult, ForgeoConfig, Task, TaskStatus
40
40
 
41
41
  logger = logging.getLogger(__name__)
42
42
 
@@ -59,6 +59,32 @@ EDITABLE_TASK_FIELDS = frozenset(
59
59
  )
60
60
 
61
61
 
62
+ def _join_output_logs(result: ExecutionResult, cap: int | None = None) -> str | None:
63
+ """The agent's output as one newline-joined string, ``None`` when empty.
64
+
65
+ ``BacklogStore`` persists agent output as a single string field, so the
66
+ agent's ``list[str]`` (its ``[stdout]``/``[stderr]``-prefixed lines) is
67
+ flattened here, prefixes stripped. An empty result means "nothing to
68
+ record" and must stay ``None`` — the caller then leaves any previously
69
+ stored response untouched rather than wiping it. ``cap`` bounds the number
70
+ of kept lines, mirroring ``agent_response_lines`` (``None`` = unbounded;
71
+ ``0``/negative = persist nothing).
72
+ """
73
+
74
+ prefixes = ("[stdout]", "[stderr]")
75
+ out = [
76
+ line[len(prefix) + 1 :]
77
+ for line in result.output_logs
78
+ for prefix in prefixes
79
+ if line.startswith(prefix)
80
+ ]
81
+ if cap is not None:
82
+ if cap <= 0:
83
+ return None
84
+ out = out[-cap:]
85
+ return "\n".join(out) if out else None
86
+
87
+
62
88
  def unsatisfied_dependencies(tasks: list[Task], task: Task) -> list[dict[str, str]]:
63
89
  """The dependencies of ``task`` that are not yet satisfied.
64
90
 
@@ -208,8 +234,9 @@ class BacklogStore(ABC):
208
234
  concurrent mutations within one process — lives here.
209
235
  """
210
236
 
211
- def __init__(self) -> None:
237
+ def __init__(self, *, output_cap: int | None = None) -> None:
212
238
  self._lock = asyncio.Lock()
239
+ self._output_cap = output_cap
213
240
 
214
241
  @abstractmethod
215
242
  async def _read(self) -> dict[str, Any]:
@@ -253,7 +280,9 @@ class BacklogStore(ABC):
253
280
  await self._write(store)
254
281
  return task
255
282
 
256
- async def update_status(self, task_id: str, status: TaskStatus) -> Task | None:
283
+ async def update_status(
284
+ self, task_id: str, status: TaskStatus, result: ExecutionResult
285
+ ) -> Task | None:
257
286
  """Transition a task's status, bumping its ``updated_at`` timestamp.
258
287
 
259
288
  Any transition away from ``FAILED`` clears the persisted
@@ -270,6 +299,9 @@ class BacklogStore(ABC):
270
299
  and status is not TaskStatus.FAILED
271
300
  )
272
301
  entry["status"] = status.value
302
+ joined = _join_output_logs(result, self._output_cap)
303
+ if joined is not None:
304
+ entry["agent_response"] = joined
273
305
  if status is not TaskStatus.FAILED:
274
306
  entry["failure_reason"] = []
275
307
  if leaving_failed:
@@ -278,7 +310,9 @@ class BacklogStore(ABC):
278
310
 
279
311
  return await self._update_entry(task_id, mutate)
280
312
 
281
- async def set_blocked(self, task_id: str, reason: list[str]) -> Task | None:
313
+ async def set_blocked(
314
+ self, task_id: str, reason: list[str], result: ExecutionResult
315
+ ) -> Task | None:
282
316
  """Mark a task ``BLOCKED``, persisting the agent's blocker reason.
283
317
 
284
318
  ``reason`` is stored on the task as ``blocker_reason`` (the source
@@ -291,10 +325,15 @@ class BacklogStore(ABC):
291
325
  entry["blocker_reason"] = list(reason)
292
326
  entry["blocked_count"] = int(entry.get("blocked_count", 0)) + 1
293
327
  entry["failure_reason"] = []
328
+ joined = _join_output_logs(result, self._output_cap)
329
+ if joined is not None:
330
+ entry["agent_response"] = joined
294
331
 
295
332
  return await self._update_entry(task_id, mutate)
296
333
 
297
- async def set_failed(self, task_id: str, reason: list[str]) -> Task | None:
334
+ async def set_failed(
335
+ self, task_id: str, reason: list[str], result: ExecutionResult
336
+ ) -> Task | None:
298
337
  """Mark a task ``FAILED``, persisting the failure reason.
299
338
 
300
339
  ``reason`` is stored on the task as ``failure_reason`` (shown in the
@@ -307,6 +346,9 @@ class BacklogStore(ABC):
307
346
  entry["status"] = TaskStatus.FAILED.value
308
347
  entry["failure_reason"] = list(reason)
309
348
  entry["failed_wait_cycles"] = 0
349
+ joined = _join_output_logs(result, self._output_cap)
350
+ if joined is not None:
351
+ entry["agent_response"] = joined
310
352
 
311
353
  return await self._update_entry(task_id, mutate)
312
354
 
@@ -490,8 +532,9 @@ class JSONBacklog(BacklogStore):
490
532
  path: str | Path,
491
533
  *,
492
534
  snapshot_count: int = DEFAULT_SNAPSHOT_COUNT,
535
+ output_cap: int | None = None,
493
536
  ) -> None:
494
- super().__init__()
537
+ super().__init__(output_cap=output_cap)
495
538
  self.path = Path(path)
496
539
  self.snapshot_count = max(0, snapshot_count)
497
540
 
@@ -622,5 +665,7 @@ def open_backlog(config: ForgeoConfig) -> BacklogStore:
622
665
  # it at module level would close an import cycle.
623
666
  from forgeo.backlog_http import HttpBacklog
624
667
 
625
- return HttpBacklog(str(config.backlog), auth=config.backlog_auth)
626
- return JSONBacklog(Path(config.backlog))
668
+ return HttpBacklog(
669
+ str(config.backlog), auth=config.backlog_auth, output_cap=config.agent_response_lines
670
+ )
671
+ return JSONBacklog(Path(config.backlog), output_cap=config.agent_response_lines)
@@ -55,8 +55,9 @@ class HttpBacklog(BacklogStore):
55
55
  *,
56
56
  auth: BacklogAuth | None = None,
57
57
  timeout_seconds: float = REQUEST_TIMEOUT_SECONDS,
58
+ output_cap: int | None = None,
58
59
  ) -> None:
59
- super().__init__()
60
+ super().__init__(output_cap=output_cap)
60
61
  self.url = url
61
62
  self.timeout_seconds = timeout_seconds
62
63
  self._tokens = (
@@ -69,7 +70,7 @@ class HttpBacklog(BacklogStore):
69
70
  async def _read(self) -> dict[str, Any]:
70
71
  """GET the document; anything but a usable response raises."""
71
72
  body = await asyncio.to_thread(self._request, "GET", None)
72
- logger.info("GET %s returned body: %s", self.url, body)
73
+ logger.debug("GET %s returned body: %s", self.url, body)
73
74
  try:
74
75
  data = json.loads(body) if body.strip() else {}
75
76
  except json.JSONDecodeError as exc:
@@ -81,6 +82,7 @@ class HttpBacklog(BacklogStore):
81
82
  async def _write(self, store: dict[str, Any]) -> None:
82
83
  """POST the whole document back."""
83
84
  payload = json.dumps(store, indent=2, ensure_ascii=False).encode("utf-8")
85
+ logger.debug("POST %s %s", self.url, payload)
84
86
  await asyncio.to_thread(self._request, "POST", payload)
85
87
 
86
88
  # ------------------------------------------------------------------ #
@@ -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(task, [NO_CHANGES_DIRTY_REASON])
634
+ await self._mark_failed(
635
+ task,
636
+ [NO_CHANGES_DIRTY_REASON],
637
+ self._last_agent_result or ExecutionResult(status=ExecutionStatus.ERROR),
638
+ )
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``. None
169
- of them are editable through the web console's ``PATCH`` endpoint.
168
+ engine-managed: the agent's error when the task becomes ``FAILED``.
169
+ ``agent_response`` is likewise engine-managed: the last agent
170
+ stdout/stderr output persisted on a status transition. None of them are
171
+ editable through the web console's ``PATCH`` endpoint.
170
172
  ``retries_left`` is a human-set per-task override of the retry budget
171
173
  (``failed_retry_max`` in the config); ``retry_count`` and
172
174
  ``failed_wait_cycles`` are engine-managed retry state.
@@ -194,6 +196,14 @@ class Task(BaseModel):
194
196
  blocker_reason: list[str] = Field(default_factory=list)
195
197
  blocked_count: int = Field(default=0, ge=0)
196
198
  failure_reason: list[str] = Field(default_factory=list)
199
+ agent_response: str | None = Field(
200
+ default=None,
201
+ description="Engine-managed: the agent's last stdout/stderr output, "
202
+ "stripped of its stream prefixes and persisted on status transitions "
203
+ "(capped at ``agent_response_lines`` lines when set). ``None`` when "
204
+ "the transition carried no output. Intended for the backlog consumer; "
205
+ "not editable via ``PATCH``.",
206
+ )
197
207
  retries_left: int | None = Field(
198
208
  default=None,
199
209
  ge=0,
@@ -409,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