The loop system provides autonomous iterative development with automatic code auditing.
- Plugin boot does not mutate loop rows. Initialization loads storage and runtime services only.
- No loops are recovered, cancelled, restarted, or reconciled during plugin startup.
- Loop recovery and restart are explicit user actions via
loop-status restart=true.
- Any non-completed loop is restartable via explicit restart when the worktree is available.
- Restartable statuses:
running,cancelled,errored,stalled. - Completed loops are history-only and cannot be restarted.
- Missing worktree blocks restart — the worktree directory must exist for restart to proceed.
- Restart preserves loop identity, plan, worktree path, section progress, and review findings.
- Restart resets iteration count and error budget.
- Restart creates a fresh session and resumes from the persisted phase and section index.
- Stale workspace sweep is teardown cleanup-only, not boot-time recovery.
- Sweep removes workspace registrations for non-running restartable loops (
cancelled,errored,stalled) while preserving worktrees for manual restart. - Completed loops are fully removed (registration + worktree).
- Running loops are never touched by sweep.
stateDiagram-v2
[*] --> Coding: loop tool invoked
Coding --> Auditing: coding idle complete
Auditing --> Coding: section dirty or audit dirty
Auditing --> Auditing: next section
Auditing --> FinalAuditing: last section clean
Auditing --> [*]: audit clear
FinalAuditing --> Coding: final audit dirty (fix mode)
Coding --> FinalAuditing: final-audit fix complete
FinalAuditing --> PostAction: final audit clean
note right of PostAction: Only when loop.postAction.enabled
PostAction --> [*]: post-action complete
Coding --> [*]: max iterations / retry limit / stall timeout / cancellation
Auditing --> [*]: max iterations / retry limit / stall timeout / cancellation
FinalAuditing --> [*]: final audit clean (no post-action)
Each loop has a LoopState backed by the typed loops and loop_large_fields SQLite tables:
interface LoopState {
active: boolean // Whether loop is currently running
sessionId: string // Current OpenCode session ID
loopName: string // Unique loop identifier
worktreeDir: string // Worktree path
projectDir?: string // Project directory path
worktreeBranch?: string // Branch name if using worktree
iteration: number // Current iteration count
maxIterations: number // Maximum iterations (0 = unlimited)
startedAt: string // ISO timestamp
prompt?: string // Original task prompt
phase: 'coding' | 'auditing' | 'final_auditing' | 'post_action'
lastAuditResult?: string // Last audit output
errorCount: number // Consecutive error count
auditCount: number // Number of audits completed
terminationReason?: string // Reason for termination
completedAt?: string // ISO timestamp
worktree?: boolean // Whether using worktree isolation
modelFailed?: boolean // Whether model error occurred
sandbox?: boolean // Whether using Docker sandbox
sandboxContainer?: string // Container name if sandboxed
completionSummary?: string // Summary of loop completion
executionModel?: string // Model used for execution
auditorModel?: string // Model used for auditing
workspaceId?: string // OpenCode workspace ID
hostSessionId?: string // Host session ID for post-completion redirect
currentSectionIndex: number
totalSections: number
finalAuditDone: boolean
kind?: 'plan' | 'goal' // Discriminator: plan loops persist a plan; goal loops persist goal text
goal?: string // Goal text for goal loops (undefined for plan loops)
}Each iteration runs in a fresh session to keep context small and prioritize speed:
- Coding phase completes
- Current session is destroyed
- New session is created
- Continuation prompt is injected with:
- Original task prompt
- Current iteration number
- Audit findings (if any)
function buildContinuationPrompt(state: LoopState, auditFindings?: string): string {
let systemLine = `Loop iteration ${state.iteration}`
if (state.maxIterations > 0) {
systemLine += ` / ${state.maxIterations}`
} else {
systemLine += ` | No max iterations set - loop runs until auditor all-clear or cancelled`
}
let prompt = `[${systemLine}]\n\n${state.prompt ?? ''}`
if (auditFindings) {
prompt += `\n\n---\nThe code auditor reviewed your changes. You MUST address all bugs and convention violations.`
}
return prompt
}Loop usage is captured across rotated code and auditor sessions so loop-status can report cumulative cost and token totals after the original session has been replaced.
token-usage.tsextracts assistant message usage, normalizes token fields, and groups totals by model label.loop_session_usagepersists per-session, per-model rows keyed by project, loop name, session ID, and role.loop-status <name>merges persisted rows with the currently live session output while avoiding double-counting the active session.- When no loops are active,
loop-statuscan still show cumulative usage for completed loops that have persisted usage data.
Tracked token buckets are input, output, reasoning, cache read, and cache write, plus cost and assistant message count.
A watchdog monitors loop activity. If no progress is detected within stallTimeoutMs (default: 60 seconds), the current phase is re-triggered.
const STALL_TIMEOUT_MS = 60_000
const MAX_CONSECUTIVE_STALLS = 5After 5 consecutive stalls, the loop terminates with terminationReason: 'stall_timeout'.
Audit findings survive session rotation via the review store:
interface ReviewFinding {
projectId: string
file: string
line: number
severity: 'bug' | 'warning'
description: string
scenario: string | null
loopName: string | null
sectionIndex: number | null
createdAt: number
}At the start of each audit:
- Existing findings are retrieved via
review-read - Resolved findings are deleted via
review-delete - Unresolved findings are carried forward
Outstanding severity: 'bug' findings block loop completion — the loop terminates only when the auditor has run at least once and zero bug-severity findings remain.
Loops always run in an isolated git worktree. Sandbox is optional: when Docker is available and sandbox.mode = 'docker' is configured, a sandbox container is provisioned automatically; otherwise the loop runs in worktree-only mode.
Worktree loops require a repository with at least one commit. If OpenCode started before the initial commit, it resolves the project as global; create the commit, restart OpenCode, and retry. Forge rejects execute-plan loop mode, execute-goal, local or remote TUI loop launch, and feature-group launch/restart before creating workspaces, sessions, or group state when this precondition is not met.
Note: this applies to the
execute-plantool's defaultmode: loop. The same tool also acceptsmode: new-session, which bypasses the loop entirely and runs the plan in a fresh standalone session with no worktree or sandbox (see Tools Reference).
graph TD
A[loop tool invoked] --> B[Create worktree]
B --> C[Create new branch]
C --> D[Start coding session]
D --> E[Iterate until completion]
E --> F[Loop completes or is cancelled]
F --> G[Cleanup worktree]
G --> H[Branch preserved]
When a workspace carries a SHA pin (extra.startRef, set by remote loop launches), the new branch is created from that exact commit instead of the clone's current HEAD. If the commit is not present locally, the adapter fetches the sync ref (extra.syncRef, default refs/forge/<loopName>) from the configured git remote first, and fails with a descriptive error when the SHA still cannot be resolved. If the loop branch already exists, its tip must match the pinned SHA — a leftover same-named branch at a different commit fails creation with an actionable error instead of silently running old code (unpinned workspaces still reuse existing branches). On final teardown the sync ref is deleted from the shared git remote. See Configuration → Remotes.
Benefits of worktree isolation:
- Isolation from ongoing development
- Safe to experiment without affecting main branch
- Branch preserved for later review/merge
- Per-loop customization via
loop.worktreeOpencodeConfig— inject MCP servers and other opencode config into each worktree without host config changes or commit pollution (see Configuration Reference)
Sandbox is optional. When Docker is available and configured, a sandbox container is provisioned automatically; otherwise loops run in worktree-only mode.
- Container created with worktree mounted at
/workspace bash,glob,greptools redirect into containerread/write/editoperate on host filesystem- Container stopped and removed on loop completion
See sandbox documentation for details.
In user-facing language, a plan is decomposed into milestones — ordered units of execution. In the code and database, these are called sections:
section_plansSQL table — one row per milestone, ordered bysectionIndexcurrentSectionIndex/totalSectionscolumns on the loop row<!-- forge-section -->markers in the architect plan outputsection-readtool reads the current or specified milestone
Decomposition is a one-shot preprocessing step at loop start (services/deterministic-decomposer.ts), not a runtime loop phase. Once milestones exist, the loop advances through them via advance-section transitions inside the auditing phase. When the final_auditing phase reports outstanding bug findings, the loop rotates to a coding session in "final-audit fix" mode — the code agent fixes the reported findings without rewinding to a specific section, and on idle the loop transitions straight back to final_auditing for re-verification.
A loop completes when the active phase emits a clean audit result (optionally followed by a post-completion action phase):
- Non-sectioned loops complete on
audit-clear. - Sectioned loops advance through clean section audits, then complete on
final-audit-clean. - Dirty section audits rotate back to coding for the same section so findings can be addressed.
- Dirty final audits rotate to coding in "final-audit fix" mode (no section rewind); when the fix coding pass goes idle, the loop returns straight to
final_auditing. - After a clean final audit, if
loop.postAction.enabledistrueand specifies askillorprompt, the loop enters apost_actionphase that runs inside the worktree before teardown. Completion occurs when the post-action session goes idle (post-action-completeevent).
After a clean final audit, before worktree teardown, the loop may run a post-completion action configured via loop.postAction in forge-config.jsonc. This phase is best-effort — it is not re-audited and relies only on safe, scoped fixes. The post-action runs as the code agent in a fresh session inside the worktree.
| Field | Type | Default | Description |
|---|---|---|---|
loop.postAction.enabled |
boolean |
false |
Enable the post-completion action phase. |
loop.postAction.skill |
string |
— | Name of a skill to load via the Skill tool at action time (e.g. "pr-review"). Must be installed host-side. |
loop.postAction.prompt |
string |
— | Optional extra instruction text appended to the action prompt. Used standalone when no skill is set. |
- Runs only after a clean final audit completes.
- Runs inside the worktree as the
codeagent, with access to the full worktree state (including uncommitted changes). - Best-effort: The post-action result is not re-audited; it applies only safe, scoped fixes. The question tool is blocked — any finding requiring clarification is auto-deferred.
- On idle (
post-action-complete), the loop terminates normally. - If the post-action session fails to create, the loop terminates as completed without retrying.
- Outcome capture: On
post-action-complete, the post-action session's raw final assistant message is stored verbatim in the loop'scompletion_summary(surfaced as Completion Summary in the dashboard). The loop status is alwayscompletedregardless of what the post-action reported — the plan itself was already cleared by the final audit; the summary only provides context (alternate-review verdict, CI result, etc.). Completion summary is captured only on the cleanpost-action-completepath; idle-exhausted, error, and abort-without-assistant terminations leave it empty.
Loops can be cancelled via:
loop-canceltool/loop-cancelslash command
Cancellation:
- Marks loop as inactive
- Sets
terminationReasonto'cancelled' - Stops sandbox container if applicable
- Optionally cleans up worktree (if
cleanupWorktree: true)
| Error Type | Behavior |
|---|---|
| Model error | Automatic fallback to default model, retry |
| Error retry limit | Loop terminates with terminationReason: 'error_max_retries' |
| Audit retry limit | Loop terminates with terminationReason: 'audit_retry_exhausted' |
| Final audit retry limit | Loop terminates with terminationReason: 'final_audit_retry_exhausted' |
| Stall timeout | Loop terminates with terminationReason: 'stall_timeout' after the configured consecutive stall limit |
A goal loop (kind: 'goal') is a lightweight alternative to a plan loop for work that does not need a structured plan. It is started by the /execute-goal <prompt> slash command (or the execute-goal tool) with free-text goal text.
- No plan, no decomposition, no approval. The goal text is persisted in
loop_large_fields.goal(never in theplanstable) and is the auditor's authoritative scope. There are no sections, nofinal_auditingphase, and nopost_actionphase. - Dedicated rotating sessions. Forge creates a code session in the isolated worktree, sends the goal as its initial prompt, and leaves the invoking session unchanged as the post-completion host redirect target. As with plan loops, each completed code or audit pass is retired before the next session takes over.
- Idle-driven audits. When the executor goes idle, the loop runner starts a fresh
auditor-loopsession against the worktree (same as plan loops). The auditor verifies both goal completion and code correctness, and may block termination withseverity: "bug"findings (using the stable pseudo-pathGOALwithline: 1when no source location applies for an unmet part of the goal). - Dirty audits create a fresh code session. When findings remain, Forge creates and selects a new code session in the worktree and sends a continuation prompt containing the goal and outstanding findings. That session goes idle to trigger the next audit.
- Clean audits terminate immediately. When a completed auditor pass leaves zero outstanding review findings (any severity), the loop terminates with
completed— no final audit, no post-completion action.
A goal loop completes only after the auditor has run at least once and leaves no open review findings. The configured loop.postAction is never invoked for goal loops.
maxIterations bounds the number of coding iterations (0 = unlimited, the default unless overridden by the command or loop.defaultMaxIterations). When the cap is reached with findings still open, the loop terminates with max_iterations.
Goal loops are fully visible to loop-status, cancellable with loop-cancel, and restartable with loop-status ... restart=true. Restart preserves the goal kind and goal text, skips plan decomposition, and resumes from a fresh session.
execute-goal is added to the same loop/audit denial lists as execute-plan, launch-group, and the group tools, so an active executor or auditor session cannot recursively start another goal loop. Auditors exclude execute-goal from their tools.
| Aspect | execute-plan (loop) |
execute-goal |
launch-group |
|---|---|---|---|
| Input | Structured plan (persisted in plans) |
Free-text goal | PRD / pre-split feature list |
| Sections / milestones | Yes (decomposed) | No | Per feature (each feature is its own loop) |
| Executor session | Fresh session per iteration | Fresh dedicated session per coding pass | Fresh session per feature loop |
| Final audit | Yes (after all sections) | No | Per feature loop |
| Post-completion action | Yes (when configured) | Never | Per feature loop |
| Slash command | /execute-plan |
/execute-goal |
None (agent-invoked) |
Inside active loop sessions:
git pushis denied (permission hook)execute-planis blocked (tool hooks)execute-goalis blocked (tool hooks)questionis blocked (tool hooks)
{ "loop": { "postAction": { "enabled": false, // Enable the post-completion action phase "skill": "pr-review", // Skill to load via the Skill tool (e.g. "pr-review") "prompt": "..." // Extra instruction text; used standalone when no skill is set } } }