Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Load plans dialog showing archived plans:
- **Plans** — architect produces marked plans that are auto-captured to SQL storage
- **Execution** — `New session`, `Execute here`, and `Loop` launch paths for approved plans
- **Loops** — iterative coding/auditing with isolated git worktree and optional Docker sandbox
- **Review Findings** — persistent, branch-aware review findings across loop sessions
- **Review Findings** — persistent, loop-scoped review findings across loop sessions
- **TUI** — sidebar, plan viewer/editor, execution dialog, and load-plan UI
- **Sandbox** — Optional Docker worktree loop isolation with bind-mounted project files

Expand Down Expand Up @@ -109,7 +109,7 @@ Review finding storage for persisting audit results across session rotations.

| Tool | Description |
|------|-------------|
| `review-write` | Store a review finding with file, line, severity, and description. Auto-injects branch field. |
| `review-write` | Store a review finding with file, line, severity, and description. Findings are scoped to the current loop. |
| `review-read` | Retrieve review findings. Filter by file path or search by regex pattern. |
| `review-delete` | Delete a review finding by file and line. |

Expand All @@ -121,7 +121,7 @@ Iterative development loops with automatic auditing. Loops always run in an isol
|------|-------------|
| `loop` | Execute a plan using an iterative development loop in an isolated git worktree. Args: `title` required; `plan`, `loopName`, and `hostSessionId` optional. |
| `loop-cancel` | Cancel an active loop by worktree name |
| `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart` to resume inactive loops. |
| `loop-status` | List active/recent loops or get detailed status by worktree name, including cumulative token usage when available. Supports `restart=true` to restart any non-completed loop (`running`, `cancelled`, `errored`, `stalled`). Completed loops are history-only and cannot be restarted. |

`loop` reads the current session's captured plan when `plan` is omitted. `maxIterations`, execution model, auditor model, and sandbox behavior come from configuration or the TUI execution dialog, not direct `loop` tool arguments.

Expand Down Expand Up @@ -488,7 +488,7 @@ Loops always run in an isolated git worktree. Sandbox is optional: when Docker i

### Auditor Integration

After each coding iteration, the auditor agent reviews changes against project conventions and stored review findings. Findings are persisted via `review-write` scoped to the loop's branch. Outstanding `severity: 'bug'` findings block completion — the loop terminates only when the auditor has run at least once and zero bug-severity findings remain.
After each coding iteration, the auditor agent reviews changes against project conventions and stored review findings. Findings are persisted via `review-write` scoped to the current loop. Outstanding `severity: 'bug'` findings block completion — the loop terminates only when the auditor has run at least once and zero bug-severity findings remain.

### Stall Detection

Expand Down
144 changes: 113 additions & 31 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,10 @@ The plugin follows this initialization sequence within `createForgePlugin()`:
7. **Database** - Initialize SQLite storage (`initializeDatabase()`)
8. **Repositories** - Create typed repos (loops, plans, reviewFindings, sectionPlans)
9. **Loop Event Handler** - Connect loop runtime to events and state management
10. **Stale Loop Reconciliation** - Reconcile loops from previous session (cancel stalled, preserve active sandboxes, restart candidates)
11. **Tools and Agents** - Register all tools (`createTools()`) and agents (`buildAgents()`)
12. **Hooks** - Final registration of all hook points
10. **Tools and Agents** - Register all tools (`createTools()`) and agents (`buildAgents()`)
11. **Hooks** - Final registration of all hook points

**Note:** Plugin initialization does not recover, cancel, or restart loops. Boot initializes storage and runtime services only. Loop recovery and restart are explicit user actions via `loop-status restart=true`.

## Cleanup

Expand Down
28 changes: 28 additions & 0 deletions docs/loop-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

The loop system provides autonomous iterative development with automatic code auditing.

## Loop Lifecycle Rules

### Plugin Boot Behavior

