Skip to content

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.yamlbacklog.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 OPEN task whose run_at is in the past is picked before every task without run_at — the "run this after deploy" case. Among due tasks the one with the earliest run_at (most overdue) fires first;
  • a runnable OPEN task whose run_at is 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:

  • BLOCKED tasks do not get picked, but their presence pauses Forgeo.
  • COMPLETED and FAILED tasks are skipped.
  • An OPEN task whose dependencies are not all COMPLETED is skipped: Forgeo runs its dependencies first. A dependency that is missing (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_at in the past (or equal to now) fires immediately: the task is picked ahead of older OPEN tasks, and the daemon wakes for it instead of waiting out the interval;
  • a run_at in 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 at run_at instead of at the next scheduled pick;
  • set it to null (or omit the field) to go back to plain oldest-first ordering;
  • a run_at never runs a task whose dependencies are not all COMPLETED, and it is ignored for tasks in any state other than OPEN.

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 status shows a waiting on: line naming the oldest OPEN task that is not yet runnable and the dependency ids keeping it waiting (with their current status, or missing).
  • 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 FAILED task 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 whose tasks is 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.time values 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 null for a list field. dependencies, acceptance_criteria, files_to_modify, blocker_reason and failure_reason accept a list or nothing at all — an explicit null makes that row unparsable, and it comes back as a FAILED placeholder 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, created and updated map to the corresponding task fields.
  • open_statuses identifies issues eligible for picking.
  • running_status is applied before the agent starts, preventing a second worker from claiming the same issue.
  • completed_status is applied after a successful commit.
  • blocked_status is optional; the forgeo-blocked label is always applied when the agent needs human input.
  • failed_status is optional; without it, the forgeo-failed label 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:

  • basic uses username or username_env plus an API token named by token_env.
  • bearer uses a personal-access token named by token_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_at and updated_at map to task fields.
  • state open maps to OPEN; closed maps to COMPLETED.
  • Labels forgeo-running, forgeo-blocked, forgeo-failed (prefix configurable via label_prefix) represent running, blocked, and failed tasks. An open issue carrying forgeo-running is 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_env names the environment variable holding a personal-access token (classic or fine-grained). The token is sent as Authorization: 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_at and updated_at map to task fields.
  • state opened maps to OPEN; closed maps to COMPLETED.
  • Labels forgeo-running, forgeo-blocked, forgeo-failed represent running, blocked, and failed tasks, like GitHub. An opened issue with forgeo-running is filtered as claimed.
  • Closing/reopening via state_event transitions the task to COMPLETED/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_env names the environment variable holding a personal-access token. Sent as PRIVATE-TOKEN and Authorization: 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.