Backlog format¶
The backlog is a plain JSON document — a list of tasks Forgeo works through one by one.
A JSON file backlog¶
By default the backlog is a file you edit by hand, living wherever backlog:
points in forgeo.yaml — backlog.json at the project root,
or .forgeo/backlog.json when generated by forgeo init. Keep it outside the
repository if you can so the agent never touches it. It can also be
served over HTTP by another application, in which case
the document below is exactly what that endpoint exchanges with Forgeo, or it
can be sourced directly from Jira,
GitHub or GitLab.
{
"tasks": [
{
"id": "TASK-001",
"title": "Implement fibonacci module",
"description": "Write a fibonacci module with memoization and tests.",
"status": "OPEN",
"created_at": "2026-07-31T10:00:00Z"
}
]
}
Task schema¶
Each entry in tasks is a task object:
| Field | Type | Default | Meaning |
|---|---|---|---|
id |
string | — | Unique task id (e.g. TASK-001). Duplicate ids are rejected. |
title |
string | — | Short title; shown in logs, commit messages and status. |
description |
string | — | Longer description handed to the agent. Must be non-blank. |
status |
string | OPEN |
One of OPEN, BLOCKED, COMPLETED, FAILED. |
created_at |
ISO-8601 datetime | now (UTC) | When the task was created; used for oldest-first ordering. |
updated_at |
ISO-8601 datetime | now (UTC) | Bumped whenever the status changes. |
run_at |
ISO-8601 datetime / null |
null |
Optional one-shot schedule: the earliest moment this task may be picked. A past value makes the task fire immediately on the next cycle (and the daemon wakes early for it); a future value keeps the task unpicked until then. null (the default) picks the task by oldest created_at as before. Editable via PATCH. |
dependencies |
list[string] | [] |
Task ids this task depends on. Forgeo only picks the task once every dependency is COMPLETED (missing ids and ids in any other state keep it waiting). |
acceptance_criteria |
list[string] | [] |
Rendered into the FORGEO_TASK instruction under an "Acceptance criteria:" heading. |
files_to_modify |
list[string] | [] |
Informational; hints for the agent. |
agent_command |
string / list[string] | — | Override the configured agent_command for this task (e.g. route it to a different model). Validated like the global key; falls back to the config default when omitted. |
agent_timeout_seconds |
number | — | Override the configured agent_timeout_seconds for this task (must be positive). Falls back to the config default when omitted. |
blocker_reason |
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. |
blocked_count |
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. |
failure_reason |
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. |
agent_response |
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. |
retries_left |
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. |
retry_count |
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. |
failed_wait_cycles |
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. |
Only id, title, description, and status (optionally) are required;
every other field is optional.
Per-task agent routing¶
A task may override Forgeo's coding agent by setting agent_command
(and optionally agent_timeout_seconds). Forgeo then runs that command
for that task instead of the configured default; the task still arrives as
FORGEO_TASK exactly as usual. This lets you route trivial tasks to a
cheap/fast model and hard ones to a frontier model:
{
"tasks": [
{
"id": "TASK-001",
"title": "Add docstrings to the public API",
"agent_command": "claude -p \"$FORGEO_TASK\" --model claude-3-haiku",
"agent_timeout_seconds": 120
},
{
"id": "TASK-002",
"title": "Rearchitect the cache layer",
"agent_command": "claude -p \"$FORGEO_TASK\" --model claude-3-opus"
}
]
}
Statuses¶
| Status | Meaning |
|---|---|
OPEN |
To be picked by Forgeo. |
BLOCKED |
Waiting on a human decision; Forgeo pauses while any task is blocked. |
COMPLETED |
The agent finished and the work was committed (and pushed). |
FAILED |
The agent errored; changes were discarded and the reason is recorded in failure_reason. |
Retrying a failed task¶
Some failures are transient — a network blip, a flaky test, a dependency
version hiccup — and a retry would succeed without a human. When
failed_retry_max is set in forgeo.yaml, Forgeo retries
a FAILED task automatically after failed_retry_wait_cycles cycles: it is
moved back to OPEN, picked up on the next run, and its retry_count is
incremented. A task that exhausts its budget stays FAILED with its original
failure_reason preserved, exactly as before — a human reopens it manually.
BLOCKED tasks are never auto-retried.
To give a single task a different budget than the rest of the backlog, set
its retries_left field: a number caps how many times it may be retried
(0 opts it out of retries entirely), and null (or an omitted field)
falls back to the config's failed_retry_max:
{
"tasks": [
{
"id": "TASK-001",
"title": "Fork a chatty upstream test dependency",
"description": "Swap the network call for a stub.",
"retries_left": 0
},
{
"id": "TASK-002",
"title": "Migrate the caching layer",
"description": "Flaky under load; give it a few attempts.",
"retries_left": 3
}
]
}
The retry count is visible in runs.jsonl (the run record that eventually
succeeds carries it) and in the web console: a failed task's card and modal
show retried Nx, the task modal shows the retry budget and how many retries
remain, and the History tab has a retry column.
You add, remove, or reopen tasks by editing the file directly — or use the
web console: the new-task form (POST
/api/instances/<name>/tasks) assigns the next free WEB-### id for you, and
the task detail modal's Edit button updates an existing task's fields
(PATCH /api/instances/<name>/tasks/<id>), while its Delete button
removes an OPEN or BLOCKED task (DELETE /api/instances/<name>/tasks/<id>).
For file/http backlogs this is the primary editor; for jira/github/gitlab the web console is a
read-mostly mirror of the native tracker — a top banner links to the external board, each task card/modal
links to the native issue (Open in Jira/GitHub/GitLab ↗), and the board surfaces Forgeo-specific state
(blocker_reason, failure_reason, agent_response, retry budget) that the tracker does not — triage stays in
Jira/GitHub/GitLab, Forgeo reflects it (see Web console & HTTP API).
Resolving a blocked task¶
When the agent signals BLOCKED, Forgeo commits its partial work as a
[partial] commit on main and marks the task BLOCKED, recording the
agent's reason in blocker_reason. BLOCKER.md is a derived view of the
backlog's BLOCKED tasks — it is re-rendered every cycle with the real
per-task reasons and disappears automatically once the last BLOCKED task is
resolved.
To retry a BLOCKED task, reopen it: in the web console, open the task
card and press Reopen (edit the task first if you want to correct
something — editing is optional). Reopen is also available as POST
/api/instances/<name>/tasks/<id>/reopen; either way the status goes back to
OPEN, blocker_reason is cleared, and blocked_count is kept. Forgeo
picks the task up on the next scheduled run, building on the preserved
partial work. Reopening by hand is the same as setting the status back to
OPEN in this file — but that does not clear blocker_reason, so prefer
the web console's Reopen when the task was blocked by the agent.
Oldest-first ordering¶
Forgeo picks the oldest OPEN task whose dependencies are all COMPLETED,
i.e. the OPEN task with the smallest created_at that is not waiting on
anything. An optional run_at one-shot schedule overrides the order:
- a runnable
OPENtask whoserun_atis in the past is picked before every task withoutrun_at— the "run this after deploy" case. Among due tasks the one with the earliestrun_at(most overdue) fires first; - a runnable
OPENtask whoserun_atis in the future is skipped until that moment arrives, so it never displaces an already-eligible task.
Tasks in other states are ignored for picking:
BLOCKEDtasks do not get picked, but their presence pauses Forgeo.COMPLETEDandFAILEDtasks are skipped.- An
OPENtask whosedependenciesare not allCOMPLETEDis skipped: Forgeo runs its dependencies first. A dependency that ismissing(no task with that id exists) or stuck in another state (e.g.FAILED) keeps the task waiting forever, so it can never run and is not picked.
Set created_at deliberately (e.g. back-date a task) if you want to control
the order in which tasks are processed.
One-shot scheduling¶
A task with a run_at is a one-shot schedule: it runs at the earliest
moment that satisfies both it and the usual picking rules (the task is OPEN
and its dependencies are all COMPLETED). This is for time-sensitive work
that should not wait for the next scheduled pick — "run this after deploy",
"generate the weekly report":
{
"tasks": [
{
"id": "TASK-001",
"title": "Regenerate the docs site",
"description": "Rebuild docs/ from the current source.",
"run_at": "2026-08-21T09:00:00Z"
},
{
"id": "TASK-002",
"title": "Rotate the staging credentials",
"description": "Run right after the deploy finishes.",
"run_at": "2026-08-20T18:00:00Z"
}
]
}
Semantics:
- a
run_atin the past (or equal to now) fires immediately: the task is picked ahead of olderOPENtasks, and the daemon wakes for it instead of waiting out the interval; - a
run_atin the future keeps the task unpicked until then; the daemon sleeps only until that moment (when it is sooner than the interval) so the task fires atrun_atinstead of at the next scheduled pick; - set it to
null(or omit the field) to go back to plain oldest-first ordering; - a
run_atnever runs a task whose dependencies are not allCOMPLETED, and it is ignored for tasks in any state other thanOPEN.
The web console's Create form and the task modal's Edit form both have a Run at date/time input to set or clear the schedule (the task card and modal also show it when set).
Dependencies¶
dependencies is a list of task ids that must be COMPLETED before this task
runs. Forgeo enforces them when picking the next task: the oldest OPEN task
whose dependencies are all COMPLETED is chosen, so a task is never run before
the work it depends on. A task without dependencies behaves exactly as
before.
Ordering is oldest-first among runnable tasks: if the oldest OPEN task is
still waiting on an uncompleted dependency, Forgeo picks the next-oldest
OPEN task that is runnable instead. When nothing is runnable — e.g. a cycle
where A depends on B and B depends on A — Forgeo reports no next task
and runs a refactoring pass until a dependency is COMPLETED.
Unsatisfied dependencies are surfaced so a waiting task is never a silent black hole:
forgeo statusshows awaiting on:line naming the oldestOPENtask that is not yet runnable and the dependency ids keeping it waiting (with their current status, ormissing).- the web console task detail shows a Waiting on dependencies banner listing
each uncompleted dependency with its status; a dependency id that does not
exist in the backlog is shown as
missing.
To unblock a waiting task, complete (or delete and re-add, or fix) the
referenced task — or edit the task's dependencies from the web console /
the backlog file.
How a task is executed¶
Once picked, the task is handed to the agent as FORGEO_TASK, and the exit
code decides what happens to the work (commit & push, partial commit +
BLOCKER.md, or discard). See Agent contract for the
full mapping.
Corruption tolerance¶
The backlog is the single source of truth, so it is guarded on both ends:
- a missing file is treated as an empty backlog (and is created on first write);
- a corrupt file is renamed to
backlog.json.corrupt-<timestamp>and replaced by the newest valid snapshot, or by an empty store when there is none — nothing is silently discarded; - an unparsable task row is kept as a
FAILEDtask rather than killing the whole store; - before every agent run (and on daemon startup) the current backlog is copied to a rotating snapshot next to it, so a bad write is always recoverable.
Snapshots¶
Forgeo writes a snapshot of the current backlog to backlog.json.bak before
every agent run and whenever the daemon starts (a config change that reloads
the backlog is snapped on that cycle too).
Snapshots are rotated so only the last few are kept — by default 2:
backlog.json.bak (newest) and backlog.json.bak.1 (older). The newest
snapshot is always backlog.json.bak; older snapshots gain an index.
If a read ever finds the backlog corrupt (a half-written file, a hostile
agent, an accidental manual edit), the newest valid snapshot is restored
in place automatically and the corrupt file is preserved under
backlog.json.corrupt-<timestamp> as before. A corrupt snapshot is skipped in
favor of an older valid one; when no snapshot exists, the forgeo falls back to
an empty store exactly as before. A missing backlog is a no-op — no snapshot
is created for a file that does not exist.
This whole section is about a backlog file. A remote backlog is owned by the application serving it, which keeps its own history, so Forgeo neither snapshots nor repairs it — see below.
A backlog over HTTP¶
Setting backlog: to an http(s) URL moves the backlog into another
application — typically one that already displays and edits work items. Forgeo
then treats that endpoint exactly like the file:
| When | Request |
|---|---|
| Every read | GET <url> returns the whole document |
| Every write | POST <url> sends the whole document back |
backlog: https://api.example.com/api/forgeo/backlog
Add backlog_auth when the endpoint requires
a token. Everything else is unchanged: the same task schema, the same
oldest-first ordering, the same status transitions.
What the endpoint must do¶
- Return the document under
tasks, as above. A response that is not a JSON object, or whosetasksis not a list, reads as an empty backlog. - Replace, never append. The POST body is the complete task list as it should be after the change; an endpoint that appends will duplicate every task on every cycle.
- Send dates as ISO-8601 strings, not epoch numbers. Jackson (and several
other serializers) emit
java.timevalues as numeric timestamps by default; Forgeo would read those as Unix timestamps, dating every task to 1970 and inverting the oldest-first ordering. - Never send
nullfor a list field.dependencies,acceptance_criteria,files_to_modify,blocker_reasonandfailure_reasonaccept a list or nothing at all — an explicitnullmakes that row unparsable, and it comes back as aFAILEDplaceholder task. - Preserve
agent_command's shape. A string is run through a shell, a list is executed directly; turning"claude -p ..."into["claude -p ..."]makes Forgeo look for a binary with that entire name. - Store the engine-managed fields it receives (
status,updated_at,blocker_reason,blocked_count,failure_reason) and hand them back unchanged. That is how a blocked task keeps its explanation.
When the endpoint is down¶
A failed request fails the cycle: the daemon logs the error and retries on
the next interval, leaving the remote backlog untouched. It is never read as an
empty backlog — that would start a refactoring pass and let the POST at the end
of the cycle overwrite the real task list with nothing. forgeo status and
forgeo once report Backlog unavailable: ... and exit 1; the web console
answers 502 for that instance's tasks and flags it on the home page instead
of showing an empty board.
Runtime files¶
There is no backlog file for Forgeo's own runtime files to sit beside, so
backlog.lock, backlog.run, backlog.state.json, backlog.update.json and
runs.jsonl go into state_dir, which defaults to the directory holding
forgeo.yaml. No snapshots are written: the document belongs to the remote
application, so rolling it back is that application's job, not Forgeo's.
A Jira backlog¶
Set backlog_provider: jira and point backlog: at the Jira base URL:
backlog_provider: jira
backlog: https://jira.example.com
jira:
jql: 'project = APP AND labels = forgeo'
project_key: APP
auth:
scheme: basic
username_env: JIRA_USER
token_env: JIRA_TOKEN
workflow:
open_statuses: ["10000", "10001"]
open_status: "10000"
running_status: "3"
completed_status: "10002"
The JQL is the provider's scope. It should include open, running, blocked and completed issues so Forgeo can see dependencies and render the dashboard correctly. Jira status references can be names or ids; ids are more stable.
Mapping and lifecycle¶
- Jira issue keys are Forgeo task ids.
summary,description,createdandupdatedmap to the corresponding task fields.open_statusesidentifies issues eligible for picking.running_statusis applied before the agent starts, preventing a second worker from claiming the same issue.completed_statusis applied after a successful commit.blocked_statusis optional; theforgeo-blockedlabel is always applied when the agent needs human input.failed_statusis optional; without it, theforgeo-failedlabel represents a failed task while the issue returns to the configured open status.
Forgeo stores blocker reasons, failure reasons, retry counters, claim time and
bounded agent output in the Jira issue property named forgeo by default.
Set jira.property_key to change it. Optional Jira custom fields can carry
acceptance_criteria, dependencies, files_to_modify, per-task agent
settings, run_at, and retries_left. If no custom run_at field is
configured, Jira's native duedate is used at midnight UTC:
jira:
fields:
acceptance_criteria: customfield_10042
dependencies: customfield_10043
Dependencies may also be inferred from Jira issue links whose link type is
blocks. The issue that is blocked is treated as depending on the issue that
blocks it.
Authentication¶
Jira credentials are never stored directly in forgeo.yaml:
basicusesusernameorusername_envplus an API token named bytoken_env.beareruses a personal-access token named bytoken_env.
The client uses Jira REST API v3 by default, including Jira Cloud's
/search/jql endpoint and cursor pagination via nextPageToken. Set
jira.api_version: 2 for Jira installations that expose the older offset-based
search endpoint and v2 comment/description format.
Runtime behavior¶
The daemon reads Jira with paginated JQL searches. A task is claimed by
rechecking it and transitioning it to running_status before the agent runs.
If a process dies while holding a claim, a later cycle releases claims older
than claim_timeout_seconds and returns them to the configured open status.
An unavailable Jira endpoint fails the cycle; it is never treated as an empty
backlog.
A GitHub backlog¶
Set backlog_provider: github and point backlog: at the GitHub API base URL:
backlog_provider: github
backlog: https://api.github.com
github:
repo: owner/repo
token_env: GITHUB_TOKEN
label_prefix: forgeo
Use https://api.github.com for github.com or https://github.example.com/api/v3 for Enterprise.
Mapping and lifecycle¶
- GitHub issue numbers are Forgeo task ids.
title,body(visible part),created_atandupdated_atmap to task fields.stateopenmaps toOPEN;closedmaps toCOMPLETED.- Labels
forgeo-running,forgeo-blocked,forgeo-failed(prefix configurable vialabel_prefix) represent running, blocked, and failed tasks. An open issue carryingforgeo-runningis considered claimed and filtered from picking. - Closing an issue completes its task; reopening it moves the task back to
OPEN.
Forgeo stores blocker reasons, failure reasons, retry counters, claim time, dependencies, and bounded agent output in a hidden JSON block inside the issue body: <!-- forgeo: {...} -->. The visible body remains human-readable; the hidden block is stripped on read and merged on write. No GitHub issue property or custom field is required. Set github.property_key only for symmetry; the marker key is forgeo by default.
Dependencies are persisted via the hidden block's dependencies list; no GitHub issue links are required.
Authentication¶
GitHub credentials are never stored in forgeo.yaml:
token_envnames the environment variable holding a personal-access token (classic or fine-grained). The token is sent asAuthorization: Bearer <token>.
Runtime behavior¶
The daemon lists GitHub issues with paginated GET /repos/{owner}/{repo}/issues?state=all. A task is claimed by adding the forgeo-running label and persisting claimed_at in the hidden block. If a process dies while holding a claim, a later cycle releases claims older than claim_timeout_seconds and removes the running label. An unavailable GitHub endpoint fails the cycle.
A GitLab backlog¶
Set backlog_provider: gitlab and point backlog: at the GitLab base URL:
backlog_provider: gitlab
backlog: https://gitlab.example.com
gitlab:
repo: group/project # or numeric project id
token_env: GITLAB_TOKEN
label_prefix: forgeo
GitLab base URL is the instance root (e.g. https://gitlab.com); the client appends /api/v4.
Mapping and lifecycle¶
- GitLab issue
iids are Forgeo task ids. title,description(visible part),created_atandupdated_atmap to task fields.stateopenedmaps toOPEN;closedmaps toCOMPLETED.- Labels
forgeo-running,forgeo-blocked,forgeo-failedrepresent running, blocked, and failed tasks, like GitHub. Anopenedissue withforgeo-runningis filtered as claimed. - Closing/reopening via
state_eventtransitions the task toCOMPLETED/OPEN.
Forgeo stores engine state the same way as GitHub: a hidden <!-- forgeo: {...} --> block inside description. Dependencies and other task attributes are kept there; no GitLab custom fields are required.
Authentication¶
token_envnames the environment variable holding a personal-access token. Sent asPRIVATE-TOKENandAuthorization: Bearer.
Runtime behavior¶
Paginated GET /api/v4/projects/:id/issues?state=all. Claiming adds forgeo-running and claimed_at; stale claims older than claim_timeout_seconds are released. An unavailable GitLab endpoint fails the cycle.