- **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`.

### Restartability

- **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 Semantics

- 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

- 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.

## Loop Lifecycle

```mermaid
Expand Down
2 changes: 1 addition & 1 deletion docs/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ The heart of Forge. Implements autonomous iterative development with phases: `co
| `index.ts` | Public API barrel (all re-exports) |
| `runtime.ts` | `createLoop()` factory, `Loop` interface (~2100 lines) |
| `service.ts` | DB-backed `LoopService` (`createLoopService`) |
| `state.ts` | Discriminated union `LoopState` (4 phases), converters |
| `state.ts` | Discriminated union `LoopState` (3 phases: `coding`, `auditing`, `final_auditing`), converters |
| `transitions.ts` | Pure `nextTransition()` table |
| `termination.ts` | `TerminationReason` union, `terminationStatusFor()` |
| `prompts.ts` | Prompt builders for each loop phase |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-forge",
"version": "0.4.4",
"version": "0.4.5",
"type": "module",
"oc-plugin": [
"server",
Expand Down
10 changes: 9 additions & 1 deletion src/agents/architect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,15 @@ The plugin auto-captures marked plans from your assistant responses into SQL sto
Present plans with:
- **Objective**: What we're building and why
- **Loop Name**: A short, machine-friendly name (1-3 words) that captures the plan's main intent. This will be used for worktree/session naming. Example: "Loop Name: auth-refactor" or "Loop Name: api-validation"
- **Phases**: Ordered implementation steps. Every executable section MUST be preceded by a \`<!-- forge-section -->\` marker on its own line. For every phase, specify the exact files affected, the precise code-level edits to make, sample change examples (such as function signature updates, new branches, or new exports), the existing symbols/modules being integrated with, concrete acceptance criteria, and phase-specific verification. Use \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, and \`### Verification\` inside each phase. Place a \`<!-- forge-section -->\` marker on its own line immediately before each section's heading. Shared blocks (\`## Decisions\`, \`## Conventions\`, \`## Key Context\`) go after all sections without a preceding marker.
- **Phases**: Ordered implementation steps. Use exactly one \`<!-- forge-section -->\` marker per executable phase. Place it immediately before that phase's \`## Phase ...\` heading. Never place it before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\` — those are subsections inside the current phase. For every phase, specify the exact files affected, the precise code-level edits to make, sample change examples (such as function signature updates, new branches, or new exports), the existing symbols/modules being integrated with, concrete acceptance criteria, and phase-specific verification. Use \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, and \`### Verification\` as subsections inside each phase. Shared blocks (\`## Decisions\`, \`## Conventions\`, \`## Key Context\`) go after all sections without a preceding marker.

**Valid shape:**
\`<!-- forge-section -->\`
\`## Phase 1: ...\`
\`### Files\`
\`### Edits\`
\`### Acceptance Criteria\`
\`### Verification\`
- **Verification**: Concrete criteria the code agent can validate automatically inside the loop. Every plan MUST include verification. Plans without verification are incomplete.

Plans must be **detailed, self-contained, and implementation-ready**. The code agent should be able to execute the plan without inferring missing scope, files, APIs, data shapes, or verification steps. Every phase must be specific enough that another engineer could make the described edits directly from the plan. Each plan must include:
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/forge-session-attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,10 @@ async function attachForgeSession(
)
return
} else if (action.action === 'remove-registration-only') {
// Restartable-terminal (cancelled/errored/stalled): remove registration, preserve worktree for manual restart
// Restartable (cancelled/errored/stalled): remove registration, preserve worktree for manual restart
await removeForgeWorkspaceWithContext(
{ v2: deps.v2, pendingTeardowns: deps.execDeps.pendingTeardowns, logger: deps.logger },
{ workspaceId, loopName, action: 'remove-registration-only', reasonLabel: 'attach-safety-net-restartable-terminal' },
{ workspaceId, loopName, action: 'remove-registration-only', reasonLabel: 'attach-safety-net-restartable' },
)
publishAttachFailureToast(
deps,
Expand Down
96 changes: 5 additions & 91 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { resolveLogPath } from './storage'
import { createLogger } from './utils/logger'
import { createDockerService } from './sandbox/docker'
import { createSandboxManager } from './sandbox/manager'
import { reconcileSandboxes } from './sandbox/reconcile'
import type { PluginConfig, CompactionConfig } from './types'
import { createTools } from './tools'
import { createToolExecuteBeforeHook, createToolExecuteAfterHook, createPlanApprovalEventHook } from './hooks'
Expand Down Expand Up @@ -272,79 +271,6 @@ export function createForgePlugin(config: PluginConfig): Plugin {

const loopHandler = createLoopEventHandler(loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, v2, logger, () => config, sandboxManager || undefined, dataDir, config.loop, sectionPlansRepo, notifyLoopChange, pendingTeardowns, loopSessionUsageRepo)

const reconcileResult = await loopHandler.loop.reconcileStale(
sandboxManager
? { isSandboxLive: (name: string) => sandboxManager!.isLiveByName(name) }
: undefined
)
if (reconcileResult.cancelled > 0) {
logger.log(`Reconciled ${reconcileResult.cancelled} stale loop(s) from previous session`)
}
if (reconcileResult.preserved.length > 0) {
logger.log(`Preserved ${reconcileResult.preserved.length} active sandbox loop(s) across restart: ${reconcileResult.preserved.join(', ')}`)
}

if (reconcileResult.restartCandidates.length > 0) {
const { existsSync } = await import('node:fs')
const { createForgeExecutionService } = await import('./services/execution')
const restartService = createForgeExecutionService({
projectId,
directory,
config,
logger,
dataDir,
v2,
legacyClient: client,
plansRepo,
loopsRepo,
loopHandler,
loop: loopHandler.loop,
sandboxManager,
sectionPlansRepo,
reviewFindingsRepo,
workspaceStatusRegistry,
pendingTeardowns,
})

let restored = 0
let restoreFailed = 0
for (const candidate of reconcileResult.restartCandidates) {
if (!candidate.worktreeDir || !existsSync(candidate.worktreeDir)) {
logger.log(`Loop: cannot auto-restart ${candidate.loopName}, worktree missing at ${candidate.worktreeDir}`)
loopHandler.loop.reconcileFinalize(candidate.loopName, 'cancel')
restoreFailed++
continue
}
try {
const result = await restartService.dispatch(
{ surface: 'tool', projectId, directory: candidate.projectDir ?? candidate.worktreeDir },
{ type: 'loop.restart', selector: { kind: 'partial', name: candidate.loopName }, force: true },
)
if (result.ok) {
loopHandler.loop.reconcileFinalize(candidate.loopName, 'restored')
restored++
} else {
logger.error(`Loop: auto-restart failed for ${candidate.loopName}: ${result.error.message}`)
loopHandler.loop.reconcileFinalize(candidate.loopName, 'cancel')
restoreFailed++
}
} catch (err) {
logger.error(`Loop: auto-restart threw for ${candidate.loopName}`, err)
loopHandler.loop.reconcileFinalize(candidate.loopName, 'cancel')
restoreFailed++
}
}
if (restored > 0) {
logger.log(`Auto-restored ${restored} loop(s) across plugin restart`)
}
if (restoreFailed > 0) {
logger.log(`Auto-restart unavailable for ${restoreFailed} loop(s); cancelled`)
}
}

// Sandbox reconciliation interval handle
let sandboxReconcileInterval: ReturnType<typeof setInterval> | null = null

const agents = buildAgents()

const compactionConfig: CompactionConfig | undefined = config.compaction
Expand All @@ -365,12 +291,6 @@ export function createForgePlugin(config: PluginConfig): Plugin {
process.removeListener('SIGINT', handleSigint)
process.removeListener('SIGTERM', handleSigterm)

// Clear sandbox reconciliation interval
if (sandboxReconcileInterval) {
clearInterval(sandboxReconcileInterval)
sandboxReconcileInterval = null
}

logger.log('Loop: active loops preserved during plugin cleanup')

loopHandler.clearAllRetryTimeouts()
Expand Down Expand Up @@ -413,16 +333,9 @@ export function createForgePlugin(config: PluginConfig): Plugin {
pendingTeardowns,
}

if (sandboxManager) {
const reconcileDeps = { sandboxManager, loop: loopHandler.loop, logger }
await reconcileSandboxes(reconcileDeps)

sandboxReconcileInterval = setInterval(() => {
reconcileSandboxes(reconcileDeps).catch((err) => {
logger.error('Sandbox reconciliation failed', err)
})
}, 2000)
}
// Sandbox reconciliation interval removed per Phase 2 requirements.
// Sandbox reconciliation now only occurs for loops started/restarted
// in the current plugin process, triggered by explicit runtime events.

// Create forge-session-attach hook for triggering attachLoopToSession on session.created events
const forgeAttachExecDeps = {
Expand Down Expand Up @@ -578,7 +491,8 @@ READ-ONLY mode: no file edits, no destructive commands. Search and analyze only.

When emitting the final plan:
- Wrap the plan in \`<!-- forge-plan:start -->\` and \`<!-- forge-plan:end -->\` (each on its own line)
- Insert \`<!-- forge-section -->\` on its own line before each executable section
- Use exactly one \`<!-- forge-section -->\` marker per executable phase; place it immediately before that phase's \`## Phase\` heading
- Do not insert \`<!-- forge-section -->\` before \`### Files\`, \`### Edits\`, \`### Acceptance Criteria\`, or \`### Verification\`
- Shared \`## Decisions\` / \`## Conventions\` / \`## Key Context\` blocks go after all sections (no preceding marker)
- After the plan, call the \`question\` tool with options: "New session", "Execute here", "Loop"
</system-reminder>`,
Expand Down
85 changes: 85 additions & 0 deletions src/loop/restartability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Shared restartability logic for determining if a loop can be restarted.
* Used by both execution service and tooling/display layers.
*/

import { existsSync } from 'fs'
import type { LoopState } from '../loop/state'
import { parseTerminationReasonString } from '../loop'

export type RestartBlockedReason =
| 'completed'
| 'missing_worktree'
| 'active_requires_force'

export interface RestartabilityResult {
restartable: boolean
restartRequiresForce: boolean
restartBlockedReason?: RestartBlockedReason
restartBlockedMessage?: string
}

/**
* Determine if a loop can be restarted based on its state.
*
* Rules:
* - Completed loops cannot restart (checked by status field and terminationReason)
* - Missing worktree blocks restart
* - Active/running loops require force
* - All other terminal states (cancelled, errored, stalled) are restartable without force
*/
export function getRestartability(
state: LoopState,
opts?: { force?: boolean; worktreeExists?: (path: string) => boolean }
): RestartabilityResult {
const worktreeExists = opts?.worktreeExists ?? existsSync

// Completed loops cannot restart - check persisted status first
if (state.status === 'completed') {
return {
restartable: false,
restartRequiresForce: false,
restartBlockedReason: 'completed',
restartBlockedMessage: `Loop "${state.loopName}" completed successfully and cannot be restarted.`,
}
}

// Also check terminationReason for legacy/secondary validation
if (state.terminationReason) {
const parsed = parseTerminationReasonString(state.terminationReason)
if (parsed.kind === 'completed') {
return {
restartable: false,
restartRequiresForce: false,
restartBlockedReason: 'completed',
restartBlockedMessage: `Loop "${state.loopName}" completed successfully and cannot be restarted.`,
}
}
}

// Missing worktree blocks restart
if (state.worktree && state.worktreeDir && !worktreeExists(state.worktreeDir)) {
return {
restartable: false,
restartRequiresForce: false,
restartBlockedReason: 'missing_worktree',
restartBlockedMessage: `Cannot restart "${state.loopName}": worktree directory no longer exists at ${state.worktreeDir}.`,
}
}

// Active/running loops require force
if (state.active) {
return {
restartable: true,
restartRequiresForce: true,
restartBlockedReason: 'active_requires_force',
restartBlockedMessage: `Loop "${state.loopName}" is currently active. Use force=true to force-restart a stuck loop.`,
}
}

// All other terminal states (cancelled, errored, stalled) are restartable without force
return {
restartable: true,
restartRequiresForce: false,
}
}
Loading