diff --git a/README.md b/README.md index 6dd482e05..3c36fc57c 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,13 @@ Add to your `opencode.json` to enable Forge’s server-side hooks, tools, and ag } ``` -**Optional — workspace integration:** to let worktree loops appear as switchable OpenCode workspaces in the TUI, also export this in the environment that launches `opencode`: +As of OpenCode 1.17.8, `OPENCODE_EXPERIMENTAL_WORKSPACES=true` is required for the plugin's loop functionality to work. Set it in the environment that launches `opencode`: ```bash export OPENCODE_EXPERIMENTAL_WORKSPACES=true ``` -Requires OpenCode ≥ 1.15.0. Without it, loops still run normally — you just don't get workspace switching. See [Workspace Integration](#workspace-integration) for details. +Without this, Forge cannot create loop worktrees and `loop` / `/loop` will fail. See [Common Issues](#common-issues) and [Workspace Integration](#workspace-integration) for details. ## What Forge Adds @@ -499,30 +499,18 @@ Loops always run in an isolated git worktree. Sandbox is optional: when Docker i ## Workspace Integration -Worktree loops can optionally register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything. +Forge worktree loops register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything. ### Requirements -Workspace integration requires the **experimental workspace runtime** to be enabled in OpenCode itself. The plugin API surface (`experimental_workspace.register`) is always present, but the underlying sync, session-scoping, and TUI dialogs are gated behind an environment variable. Without it, Forge's adapter registers fine but `workspace.create` silently no-ops and the TUI never shows worktree workspaces. - -Set one of these in the environment that launches `opencode`: - -```bash -export OPENCODE_EXPERIMENTAL_WORKSPACES=true -# or, to enable every experimental opencode feature at once: -export OPENCODE_EXPERIMENTAL=true -``` - -Accepted values are `true` or `1` (case-insensitive). Requires **OpenCode ≥ 1.15.0**. +Workspace integration requires the **experimental workspace runtime** enabled in OpenCode. See [Quick Start](#quick-start) for the environment variable setup. No forge config option enables or disables this — the toggle is purely on the OpenCode side and must be present before OpenCode starts. > The `OPENCODE_EXPERIMENTAL_WORKSPACES` flag is not currently documented on opencode.ai. The authoritative source is `packages/core/src/flag/flag.ts` and `packages/opencode/src/effect/runtime-flags.ts` in the OpenCode repo. -No forge config option enables or disables this — the toggle is purely on the OpenCode side. - ### When workspace integration is active -- **Env var set, OpenCode ≥ 1.15.0** → worktree loops become workspace-backed. The worktree directory appears as a switchable workspace in the TUI, and its sessions are bound to that workspace. -- **Env var unset or older OpenCode** → Forge's adapter still registers (the API surface is always present), but `workspace.create` no-ops and the loop runs as a plain worktree loop with no workspace switching. Everything else (iteration, auditing, sandbox, status, cancel, restart) is unaffected. +- **Env var set, OpenCode ≥ 1.17.8** → Forge can create the worktree workspace, bind loop sessions to it, and show the loop as a switchable workspace in the TUI. +- **Env var unset or older OpenCode** → `experimental.workspace.create` is unavailable or no-ops, Forge cannot create the loop worktree, and `loop` / `/loop` fails before iteration starts. ### What it does @@ -536,15 +524,29 @@ When a worktree loop starts with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`, forge: The adapter's `remove` hook commits in-flight changes (when teardown context allows), stops the sandbox container if any, and removes the worktree directory unless the loop is restartable. Branches are preserved for later restart or merge. -### Graceful degradation +### Failure behavior -If workspace creation or session binding fails at runtime — env var unset, OpenCode version too old, network error, API mismatch — the loop **does not abort**. Forge logs the failure, clears the workspace ID, and the loop continues as a regular (non-workspace) worktree loop. You lose workspace-based switching for that loop, but iteration, auditing, sandbox, and restart all run to completion. +If initial workspace creation fails at startup — env var unset, OpenCode version too old, network error, API mismatch — the loop aborts before creating the first loop session. If a workspace disappears after a loop is already running, Forge attempts to re-provision or detach it and continue where possible. ### From the TUI - Loops are launched via the execution dialog (select Loop mode) - On hosts with workspace support, active loops appear as switchable workspaces alongside your main project +## Common Issues + +### `loop` / `/loop` fails to start + +**Most common cause:** `OPENCODE_EXPERIMENTAL_WORKSPACES=true` was not set in the environment that launched OpenCode. See [Quick Start](#quick-start) for setup. + +Symptoms include: + +- `loop` or `/loop` returns an internal error before the first coding session starts +- Forge logs contain `createBuiltinWorktreeWorkspace: workspace.create threw`, `workspace.create returned no workspace id`, or `handleStartLoop: failed to create builtin worktree workspace` +- No loop worktree appears in the TUI workspace switcher + +The flag must be set before OpenCode starts — setting it inside an already-running session is too late. If OpenCode is launched by a desktop app, service manager, shell alias, terminal profile, or wrapper script, set the variable there and fully restart OpenCode. + ## Docker Sandbox Run loop iterations inside an isolated Docker container. Three tools (`bash`, `glob`, `grep`) execute inside the container via `docker exec`, while `read`/`write`/`edit` operate on the host filesystem. The worktree directory is bind-mounted at `/workspace` for instant file sharing, and the source project directory is mounted read-only at `/project` for convenient host-side access. @@ -563,7 +565,7 @@ docker build -t oc-forge-sandbox:latest container/ The image includes Node.js 24, pnpm, Bun, Python 3 + uv, ripgrep, git, and jq. -The `container/Dockerfile` ships with the package. If the image is missing at loop start, the sandbox fails fast with a message showing the build command and the `"enabled": false` opt-out. There is no auto-build — the image must be built manually. +The `container/Dockerfile` ships with the plugin package. If the image is missing when OpenCode starts, Forge shows a warning toast with a "Forge: Build sandbox image" command in the palette. You can also trigger the build from the command palette at any time by searching for `Forge: Build sandbox image`, which opens a confirmation dialog and runs `docker build` automatically. **2. Configure the sandbox** (`~/.config/opencode/forge-config.jsonc`): @@ -665,12 +667,14 @@ When a `sh` command produces output exceeding the tool's limit, the overflow is ### Customizing the Image -The `container/Dockerfile` is included in the project package. To add project-specific tools (e.g., Go, Rust, additional language servers), edit the Dockerfile and rebuild: +The `container/Dockerfile` is included in the plugin package. To add project-specific tools (e.g., Go, Rust, additional language servers), edit the Dockerfile and rebuild: ```bash docker build -t oc-forge-sandbox:latest container/ ``` +You can also rebuild from the command palette using `Forge: Build sandbox image`. This picks up any local changes to the bundled Dockerfile automatically. + ## Development diff --git a/docs/api/README.md b/docs/api/README.md index f71edc923..906c2d26c 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -41,13 +41,13 @@ Add to your `opencode.json` to enable Forge’s server-side hooks, tools, and ag } ``` -**Optional — workspace integration:** to let worktree loops appear as switchable OpenCode workspaces in the TUI, also export this in the environment that launches `opencode`: +**Required for loops:** Forge creates loop worktrees through OpenCode's experimental workspace runtime. Export this in the environment that launches `opencode`: ```bash export OPENCODE_EXPERIMENTAL_WORKSPACES=true ``` -Requires OpenCode ≥ 1.15.0. Without it, loops still run normally — you just don't get workspace switching. See [Workspace Integration](#workspace-integration) for details. +Requires OpenCode ≥ 1.15.0. If this is missing, Forge cannot create the loop worktree and `loop` / `/loop` will fail. See [Common Issues](#common-issues) and [Workspace Integration](#workspace-integration) for details. ## What Forge Adds @@ -499,11 +499,11 @@ Loops always run in an isolated git worktree. Sandbox is optional: when Docker i ## Workspace Integration -Worktree loops can optionally register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything. +Forge worktree loops register as **OpenCode workspaces**, letting you switch between them (and your main project) from the same TUI session without restarting or re-opening anything. ### Requirements -Workspace integration requires the **experimental workspace runtime** to be enabled in OpenCode itself. The plugin API surface (`experimental_workspace.register`) is always present, but the underlying sync, session-scoping, and TUI dialogs are gated behind an environment variable. Without it, Forge's adapter registers fine but `workspace.create` silently no-ops and the TUI never shows worktree workspaces. +Workspace integration requires the **experimental workspace runtime** to be enabled in OpenCode itself. Forge's current loop startup path creates the worktree through `experimental.workspace.create`, so this flag is required for `loop` / `/loop`, not just for TUI switching. Set one of these in the environment that launches `opencode`: @@ -517,12 +517,12 @@ Accepted values are `true` or `1` (case-insensitive). Requires **OpenCode ≥ 1. > The `OPENCODE_EXPERIMENTAL_WORKSPACES` flag is not currently documented on opencode.ai. The authoritative source is `packages/core/src/flag/flag.ts` and `packages/opencode/src/effect/runtime-flags.ts` in the OpenCode repo. -No forge config option enables or disables this — the toggle is purely on the OpenCode side. +No forge config option enables or disables this — the toggle is purely on the OpenCode side and must be present before OpenCode starts. Forge cannot reliably set it from the plugin because OpenCode reads runtime flags before plugins are loaded, and the TUI/server may be separate processes. ### When workspace integration is active -- **Env var set, OpenCode ≥ 1.15.0** → worktree loops become workspace-backed. The worktree directory appears as a switchable workspace in the TUI, and its sessions are bound to that workspace. -- **Env var unset or older OpenCode** → Forge's adapter still registers (the API surface is always present), but `workspace.create` no-ops and the loop runs as a plain worktree loop with no workspace switching. Everything else (iteration, auditing, sandbox, status, cancel, restart) is unaffected. +- **Env var set, OpenCode ≥ 1.15.0** → Forge can create the worktree workspace, bind loop sessions to it, and show the loop as a switchable workspace in the TUI. +- **Env var unset or older OpenCode** → `experimental.workspace.create` is unavailable or no-ops, Forge cannot create the loop worktree, and `loop` / `/loop` fails before iteration starts. ### What it does @@ -536,15 +536,40 @@ When a worktree loop starts with `OPENCODE_EXPERIMENTAL_WORKSPACES=true`, forge: The adapter's `remove` hook commits in-flight changes (when teardown context allows), stops the sandbox container if any, and removes the worktree directory unless the loop is restartable. Branches are preserved for later restart or merge. -### Graceful degradation +### Failure behavior -If workspace creation or session binding fails at runtime — env var unset, OpenCode version too old, network error, API mismatch — the loop **does not abort**. Forge logs the failure, clears the workspace ID, and the loop continues as a regular (non-workspace) worktree loop. You lose workspace-based switching for that loop, but iteration, auditing, sandbox, and restart all run to completion. +If initial workspace creation fails at startup — env var unset, OpenCode version too old, network error, API mismatch — the loop aborts before creating the first loop session. If a workspace disappears after a loop is already running, Forge attempts to re-provision or detach it and continue where possible. ### From the TUI - Loops are launched via the Execute tab in the Plan Viewer dialog (select Loop mode) - On hosts with workspace support, active loops appear as switchable workspaces alongside your main project +## Common Issues + +### `loop` / `/loop` fails to start + +**Most common cause:** `OPENCODE_EXPERIMENTAL_WORKSPACES=true` was not set in the environment that launched OpenCode. + +Symptoms include: + +- `loop` or `/loop` returns an internal error before the first coding session starts +- Forge logs contain `createBuiltinWorktreeWorkspace: workspace.create threw`, `workspace.create returned no workspace id`, or `handleStartLoop: failed to create builtin worktree workspace` +- No loop worktree appears in the TUI workspace switcher + +Fix: + +```bash +export OPENCODE_EXPERIMENTAL_WORKSPACES=true +opencode +``` + +If OpenCode is launched by a desktop app, service manager, shell alias, terminal profile, or wrapper script, set the variable there and fully restart OpenCode. Setting it inside an already-running OpenCode session is too late. + +### Can Forge enable workspaces automatically? + +Not reliably. OpenCode reads its experimental runtime flags before plugins are loaded, so setting `process.env.OPENCODE_EXPERIMENTAL_WORKSPACES = "true"` inside Forge would usually happen too late and only affect the current process. Configure the environment before starting OpenCode instead. + ## Docker Sandbox Run loop iterations inside an isolated Docker container. Three tools (`bash`, `glob`, `grep`) execute inside the container via `docker exec`, while `read`/`write`/`edit` operate on the host filesystem. Your project directory is bind-mounted at `/workspace` for instant file sharing. diff --git a/docs/modules.md b/docs/modules.md index 13bed2b5d..abe1254df 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -182,7 +182,8 @@ createLoop(deps: LoopRuntimeDeps): Loop isWorkspaceNotFoundError(error): boolean // State -rowToLoopState(row, largeFields?): LoopState +loopRowToState(row, largeFields?): LoopState +loopStateToRow(state, projectId): LoopRow MAX_RETRIES: number // Transitions @@ -485,7 +486,7 @@ Other modules do NOT have barrel files (utils, sandbox, services, workspace). All data access goes through typed repo interfaces: - `LoopsRepo`, `PlansRepo`, `ReviewFindingsRepo`, `SectionPlansRepo`, `TuiPrefsRepo` - Each created via `createXxxRepo(db)` with project-scoped queries. -- Rows are mapped to domain objects via `rowToLoopState()` etc. +- Rows are mapped to domain objects via `loopRowToState()` etc. ### State Machine Pattern diff --git a/package.json b/package.json index 717b4295a..f1dfc1266 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-forge", - "version": "0.5.0", + "version": "0.5.0-beta.2", "type": "module", "oc-plugin": [ "server", diff --git a/src/hooks/host-side-effects.ts b/src/hooks/host-side-effects.ts index 5fb7dca3e..ba99835a4 100644 --- a/src/hooks/host-side-effects.ts +++ b/src/hooks/host-side-effects.ts @@ -10,6 +10,7 @@ import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-r import { aggregateToUsageSummary } from '../utils/loop-format' import { sweepStaleForgeWorkspaces } from '../workspace/sweep-stale' import { selectSessionBestEffort } from '../utils/tui-navigation' +import { cleanupLoopWorktree } from '../utils/worktree-cleanup' export interface TerminationSideEffectsContext { client: ForgeClient @@ -171,9 +172,10 @@ function getToastMessage(state: LoopState, reason: TerminationReason): string { * session. Once `workspace.remove` fires, that view becomes orphaned. We reuse * the same navigation path as warp-in (`selectSessionBestEffort`): the * `tui.selectSession` command first, falling back to a `tui.session.select` - * publish. Omitting the `workspace` property returns the user to the host - * session on the local system. Best-effort: failures are logged but never - * block teardown. + * publish. Selecting through the current workspace context reaches a TUI that + * is still scoped to the soon-to-be-removed workspace; selecting without a + * workspace reaches local project views. Best-effort: failures are logged but + * never block teardown. */ async function unwarpToHostSession( state: LoopState, @@ -181,12 +183,29 @@ async function unwarpToHostSession( ): Promise { if (!state.hostSessionId || !state.projectDir) return + if (state.workspaceId) { + await selectSessionBestEffort(ctx.client, state.projectDir, ctx.logger, { + sessionID: state.hostSessionId, + workspace: state.workspaceId, + }) + } + await selectSessionBestEffort(ctx.client, state.projectDir, ctx.logger, { sessionID: state.hostSessionId, }) + + const settleMs = resolveUnwarpSettleMs() + if (settleMs > 0) { + await new Promise((resolve) => setTimeout(resolve, settleMs)) + } ctx.logger.log(`Loop: unwarped TUI to host session ${state.hostSessionId} for ${state.loopName}`) } +function resolveUnwarpSettleMs(): number { + const raw = Number(process.env.FORGE_UNWARP_SETTLE_MS) + return Number.isFinite(raw) && raw >= 0 ? raw : 750 +} + /** Tear down the worktree workspace — always commits changes back. */ async function teardownWorktree( state: LoopState, @@ -198,18 +217,21 @@ async function teardownWorktree( const reasonLabel = resolveReasonLabel(reason) const doCommit = true const doRemoveWorktree = reason.kind === 'completed' + const removeWorktreeAfterWorkspaceRemoval = doRemoveWorktree ctx.pendingTeardowns?.set(state.loopName, { iteration: state.iteration, reasonLabel, doCommit, - doRemoveWorktree, + doRemoveWorktree: false, }) await unwarpToHostSession(state, ctx) + let removedWorkspace = false try { await ctx.client.workspace.remove({ id: state.workspaceId }) + removedWorkspace = true ctx.logger.log(`Loop: workspace ${state.workspaceId} removed for ${state.loopName}`) } catch (err) { ctx.logger.error(`Loop: workspace.remove threw for ${state.workspaceId}`, err) @@ -217,6 +239,18 @@ async function teardownWorktree( ctx.pendingTeardowns?.clear(state.loopName) } + if (removedWorkspace && removeWorktreeAfterWorkspaceRemoval && state.worktreeDir) { + const settleMs = resolveUnwarpSettleMs() + if (settleMs > 0) { + await new Promise((resolve) => setTimeout(resolve, settleMs)) + } + await cleanupLoopWorktree({ + worktreeDir: state.worktreeDir, + logPrefix: 'Loop: post-workspace-remove', + logger: ctx.logger, + }) + } + // Opportunistic sweep of stale sibling workspaces (port required) if (ctx.client && ctx.loopsRepo && ctx.projectId && ctx.pendingTeardowns && state.projectDir) { try { diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index 62ab19c7d..c19d9999a 100644 --- a/src/hooks/loop.ts +++ b/src/hooks/loop.ts @@ -70,7 +70,7 @@ export function createLoopEventHandler( getConfig, sandboxManager, dataDir, - getPlanText: loop.getPlanText, + getPlanText: loop.service.getPlanText, pendingTeardowns, loopsRepo, projectId, diff --git a/src/hooks/plan-approval.ts b/src/hooks/plan-approval.ts index dc1136210..ae12d83a9 100644 --- a/src/hooks/plan-approval.ts +++ b/src/hooks/plan-approval.ts @@ -87,8 +87,8 @@ async function resolveBlockedLoopToolState( ): Promise<{ active?: boolean; loopName?: string; phase?: string } | null> { if (deps.resolveActiveLoopForSession) return deps.resolveActiveLoopForSession(sessionID) - const loopName = loop.resolveLoopName(sessionID) - const state = loopName ? loop.getActiveState(loopName) : null + const loopName = loop.service.resolveLoopName(sessionID) + const state = loopName ? loop.service.getActiveState(loopName) : null if (state?.active && isActiveLoopToolSession(state, sessionID)) return state return null } @@ -136,7 +136,7 @@ function isPlanApprovalQuestionArgs(args: unknown): boolean { } export function createToolExecuteBeforeHook(ctx: ToolContext, deps: LoopToolBlockingDeps = {}): Hooks['tool.execute.before'] { - const loop = ctx.loop ?? (ctx as ToolContext & { loopService: ToolContext['loop'] }).loopService + const loop = ctx.loop const { logger } = ctx return async ( @@ -154,7 +154,7 @@ export function createToolExecuteBeforeHook(ctx: ToolContext, deps: LoopToolBloc } export function createToolExecuteAfterHook(ctx: ToolContext, deps: LoopToolBlockingDeps = {}): Hooks['tool.execute.after'] { - const loop = ctx.loop ?? (ctx as ToolContext & { loopService: ToolContext['loop'] }).loopService + const loop = ctx.loop const { logger, config } = ctx return async ( diff --git a/src/index.ts b/src/index.ts index 5b263b0ec..663dd2c10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -223,6 +223,34 @@ export function createForgePlugin(config: PluginConfig): Plugin { } } + if (sandboxManager && forgeClient) { + const sandboxImage = config.sandbox?.image ?? 'oc-forge-sandbox:latest' + const buildContextDir = resolveBundledContainerDir() + void (async () => { + try { + const dockerOk = await dockerService.checkDocker() + if (!dockerOk) return + const exists = await dockerService.imageExists(sandboxImage) + if (!exists) { + logger.log(`Sandbox image "${sandboxImage}" not found — publishing toast`) + await forgeClient.tui.publish({ + body: { + type: 'tui.toast.show' as const, + properties: { + title: 'Sandbox image not found', + message: `Docker image "${sandboxImage}" is missing. Build it from the command palette: "Forge: Build sandbox image", or run: docker build -t ${sandboxImage} "${buildContextDir}"`, + variant: 'warning' as const, + duration: 10_000, + }, + }, + }).catch(() => {}) + } + } catch (err: unknown) { + logger.log(`Sandbox image check: ${err instanceof Error ? err.message : String(err)}`) + } + })() + } + // Pending-teardown registry: caller (loop termination side-effects) writes // iteration/reason/doCommit here right before invoking workspace.remove so // the forge adapter can build informative commit messages while remaining diff --git a/src/loop/runtime-prompt.ts b/src/loop/runtime-prompt.ts new file mode 100644 index 000000000..cc26bc6d4 --- /dev/null +++ b/src/loop/runtime-prompt.ts @@ -0,0 +1,131 @@ +import type { ForgeClient } from '../client/port' +import type { Logger, PluginConfig } from '../types' +import type { LoopService } from './service' +import { sendLoopPrompt } from './send-loop-prompt' +import { resolveLoopModel } from '../utils/loop-helpers' +import { markPromptSent } from './idle-gate' +import { promptAuditSession } from '../utils/audit-session' + +export interface PromptDispatchDeps { + client: ForgeClient + logger: Logger + getConfig: () => PluginConfig + loopService: LoopService +} + +export interface SendPromptInput { + loopName: string + sessionId: string + promptText: string + agent: 'code' | 'auditor-loop' + model?: { providerID: string; modelID: string } | null + variant?: string +} + +export interface PromptDispatch { + sendPromptWithFallback(input: SendPromptInput): Promise<{ error?: unknown; usedModel?: { providerID: string; modelID: string } | undefined }> + + getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null; lastMessageRole: string }> +} + +export function createPromptDispatch(deps: PromptDispatchDeps): PromptDispatch { + const { client, logger, getConfig, loopService } = deps + + async function sendPromptWithFallback(input: SendPromptInput): Promise<{ error?: unknown; usedModel?: { providerID: string; modelID: string } | undefined }> { + const { loopName, sessionId, promptText, agent } = input + + if (agent === 'auditor-loop') { + const auditorModel = input.model != null ? input.model : undefined + const { result, usedModel } = await sendLoopPrompt({ + loopName, sessionId, agent: 'auditor-loop', logger, + primaryModel: auditorModel, + performPrompt: async (model) => { + const freshState = loopService.getActiveState(loopName) + if (!freshState?.active) throw new Error('loop_cancelled') + markPromptSent(loopName, sessionId, logger) + const r = await promptAuditSession(client, { + sessionId, + worktreeDir: freshState.worktreeDir, + workspaceId: freshState.workspaceId, + prompt: promptText, + ...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}), + }) + return r.ok ? {} : { error: r.error } + }, + }) + return { error: result.error, usedModel } + } + + const effectiveModel = input.model != null ? input.model : resolveLoopModel(getConfig(), loopService, loopName) + const { result, usedModel } = await sendLoopPrompt({ + loopName, sessionId, agent: 'code', logger, + primaryModel: effectiveModel, + performPrompt: async (model) => { + const freshState = loopService.getActiveState(loopName) + if (!freshState?.active) throw new Error('loop_cancelled') + markPromptSent(loopName, sessionId, logger) + try { + await client.session.promptAsync({ + sessionID: sessionId, + directory: freshState.worktreeDir, + ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), + agent: 'code', + parts: [{ type: 'text' as const, text: promptText }], + ...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}), + }) + return {} + } catch (err) { + return { error: err } + } + }, + }) + return { error: result.error, usedModel } + } + + async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null; lastMessageRole: string }> { + try { + const messages = await client.session.messages({ + sessionID: sessionId, + directory: worktreeDir, + limit: 4, + }) as Array<{ + info: { role: string; finish?: string; error?: { name?: string; data?: { message?: string } } } + parts: Array<{ type: string; text?: string }> + }> + + const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null + + if (!lastMessage) { + return { text: null, error: null, lastMessageRole: 'none' } + } + + if (lastMessage.info.role !== 'assistant') { + logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${lastMessage.info.role ?? 'unknown'}`) + return { text: null, error: null, lastMessageRole: lastMessage.info.role ?? 'unknown' } + } + + const lastAssistant = lastMessage + if (lastAssistant.info.finish && lastAssistant.info.finish !== 'stop') { + logger.log(`Loop: assistant message in session ${sessionId} is not final yet (finish=${lastAssistant.info.finish})`) + return { text: null, error: null, lastMessageRole: `assistant:${lastAssistant.info.finish}` } + } + + const text = lastAssistant.parts + .filter((p) => p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text as string) + .join('\n') || null + + const error = lastAssistant.info.error?.data?.message ?? lastAssistant.info.error?.name ?? null + + return { text, error, lastMessageRole: 'assistant' } + } catch (err) { + logger.error(`Loop: could not read session messages`, err) + return { text: null, error: null, lastMessageRole: 'error' } + } + } + + return { + sendPromptWithFallback, + getLastAssistantInfo, + } +} diff --git a/src/loop/runtime-usage.ts b/src/loop/runtime-usage.ts new file mode 100644 index 000000000..352223592 --- /dev/null +++ b/src/loop/runtime-usage.ts @@ -0,0 +1,111 @@ +import type { ForgeClient } from '../client/port' +import type { Logger, PluginConfig } from '../types' +import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo' +import type { LoopState } from './state' +import { summarizeAssistantUsage, type UsageAttribution, type AssistantMessageInfo } from './token-usage' + +export interface UsageCaptureDeps { + client: ForgeClient + logger: Logger + getConfig: () => PluginConfig + projectId: string + loopSessionUsageRepo?: LoopSessionUsageRepo +} + +export interface UsageCapture { + getFallbackModelForSession(state: LoopState, phase: LoopState['phase']): string | undefined + captureLoopSessionUsage(input: { + loopName: string + sessionId: string + directory: string + role: 'code' | 'auditor' | 'unknown' + fallbackModel?: string + }): Promise +} + +export function createUsageCapture(deps: UsageCaptureDeps): UsageCapture { + const { client, logger, getConfig, projectId, loopSessionUsageRepo } = deps + + /** + * Determine the fallback model for a session based on phase and loop state. + * For code sessions: state.executionModel > config.executionModel + * For audit/final-audit sessions: state.auditorModel > state.executionModel > config.auditorModel > config.executionModel + */ + function getFallbackModelForSession(state: LoopState, phase: LoopState['phase']): string | undefined { + const config = getConfig() + if (phase === 'auditing' || phase === 'final_auditing') { + return ( + state.auditorModel ?? + state.executionModel ?? + config.auditorModel ?? + config.executionModel + ) + } + // Code session + return ( + state.executionModel ?? + config.executionModel + ) + } + + /** + * Capture and persist token usage for a loop session. + * Non-fatal: logs errors but does not block deletion or termination. + */ + async function captureLoopSessionUsage(input: { + loopName: string + sessionId: string + directory: string + role: 'code' | 'auditor' | 'unknown' + fallbackModel?: string + }): Promise { + if (!loopSessionUsageRepo) { + return + } + + try { + const messages = await client.session.messages({ + sessionID: input.sessionId, + directory: input.directory, + }) as Array<{ info: AssistantMessageInfo }> + + const attribution: UsageAttribution = { + role: input.role, + fallbackModel: input.fallbackModel, + } + + const usageSummary = summarizeAssistantUsage(messages, attribution) + + if (usageSummary.perModel.length === 0) { + logger.debug(`Loop: no assistant usage to capture for session ${input.sessionId}`) + return + } + + const rows = usageSummary.perModel.map((modelUsage) => ({ + projectId, + loopName: input.loopName, + sessionId: input.sessionId, + role: input.role, + model: modelUsage.model, + cost: modelUsage.cost, + inputTokens: modelUsage.tokens.input, + outputTokens: modelUsage.tokens.output, + reasoningTokens: modelUsage.tokens.reasoning, + cacheReadTokens: modelUsage.tokens.cacheRead, + cacheWriteTokens: modelUsage.tokens.cacheWrite, + messageCount: modelUsage.messageCount, + capturedAt: Date.now(), + })) + + loopSessionUsageRepo.upsertSessionUsage(rows) + logger.debug(`Loop: captured usage for session ${input.sessionId} (${input.role})`) + } catch (err) { + logger.error(`Loop: failed to capture usage for session ${input.sessionId}`, err) + } + } + + return { + getFallbackModelForSession, + captureLoopSessionUsage, + } +} diff --git a/src/loop/runtime-workspace.ts b/src/loop/runtime-workspace.ts new file mode 100644 index 000000000..7167cd766 --- /dev/null +++ b/src/loop/runtime-workspace.ts @@ -0,0 +1,154 @@ +import type { ForgeClient } from '../client/port' +import type { LoopService } from './service' +import type { LoopState } from './state' +import type { Logger } from '../types' +import { publishWorkspaceDetachedToast } from '../utils/loop-session' +import { bindSessionToWorkspace } from '../workspace/forge-worktree' + +export function isWorkspaceNotFoundError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err ?? '') + return /Workspace not found/i.test(msg) +} + +export interface WorkspaceLifecycleDeps { + client: ForgeClient + logger: Logger + loopService: LoopService +} + +export interface WorkspaceLifecycle { + detachFromWorkspace(loopName: string, state: LoopState, context?: string): void + recoverFromMissingWorkspace( + loopName: string, + state: LoopState, + sessionId: string, + contextLabel: string, + bindError?: unknown, + ): Promise<{ workspaceId?: string; recovered: boolean }> + ensureWorkspaceForLoop( + loopName: string, + state: LoopState, + contextLabel: string, + ): Promise<{ workspaceId?: string }> +} + +export function createWorkspaceLifecycle(deps: WorkspaceLifecycleDeps): WorkspaceLifecycle { + const { client, logger, loopService } = deps + + function detachFromWorkspace( + loopName: string, + state: LoopState, + context?: string, + ): void { + loopService.clearWorkspaceId(loopName) + state.workspaceId = undefined + publishWorkspaceDetachedToast({ + client, + directory: state.projectDir ?? state.worktreeDir, + loopName, + logger, + context, + }) + } + + async function recoverFromMissingWorkspace( + loopName: string, + state: LoopState, + sessionId: string, + contextLabel: string, + bindError?: unknown, + ): Promise<{ workspaceId?: string; recovered: boolean }> { + if (!state.workspaceId) { + return { recovered: false } + } + + if (bindError && !isWorkspaceNotFoundError(bindError)) { + logger.log(`Loop: skipping workspace re-provision for ${loopName} because bind error is not "workspace not found"`) + return { recovered: false } + } + + detachFromWorkspace(loopName, state, contextLabel) + + const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') + const projectDirectory = state.projectDir ?? state.worktreeDir + if (!projectDirectory) { + logger.log(`Loop: cannot recover workspace for ${loopName}: no projectDir/worktreeDir`) + return { recovered: false } + } + const created = await createBuiltinWorktreeWorkspace( + client, + { + loopName, + directory: projectDirectory, + }, + logger, + ) + + if (!created.ok) { + logger.error(`Loop: workspace re-provision failed for ${loopName} (${created.error.reason}), continuing without workspace backing`) + return { recovered: false } + } + + const newWorkspace = created.workspace + try { + await bindSessionToWorkspace(client, newWorkspace.workspaceId, sessionId, logger, { loopName }) + loopService.setWorkspaceId(loopName, newWorkspace.workspaceId) + state.workspaceId = newWorkspace.workspaceId + if (newWorkspace.directory) state.worktreeDir = newWorkspace.directory + if (newWorkspace.branch) state.worktreeBranch = newWorkspace.branch + logger.log(`Loop: re-provisioned workspace ${newWorkspace.workspaceId} for ${loopName} after stale id`) + return { workspaceId: newWorkspace.workspaceId, recovered: true } + } catch (err) { + logger.error(`Loop: failed to bind session to re-provisioned workspace ${newWorkspace.workspaceId}`, err) + return { recovered: false } + } + } + + async function ensureWorkspaceForLoop( + loopName: string, + state: LoopState, + contextLabel: string, + ): Promise<{ workspaceId?: string }> { + if (state.workspaceId) { + return { workspaceId: state.workspaceId } + } + + if (!state.worktree) { + return {} + } + + const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') + const projectDirectory = state.projectDir ?? state.worktreeDir + if (!projectDirectory) { + logger.log(`Loop: cannot provision workspace for ${loopName} (${contextLabel}): no projectDir/worktreeDir`) + return {} + } + const created = await createBuiltinWorktreeWorkspace( + client, + { + loopName, + directory: projectDirectory, + }, + logger, + ) + + if (!created.ok) { + logger.log(`Loop: workspace creation failed for ${loopName} (${contextLabel}, ${created.error.reason}), continuing without workspace backing`) + return {} + } + + const workspace = created.workspace + loopService.setWorkspaceId(loopName, workspace.workspaceId) + state.workspaceId = workspace.workspaceId + if (workspace.directory) state.worktreeDir = workspace.directory + if (workspace.branch) state.worktreeBranch = workspace.branch + logger.log(`Loop: provisioned workspace ${workspace.workspaceId} for ${loopName} (${contextLabel})`) + return { workspaceId: workspace.workspaceId } + } + + return { + detachFromWorkspace, + recoverFromMissingWorkspace, + ensureWorkspaceForLoop, + } +} diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 2c299e50d..68397957c 100644 --- a/src/loop/runtime.ts +++ b/src/loop/runtime.ts @@ -1,5 +1,5 @@ import type { ForgeClient } from '../client/port' -import type { LoopChangeNotifier } from './service' +import type { LoopChangeNotifier, LoopService } from './service' import { createLoopService, MAX_RETRIES } from './service' import { generateUniqueName } from './name-uniqueness' import type { LoopState } from './state' @@ -7,20 +7,18 @@ import type { Logger, PluginConfig, LoopConfig } from '../types' import type { LoopsRepo } from '../storage/repos/loops-repo' import type { PlansRepo } from '../storage/repos/plans-repo' import type { ReviewFindingsRepo, ReviewFindingRow } from '../storage/repos/review-findings-repo' -import type { SectionPlansRepo, SectionPlanRow } from '../storage/repos/section-plans-repo' +import type { SectionPlansRepo } from '../storage/repos/section-plans-repo' import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo' import { createLoopWatchdog, type LoopWatchdogStallInfo, type LoopWatchdogRecoveryContext } from '../hooks/watchdog' -import { sendLoopPrompt } from './send-loop-prompt' import { resolveLoopModel, resolveLoopAuditorModel } from '../utils/loop-helpers' import type { createSandboxManager } from '../sandbox/manager' // worktree-completion imports moved to hooks/loop.ts (termination side-effects) import { buildLoopPermissionRuleset } from '../constants/loop' -import { createLoopSessionWithWorkspace, publishWorkspaceDetachedToast } from '../utils/loop-session' +import { createLoopSessionWithWorkspace } from '../utils/loop-session' // worktree-cleanup imports moved to hooks/loop.ts (termination side-effects) import { createAuditSession, promptAuditSession } from '../utils/audit-session' import { formatLoopSessionTitle } from '../utils/session-titles' -import { bindSessionToWorkspace } from '../workspace/forge-worktree' -import { markPromptSent, clearPromptPending, sessionsAwaitingBusy, isAwaitingBusy, isAwaitingBusyExpired } from './idle-gate' +import { clearPromptPending, sessionsAwaitingBusy, isAwaitingBusy, isAwaitingBusyExpired } from './idle-gate' import { clearPromptInFlight, clearPromptInFlightBySession, @@ -30,7 +28,9 @@ import { import type { TerminationReason } from './termination' import { terminationStatusFor, terminationReasonToString } from './termination' import { nextTransition } from './transitions' -import { summarizeAssistantUsage, type UsageAttribution } from './token-usage' +import { createUsageCapture } from './runtime-usage' +import { createPromptDispatch } from './runtime-prompt' +import { createWorkspaceLifecycle, isWorkspaceNotFoundError } from './runtime-workspace' import { loopRegistry } from '../utils/loop-registry' import { parseCoderDecisions } from '../utils/coder-decisions' @@ -61,6 +61,8 @@ export interface LoopRuntimeDeps { loopConfig?: LoopConfig sectionPlansRepo?: SectionPlansRepo loopSessionUsageRepo?: LoopSessionUsageRepo + /** Optional injected LoopService (test seam). Defaults to a real one built from the repos. */ + loopService?: LoopService } export interface StartLoopInput { @@ -89,65 +91,19 @@ export interface Loop { generateUniqueLoopName(baseName: string): string /** Transition a running loop's phase. */ setPhase(name: string, phase: LoopState['phase']): void - - // State management methods (from LoopService) - resolveLoopName(sessionId: string): string | null - getActiveState(name: string): LoopState | null - getAnyState(name: string): LoopState | null - setState(name: string, state: LoopState): void - deleteState(name: string): void - registerLoopSession(sessionId: string, loopName: string): void - replaceSession(name: string, opts: { newSessionId: string; phase: LoopState['phase']; iteration?: number; resetError?: boolean; auditCount?: number; lastAuditResult?: string | null }): void - setStatus(name: string, status: 'running' | 'completed' | 'cancelled' | 'errored' | 'stalled'): void - setPhaseAndResetError(name: string, phase: LoopState['phase']): void - setModelFailed(name: string, failed: boolean): void - setLastAuditResult(name: string, text: string): void - clearLastAuditResult(name: string): void - setSandboxContainer(name: string, containerName: string | null): void - clearWorkspaceId(name: string): void - setWorkspaceId(name: string, workspaceId: string): void - incrementError(name: string): number - resetError(name: string): void - terminateLoop(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }): void - getOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): ReviewFindingRow[] - bumpFindingRecurrence(name: string, findings: ReviewFindingRow[]): void - resetSectionRecurrence(name: string, sectionIndex: number): void - getStallTimeoutMs(): number - getMaxConsecutiveStalls(): number - - // Prompt building methods - buildContinuationPrompt(state: LoopState, auditFindings?: string): string - buildAuditPrompt(state: LoopState): string - buildSectionInitialPrompt(state: LoopState): string - buildSectionAuditPrompt(state: LoopState): string - buildSectionContinuationPrompt(state: LoopState, auditText: string): string - buildFinalAuditPrompt(state: LoopState): string - buildFinalAuditFixPrompt(state: LoopState, auditText: string): string - - // Plan and section methods - getPlanText(loopName: string, sessionId: string): string | null - getSectionPlan(state: LoopState, index: number): SectionPlanRow | null - getNextIncompleteSectionPlan(state: LoopState): SectionPlanRow | null - getCompletedSectionDigest(state: LoopState): { index: number; title: string; summaryDone: string | null; summaryDeviations: string | null; summaryFollowUps: string | null }[] - parseSectionSummary(text: string): { done: string | null; deviations: string | null; followUps: string | null } | null - completeSection(loopName: string, index: number, summary: { done: string | null; deviations: string | null; followUps: string | null }): void - incrementSectionAttempts(loopName: string, index: number): void - resetSectionForRewind(loopName: string, index: number): void - setCurrentSectionIndex(loopName: string, index: number): void - setFinalAuditDone(loopName: string, done: boolean): void - startSection(loopName: string, index: number): void - bulkInsertSections(loopName: string, sections: { index: number; title: string; content: string }[]): void - setTotalSections(loopName: string, total: number): void + /** Access the underlying LoopService for state/prompt/section operations. */ + service: LoopService } -export function isWorkspaceNotFoundError(err: unknown): boolean { - const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : JSON.stringify(err ?? '') - return /Workspace not found/i.test(msg) -} +export { isWorkspaceNotFoundError } from './runtime-workspace' export function createLoop(deps: LoopRuntimeDeps): Loop { const { loopsRepo, plansRepo, reviewFindingsRepo, projectId, client, logger, getConfig, onTerminated, notify, loopConfig, sectionPlansRepo, loopSessionUsageRepo } = deps - const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger, loopConfig, notify, sectionPlansRepo) + const loopService = deps.loopService ?? createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, logger, loopConfig, notify, sectionPlansRepo) + + const { getFallbackModelForSession, captureLoopSessionUsage } = createUsageCapture({ client, logger, getConfig, projectId, loopSessionUsageRepo }) + + const { sendPromptWithFallback, getLastAssistantInfo } = createPromptDispatch({ client, logger, getConfig, loopService }) const retryTimeouts = new Map() const idleRetryTimeouts = new Map() @@ -183,304 +139,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { return nextPromise } - async function sendPromptWithFallback(input: { - loopName: string - sessionId: string - promptText: string - agent: 'code' | 'auditor-loop' - model?: { providerID: string; modelID: string } | null - variant?: string - }): Promise<{ error?: unknown; usedModel?: { providerID: string; modelID: string } | undefined }> { - const { loopName, sessionId, promptText, agent } = input - - if (agent === 'auditor-loop') { - const auditorModel = input.model != null ? input.model : undefined - const { result, usedModel } = await sendLoopPrompt({ - loopName, sessionId, agent: 'auditor-loop', logger, - primaryModel: auditorModel, - performPrompt: async (model) => { - const freshState = loopService.getActiveState(loopName) - if (!freshState?.active) throw new Error('loop_cancelled') - markPromptSent(loopName, sessionId, logger) - const r = await promptAuditSession(client, { - sessionId, - worktreeDir: freshState.worktreeDir, - workspaceId: freshState.workspaceId, - prompt: promptText, - ...(model ? { auditorModel: model, ...(input.variant ? { auditorVariant: input.variant } : {}) } : {}), - }) - return r.ok ? {} : { error: r.error } - }, - }) - return { error: result.error, usedModel } - } - - const effectiveModel = input.model != null ? input.model : resolveLoopModel(getConfig(), loopService, loopName) - const { result, usedModel } = await sendLoopPrompt({ - loopName, sessionId, agent: 'code', logger, - primaryModel: effectiveModel, - performPrompt: async (model) => { - const freshState = loopService.getActiveState(loopName) - if (!freshState?.active) throw new Error('loop_cancelled') - markPromptSent(loopName, sessionId, logger) - try { - await client.session.promptAsync({ - sessionID: sessionId, - directory: freshState.worktreeDir, - ...(freshState.workspaceId ? { workspace: freshState.workspaceId } : {}), - agent: 'code', - parts: [{ type: 'text' as const, text: promptText }], - ...(model ? { model, ...(input.variant ? { variant: input.variant } : {}) } : {}), - }) - return {} - } catch (err) { - return { error: err } - } - }, - }) - return { error: result.error, usedModel } - } - - async function getLastAssistantInfo(sessionId: string, worktreeDir: string): Promise<{ text: string | null; error: string | null; lastMessageRole: string }> { - try { - const messages = await client.session.messages({ - sessionID: sessionId, - directory: worktreeDir, - limit: 4, - }) as Array<{ - info: { role: string; finish?: string; error?: { name?: string; data?: { message?: string } } } - parts: Array<{ type: string; text?: string }> - }> - - const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null - - if (!lastMessage) { - return { text: null, error: null, lastMessageRole: 'none' } - } - - if (lastMessage.info.role !== 'assistant') { - logger.log(`Loop: no assistant message found in session ${sessionId}, last message role: ${lastMessage.info.role ?? 'unknown'}`) - return { text: null, error: null, lastMessageRole: lastMessage.info.role ?? 'unknown' } - } - - const lastAssistant = lastMessage - if (lastAssistant.info.finish && lastAssistant.info.finish !== 'stop') { - logger.log(`Loop: assistant message in session ${sessionId} is not final yet (finish=${lastAssistant.info.finish})`) - return { text: null, error: null, lastMessageRole: `assistant:${lastAssistant.info.finish}` } - } - - const text = lastAssistant.parts - .filter((p) => p.type === 'text' && typeof p.text === 'string') - .map((p) => p.text as string) - .join('\n') || null - - const error = lastAssistant.info.error?.data?.message ?? lastAssistant.info.error?.name ?? null - - return { text, error, lastMessageRole: 'assistant' } - } catch (err) { - logger.error(`Loop: could not read session messages`, err) - return { text: null, error: null, lastMessageRole: 'error' } - } - } - - /** - * Determine the fallback model for a session based on phase and loop state. - * For code sessions: state.executionModel > config.executionModel - * For audit/final-audit sessions: state.auditorModel > state.executionModel > config.auditorModel > config.executionModel - */ - function getFallbackModelForSession(state: LoopState, phase: LoopState['phase']): string | undefined { - const config = getConfig() - if (phase === 'auditing' || phase === 'final_auditing') { - return ( - state.auditorModel ?? - state.executionModel ?? - config.auditorModel ?? - config.executionModel - ) - } - // Code session - return ( - state.executionModel ?? - config.executionModel - ) - } - - /** - * Capture and persist token usage for a loop session. - * Non-fatal: logs errors but does not block deletion or termination. - */ - async function captureLoopSessionUsage(input: { - loopName: string - sessionId: string - directory: string - role: 'code' | 'auditor' | 'unknown' - fallbackModel?: string - }): Promise { - if (!loopSessionUsageRepo) { - return - } - - try { - const messages = await client.session.messages({ - sessionID: input.sessionId, - directory: input.directory, - }) as Array<{ - info: { - role: string - cost?: number - tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } - model?: string - modelID?: string - modelId?: string - provider?: string - providerID?: string - model_name?: string - } - }> - - const attribution: UsageAttribution = { - role: input.role, - fallbackModel: input.fallbackModel, - } - - const usageSummary = summarizeAssistantUsage(messages, attribution) - - if (usageSummary.perModel.length === 0) { - logger.debug(`Loop: no assistant usage to capture for session ${input.sessionId}`) - return - } - - const rows = usageSummary.perModel.map((modelUsage) => ({ - projectId, - loopName: input.loopName, - sessionId: input.sessionId, - role: input.role, - model: modelUsage.model, - cost: modelUsage.cost, - inputTokens: modelUsage.tokens.input, - outputTokens: modelUsage.tokens.output, - reasoningTokens: modelUsage.tokens.reasoning, - cacheReadTokens: modelUsage.tokens.cacheRead, - cacheWriteTokens: modelUsage.tokens.cacheWrite, - messageCount: modelUsage.messageCount, - capturedAt: Date.now(), - })) - - loopSessionUsageRepo.upsertSessionUsage(rows) - logger.debug(`Loop: captured usage for session ${input.sessionId} (${input.role})`) - } catch (err) { - logger.error(`Loop: failed to capture usage for session ${input.sessionId}`, err) - } - } - - function detachFromWorkspace( - loopName: string, - state: LoopState, - context?: string, - ): void { - loopService.clearWorkspaceId(loopName) - state.workspaceId = undefined - publishWorkspaceDetachedToast({ - client: client, - directory: state.projectDir ?? state.worktreeDir, - loopName, - logger, - context, - }) - } - - async function recoverFromMissingWorkspace( - loopName: string, - state: LoopState, - sessionId: string, - contextLabel: string, - bindError?: unknown, - ): Promise<{ workspaceId?: string; recovered: boolean }> { - if (!state.workspaceId) { - return { recovered: false } - } - - if (bindError && !isWorkspaceNotFoundError(bindError)) { - logger.log(`Loop: skipping workspace re-provision for ${loopName} because bind error is not "workspace not found"`) - return { recovered: false } - } - - detachFromWorkspace(loopName, state, contextLabel) - - const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const projectDirectory = state.projectDir ?? state.worktreeDir - if (!projectDirectory) { - logger.log(`Loop: cannot recover workspace for ${loopName}: no projectDir/worktreeDir`) - return { recovered: false } - } - const newWorkspace = await createBuiltinWorktreeWorkspace( - client, - { - loopName, - directory: projectDirectory, - }, - logger, - ) - - if (!newWorkspace) { - logger.error(`Loop: workspace re-provision failed for ${loopName}, continuing without workspace backing`) - return { recovered: false } - } - - try { - await bindSessionToWorkspace(client, newWorkspace.workspaceId, sessionId, logger, { loopName }) - loopService.setWorkspaceId(loopName, newWorkspace.workspaceId) - state.workspaceId = newWorkspace.workspaceId - if (newWorkspace.directory) state.worktreeDir = newWorkspace.directory - if (newWorkspace.branch) state.worktreeBranch = newWorkspace.branch - logger.log(`Loop: re-provisioned workspace ${newWorkspace.workspaceId} for ${loopName} after stale id`) - return { workspaceId: newWorkspace.workspaceId, recovered: true } - } catch (err) { - logger.error(`Loop: failed to bind session to re-provisioned workspace ${newWorkspace.workspaceId}`, err) - return { recovered: false } - } - } - - async function ensureWorkspaceForLoop( - loopName: string, - state: LoopState, - contextLabel: string, - ): Promise<{ workspaceId?: string }> { - if (state.workspaceId) { - return { workspaceId: state.workspaceId } - } - - if (!state.worktree) { - return {} - } - - const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const projectDirectory = state.projectDir ?? state.worktreeDir - if (!projectDirectory) { - logger.log(`Loop: cannot provision workspace for ${loopName} (${contextLabel}): no projectDir/worktreeDir`) - return {} - } - const workspace = await createBuiltinWorktreeWorkspace( - client, - { - loopName, - directory: projectDirectory, - }, - logger, - ) - - if (!workspace) { - logger.log(`Loop: workspace creation failed for ${loopName} (${contextLabel}), continuing without workspace backing`) - return {} - } - - loopService.setWorkspaceId(loopName, workspace.workspaceId) - state.workspaceId = workspace.workspaceId - if (workspace.directory) state.worktreeDir = workspace.directory - if (workspace.branch) state.worktreeBranch = workspace.branch - logger.log(`Loop: provisioned workspace ${workspace.workspaceId} for ${loopName} (${contextLabel})`) - return { workspaceId: workspace.workspaceId } - } + const { detachFromWorkspace, recoverFromMissingWorkspace, ensureWorkspaceForLoop } = createWorkspaceLifecycle({ client, logger, loopService }) /** * Rotates to a new session in the same workspace. Creates and binds the new session FIRST, @@ -1983,54 +1642,6 @@ export function createLoop(deps: LoopRuntimeDeps): Loop { restart, generateUniqueLoopName, setPhase, - - // State management methods (delegated from loopService) - resolveLoopName: (sessionId: string) => loopService.resolveLoopName(sessionId), - getActiveState: (name: string) => loopService.getActiveState(name), - getAnyState: (name: string) => loopService.getAnyState(name), - setState: (name: string, state: LoopState) => loopService.setState(name, state), - deleteState: (name: string) => loopService.deleteState(name), - registerLoopSession: (sessionId: string, loopName: string) => loopService.registerLoopSession(sessionId, loopName), - replaceSession: (name: string, opts: { newSessionId: string; phase: LoopState['phase']; iteration?: number; resetError?: boolean; auditCount?: number; lastAuditResult?: string | null }) => loopService.replaceSession(name, opts), - setStatus: (name: string, status: 'running' | 'completed' | 'cancelled' | 'errored' | 'stalled') => loopService.setStatus(name, status), - setPhaseAndResetError: (name: string, phase: LoopState['phase']) => loopService.setPhaseAndResetError(name, phase), - setModelFailed: (name: string, failed: boolean) => loopService.setModelFailed(name, failed), - setLastAuditResult: (name: string, text: string) => loopService.setLastAuditResult(name, text), - clearLastAuditResult: (name: string) => loopService.clearLastAuditResult(name), - setSandboxContainer: (name: string, containerName: string | null) => loopService.setSandboxContainer(name, containerName), - clearWorkspaceId: (name: string) => loopService.clearWorkspaceId(name), - setWorkspaceId: (name: string, workspaceId: string) => loopService.setWorkspaceId(name, workspaceId), - incrementError: (name: string) => loopService.incrementError(name), - resetError: (name: string) => loopService.resetError(name), - terminateLoop: (name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }) => loopService.terminate(name, opts), - getOutstandingFindings: (loopName?: string, severity?: 'bug' | 'warning') => loopService.getOutstandingFindings(loopName, severity), - bumpFindingRecurrence: (name: string, findings: ReviewFindingRow[]) => loopService.bumpFindingRecurrence(name, findings), - resetSectionRecurrence: (name: string, sectionIndex: number) => loopService.resetSectionRecurrence(name, sectionIndex), - getStallTimeoutMs: () => loopService.getStallTimeoutMs(), - getMaxConsecutiveStalls: () => loopService.getMaxConsecutiveStalls(), - - // Prompt building methods (delegated from loopService) - buildContinuationPrompt: (state: LoopState, auditFindings?: string) => loopService.buildContinuationPrompt(state, auditFindings), - buildAuditPrompt: (state: LoopState) => loopService.buildAuditPrompt(state), - buildSectionInitialPrompt: (state: LoopState) => loopService.buildSectionInitialPrompt(state), - buildSectionAuditPrompt: (state: LoopState) => loopService.buildSectionAuditPrompt(state), - buildSectionContinuationPrompt: (state: LoopState, auditText: string) => loopService.buildSectionContinuationPrompt(state, auditText), - buildFinalAuditPrompt: (state: LoopState) => loopService.buildFinalAuditPrompt(state), - buildFinalAuditFixPrompt: (state: LoopState, auditText: string) => loopService.buildFinalAuditFixPrompt(state, auditText), - - // Plan and section methods (delegated from loopService) - getPlanText: (loopName: string, sessionId: string) => loopService.getPlanText(loopName, sessionId), - getSectionPlan: (state: LoopState, index: number) => loopService.getSectionPlan(state, index), - getNextIncompleteSectionPlan: (state: LoopState) => loopService.getNextIncompleteSectionPlan(state), - getCompletedSectionDigest: (state: LoopState) => loopService.getCompletedSectionDigest(state), - parseSectionSummary: (text: string) => loopService.parseSectionSummary(text), - completeSection: (loopName: string, index: number, summary: { done: string | null; deviations: string | null; followUps: string | null }) => loopService.completeSection(loopName, index, summary), - incrementSectionAttempts: (loopName: string, index: number) => loopService.incrementSectionAttempts(loopName, index), - resetSectionForRewind: (loopName: string, index: number) => loopService.resetSectionForRewind(loopName, index), - setCurrentSectionIndex: (loopName: string, index: number) => loopService.setCurrentSectionIndex(loopName, index), - setFinalAuditDone: (loopName: string, done: boolean) => loopService.setFinalAuditDone(loopName, done), - startSection: (loopName: string, index: number) => loopService.startSection(loopName, index), - bulkInsertSections: (loopName: string, sections: { index: number; title: string; content: string }[]) => loopService.bulkInsertSections(loopName, sections), - setTotalSections: (loopName: string, total: number) => loopService.setTotalSections(loopName, total), + service: loopService, } } diff --git a/src/loop/service.ts b/src/loop/service.ts index f090fe4e8..3a71b261b 100644 --- a/src/loop/service.ts +++ b/src/loop/service.ts @@ -4,6 +4,7 @@ import type { PlansRepo } from '../storage/repos/plans-repo' import type { ReviewFindingsRepo, ReviewFindingRow } from '../storage/repos/review-findings-repo' import type { SectionPlansRepo, SectionPlanRow } from '../storage/repos/section-plans-repo' import type { LoopState } from './state' +import { loopRowToState, loopStateToRow } from './state' import { buildContinuationPrompt as _buildContinuationPrompt, buildAuditPrompt as _buildAuditPrompt, @@ -86,41 +87,6 @@ export interface LoopService { setTotalSections(loopName: string, total: number): void } -function rowToLoopState(row: LoopRow, large: LoopLargeFields | null): LoopState { - return { - active: row.status === 'running', - sessionId: row.currentSessionId, - loopName: row.loopName, - worktreeDir: row.worktreeDir, - projectDir: row.projectDir, - worktreeBranch: row.worktreeBranch ?? undefined, - iteration: row.iteration, - maxIterations: row.maxIterations, - startedAt: new Date(row.startedAt).toISOString(), - phase: row.phase, - lastAuditResult: large?.lastAuditResult ?? undefined, - errorCount: row.errorCount, - auditCount: row.auditCount, - status: row.status, - terminationReason: row.terminationReason ?? undefined, - completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, - worktree: row.worktree, - modelFailed: row.modelFailed, - sandbox: row.sandbox, - sandboxContainer: row.sandboxContainer ?? undefined, - completionSummary: row.completionSummary ?? undefined, - executionModel: row.executionModel ?? undefined, - auditorModel: row.auditorModel ?? undefined, - workspaceId: row.workspaceId ?? undefined, - hostSessionId: row.hostSessionId ?? undefined, - currentSectionIndex: row.currentSectionIndex, - totalSections: row.totalSections, - finalAuditDone: row.finalAuditDone === 1, - executionVariant: row.executionVariant ?? undefined, - auditorVariant: row.auditorVariant ?? undefined, - } -} - export function createLoopService( loopsRepo: LoopsRepo, plansRepo: PlansRepo, @@ -135,40 +101,6 @@ export function createLoopService( const coderDecisionsByLoop = new Map() const findingRecurrenceByLoop = new Map>() - function stateToRow(state: LoopState): LoopRow { - return { - projectId, - loopName: state.loopName, - status: state.status, - currentSessionId: state.sessionId, - worktree: state.worktree ?? false, - worktreeDir: state.worktreeDir, - worktreeBranch: state.worktreeBranch ?? null, - projectDir: state.projectDir ?? state.worktreeDir, - maxIterations: state.maxIterations, - iteration: state.iteration, - auditCount: state.auditCount, - errorCount: state.errorCount, - phase: state.phase, - executionModel: state.executionModel ?? null, - auditorModel: state.auditorModel ?? null, - modelFailed: state.modelFailed ?? false, - sandbox: state.sandbox ?? false, - sandboxContainer: state.sandboxContainer ?? null, - startedAt: new Date(state.startedAt).getTime(), - completedAt: state.completedAt ? new Date(state.completedAt).getTime() : null, - terminationReason: state.terminationReason ?? null, - completionSummary: state.completionSummary ?? null, - workspaceId: state.workspaceId ?? null, - hostSessionId: state.hostSessionId ?? null, - currentSectionIndex: state.currentSectionIndex, - totalSections: state.totalSections, - finalAuditDone: state.finalAuditDone ? 1 : 0, - executionVariant: state.executionVariant ?? null, - auditorVariant: state.auditorVariant ?? null, - } - } - function hydratePlanFromPlans(state: LoopState): LoopState { const planRow = plansRepo.getForLoopOrSession?.(projectId, state.loopName, state.sessionId) ?? null if (planRow) { @@ -181,7 +113,7 @@ export function createLoopService( const row = loopsRepo.get(projectId, name) if (!row) return null const large = loopsRepo.getLarge(projectId, name) - const state = rowToLoopState(row, large) + const state = loopRowToState(row, large) return hydratePlanFromPlans(state) } @@ -197,7 +129,7 @@ export function createLoopService( if (state.loopName !== name) { throw new Error(`setState: name parameter "${name}" does not match state.loopName "${state.loopName}"`) } - const row = stateToRow(state) + const row = loopStateToRow(state, projectId) const large: LoopLargeFields = { lastAuditResult: state.lastAuditResult ?? null, } @@ -297,7 +229,7 @@ export function createLoopService( const rows = loopsRepo.listByStatus(projectId, ['running']) return rows.map((row) => { const large = loopsRepo.getLarge(projectId, row.loopName) - return hydratePlanFromPlans(rowToLoopState(row, large)) + return hydratePlanFromPlans(loopRowToState(row, large)) }) } @@ -305,7 +237,7 @@ export function createLoopService( const rows = loopsRepo.listByStatus(projectId, ['completed', 'cancelled', 'errored', 'stalled']) return rows.map((row) => { const large = loopsRepo.getLarge(projectId, row.loopName) - return hydratePlanFromPlans(rowToLoopState(row, large)) + return hydratePlanFromPlans(loopRowToState(row, large)) }) } @@ -314,13 +246,13 @@ export function createLoopService( const mapResult = (row: LoopRow | null): LoopState | null => { if (!row) return null const large = loopsRepo.getLarge(projectId, row.loopName) - return hydratePlanFromPlans(rowToLoopState(row, large)) + return hydratePlanFromPlans(loopRowToState(row, large)) } return { match: mapResult(result.match), candidates: result.candidates.map((row) => { const large = loopsRepo.getLarge(projectId, row.loopName) - return hydratePlanFromPlans(rowToLoopState(row, large)) + return hydratePlanFromPlans(loopRowToState(row, large)) }), } } diff --git a/src/loop/token-usage.ts b/src/loop/token-usage.ts index 688e76427..d55b31580 100644 --- a/src/loop/token-usage.ts +++ b/src/loop/token-usage.ts @@ -34,6 +34,18 @@ export interface UsageAttribution { fallbackModel?: string } +export interface AssistantMessageInfo { + role: string + cost?: number + tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + model?: string + modelID?: string + modelId?: string + provider?: string + providerID?: string + model_name?: string +} + /** Create an empty TokenBreakdown */ export function emptyTokenBreakdown(): TokenBreakdown { return { diff --git a/src/sandbox/docker.ts b/src/sandbox/docker.ts index 75e5a7f4e..8d438a712 100644 --- a/src/sandbox/docker.ts +++ b/src/sandbox/docker.ts @@ -14,6 +14,10 @@ export interface DockerExecResult { exitCode: number } +export interface BuildImageOpts { + timeout?: number +} + export interface CreateContainerOpts { extraMounts?: string[] resources?: SandboxResources @@ -65,7 +69,7 @@ export function buildCreateContainerArgs(name: string, projectDir: string, image export interface DockerService { checkDocker(): Promise imageExists(image: string): Promise - buildImage(dockerfilePath: string, tag: string): Promise + buildImage(contextDir: string, tag: string, opts?: BuildImageOpts): Promise createContainer(name: string, projectDir: string, image: string, opts?: CreateContainerOpts): Promise removeContainer(name: string): Promise exec(name: string, command: string, opts?: DockerExecOpts): Promise @@ -77,6 +81,7 @@ export interface DockerService { export function createDockerService(logger: Logger): DockerService { const DEFAULT_TIMEOUT = 120000 + const BUILD_TIMEOUT = 600000 function containerName(worktreeName: string): string { return `forge-${worktreeName}` @@ -100,27 +105,18 @@ export function createDockerService(logger: Logger): DockerService { } } - async function buildImage(dockerfilePath: string, tag: string): Promise { - return new Promise((resolve, reject) => { - const child = spawn('docker', ['build', '-t', tag, dockerfilePath], { - stdio: ['ignore', 'pipe', 'pipe'], - }) + async function buildImage(contextDir: string, tag: string, opts?: BuildImageOpts): Promise { + const timeout = opts?.timeout ?? BUILD_TIMEOUT + const result = await execPromise('docker', ['build', '-t', tag, contextDir], { timeout }) - const stderr: string[] = [] - child.stderr.on('data', (data) => { - stderr.push(data.toString()) - }) + if (result.exitCode === 0) return - child.on('close', (code) => { - if (code === 0) { - resolve() - } else { - reject(new Error(`Docker build failed: ${stderr.join('')}`)) - } - }) + if (result.exitCode === 124) { + throw new Error(`Docker build timed out after ${Math.round(timeout / 1000)} seconds.`) + } - child.on('error', reject) - }) + const output = result.stderr || result.stdout + throw new Error(`Docker build failed: ${output}`) } async function createContainer(name: string, projectDir: string, image: string, opts?: CreateContainerOpts): Promise { diff --git a/src/sandbox/reconcile.ts b/src/sandbox/reconcile.ts index e2dc5b446..557cccba4 100644 --- a/src/sandbox/reconcile.ts +++ b/src/sandbox/reconcile.ts @@ -80,7 +80,7 @@ export async function reconcileSandboxes(deps: ReconcileSandboxesDeps): Promise< // Container is verified running - ensure persisted name matches const active = sandboxManager.getActive(loopName) if (active && state.sandboxContainer !== active.containerName) { - loop.setSandboxContainer(loopName, active.containerName) + loop.service.setSandboxContainer(loopName, active.containerName) const action = state.sandboxContainer ? 'corrected' : 'backfilled' logger.log(`Sandbox reconcile: ${action} container name for ${loopName}`) } @@ -97,7 +97,7 @@ export async function reconcileSandboxes(deps: ReconcileSandboxesDeps): Promise< // Case 3: No container name - start fresh const result = await sandboxManager.start(loopName, state.worktreeDir, state.startedAt) - loop.setSandboxContainer(loopName, result.containerName) + loop.service.setSandboxContainer(loopName, result.containerName) logger.log(`Sandbox reconcile: started container for ${loopName}`) } catch (err) { // Log error but continue processing other loops diff --git a/src/services/execution.ts b/src/services/execution.ts index 9c7c83bcf..d7cffed90 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -26,7 +26,7 @@ import { join } from 'path' import { existsSync } from 'fs' import { applyPlanDecomposition } from './section-bootstrap' import { sendLoopPrompt } from '../loop/send-loop-prompt' -import { markPromptSent, clearPromptPending, terminationStatusFor, parseTerminationReasonString } from '../loop' +import { markPromptSent, clearPromptPending, terminationStatusFor, parseTerminationReasonString, isWorkspaceNotFoundError } from '../loop' import { ConcurrentPromptError } from '../loop/in-flight-guard' import { getRestartability, type RestartBlockedReason } from '../loop/restartability' import { loopBranchExists } from '../workspace/forge-naming' @@ -44,7 +44,7 @@ function isTransientSessionError(err: unknown): boolean { : typeof err === 'string' ? err : (() => { try { return JSON.stringify(err ?? '') } catch { return String(err) } })() - return /Session not found/i.test(msg) || /Workspace not found/i.test(msg) + return /Session not found/i.test(msg) || isWorkspaceNotFoundError(err) } // ============================================================================ @@ -454,7 +454,7 @@ async function resolvePlanSource( } case 'loop-state': { - const planText = deps.loop.getPlanText(source.loopName, ctx.sourceSessionId ?? '') + const planText = deps.loop.service.getPlanText(source.loopName, ctx.sourceSessionId ?? '') if (planText) { return { ok: true, planText } } @@ -634,8 +634,8 @@ export async function attachLoopToSession( finalAuditDone: false, } - deps.loop.setState(loopName, state) - deps.loop.registerLoopSession(sessionId, loopName) + deps.loop.service.setState(loopName, state) + deps.loop.service.registerLoopSession(sessionId, loopName) deps.logger.log(`attachLoopToSession: state stored for loop=${loopName}`) @@ -658,7 +658,7 @@ export async function attachLoopToSession( let promptText: string if (totalSections > 0) { const updatedState = { ...state, phase: 'coding' as const, currentSectionIndex: 0, totalSections } - promptText = deps.loop.buildSectionInitialPrompt(updatedState as import('../loop/state').LoopState) + promptText = deps.loop.service.buildSectionInitialPrompt(updatedState as import('../loop/state').LoopState) } else { promptText = planText } @@ -688,7 +688,7 @@ export async function attachLoopToSession( } catch (cleanupErr) { deps.logger.error('attachLoopToSession: failed to remove sandbox container after timeout', cleanupErr) } - deps.loop.deleteState(loopName) + deps.loop.service.deleteState(loopName) return { ok: false, code: 'internal_error', message: `Sandbox not ready: ${waitResult.reason}` } } @@ -747,7 +747,7 @@ export async function attachLoopToSession( if (promptResult.result.error) { deps.logger.error('attachLoopToSession: failed to send prompt', promptResult.result.error) - deps.loop.deleteState(loopName) + deps.loop.service.deleteState(loopName) return { ok: false, code: 'prompt_failed', message: 'Loop session created but failed to send prompt' } } @@ -780,7 +780,7 @@ export async function attachLoopToSession( const isAlreadyExists = msg.includes('already exists') || msg.includes('UNIQUE constraint failed') deps.logger.error('attachLoopToSession: unexpected error', err) if (!isAlreadyExists) { - deps.loop.deleteState(loopName) + deps.loop.service.deleteState(loopName) } else { deps.logger.log(`attachLoopToSession: preserving existing loop ${loopName} despite collision`) } @@ -1016,7 +1016,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo await deps.client.session.abort({ sessionID: createdSessionId }).catch(() => {}) } if (loopStatePersisted) { - deps.loop.deleteState(uniqueLoopName) + deps.loop.service.deleteState(uniqueLoopName) loopStatePersisted = false } if ((sandboxStarted || sandboxStartAttempted) && deps.sandboxManager) { @@ -1063,14 +1063,15 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo // Create builtin worktree workspace (single call — no separate worktree.create) const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const ws = await createBuiltinWorktreeWorkspace(deps.client, { + const wsResult = await createBuiltinWorktreeWorkspace(deps.client, { loopName: uniqueLoopName, directory: ctx.directory, }, deps.logger, deps.workspaceStatusRegistry) - if (!ws) { - deps.logger.error('handleStartLoop: failed to create builtin worktree workspace') - return fail('internal_error', 500, 'Failed to create worktree workspace') + if (!wsResult.ok) { + deps.logger.error(`handleStartLoop: failed to create builtin worktree workspace (${wsResult.error.reason})`, wsResult.error.cause ?? '') + return fail('internal_error', 500, wsResult.error.message, { reason: wsResult.error.reason }) } + const ws = wsResult.workspace hostWorktreeDir = ws.directory worktreeBranch = ws.branch const workspaceId = ws.workspaceId @@ -1256,23 +1257,28 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const loops: LoopStatusView[] = states.map(state => { const cap200 = (s: string | null | undefined): string | null => s ? (s.length > 200 ? s.slice(0, 200) : s) : null - const sectionViews = state.totalSections > 0 - ? Array.from({ length: state.totalSections }, (_, i) => { - const section = deps.loop.getSectionPlan(state, i) - const digest = deps.loop.getCompletedSectionDigest(state) - const summary = digest?.find(s => s.index === i) - return { - index: i, - title: section?.title ?? `Section ${i + 1}`, - status: section?.status ?? 'pending', - attempts: section?.attempts ?? 0, - startedAt: section?.startedAt, - completedAt: section?.completedAt, - summaryDone: cap200(summary?.summaryDone), - summaryDeviations: cap200(summary?.summaryDeviations), - summaryFollowUps: cap200(summary?.summaryFollowUps), - } - }) + const sectionViews = state.totalSections > 0 + ? (() => { + const digest = deps.loop.service.getCompletedSectionDigest(state) + const sectionByIndex = new Map( + (deps.sectionPlansRepo?.list(deps.projectId, state.loopName) ?? []).map(s => [s.sectionIndex, s] as const), + ) + return Array.from({ length: state.totalSections }, (_, i) => { + const section = sectionByIndex.get(i) + const summary = digest.find(s => s.index === i) + return { + index: i, + title: section?.title ?? `Section ${i + 1}`, + status: section?.status ?? 'pending', + attempts: section?.attempts ?? 0, + startedAt: section?.startedAt, + completedAt: section?.completedAt, + summaryDone: cap200(summary?.summaryDone), + summaryDeviations: cap200(summary?.summaryDeviations), + summaryFollowUps: cap200(summary?.summaryFollowUps), + } + }) + })() : undefined // Fetch cumulative usage from persisted aggregate @@ -1449,7 +1455,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo const outcome = await deps.loopHandler.runExclusive(stoppedState.loopName, async () => { if (stoppedState.active) { - const latestState = deps.loop.getActiveState(stoppedState.loopName) + const latestState = deps.loop.service.getActiveState(stoppedState.loopName) if (latestState?.active) { try { await deps.client.session.abort({ sessionID: latestState.sessionId }) } catch {} await deps.loopHandler!.clearLoopTimers(stoppedState.loopName) @@ -1481,11 +1487,12 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (stoppedState.worktree) { const { createBuiltinWorktreeWorkspace } = await import('../workspace/forge-worktree') - const ws = await createBuiltinWorktreeWorkspace(deps.client, { + const wsResult = await createBuiltinWorktreeWorkspace(deps.client, { loopName: stoppedState.loopName, directory: stoppedState.projectDir || ctx.directory, }, deps.logger, deps.workspaceStatusRegistry) - if (!ws) return { ok: false, error: 'Restart failed: could not create fresh workspace for preserved worktree.' } + if (!wsResult.ok) return { ok: false, error: `Restart failed: ${wsResult.error.message}` } + const ws = wsResult.workspace stoppedState.workspaceId = ws.workspaceId stoppedState.worktreeDir = ws.directory stoppedState.worktreeBranch = ws.branch @@ -1591,9 +1598,9 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo if (stoppedState.totalSections > 0) { // Use persisted section state to build the correct section prompt if (stoppedState.phase === 'final_auditing') { - promptText = deps.loop.buildFinalAuditPrompt(stoppedState) + promptText = deps.loop.service.buildFinalAuditPrompt(stoppedState) } else { - promptText = deps.loop.buildSectionInitialPrompt(stoppedState) + promptText = deps.loop.service.buildSectionInitialPrompt(stoppedState) } } else { // Legacy non-sectioned prompt @@ -1621,7 +1628,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo startedAt: new Date(newState.startedAt).getTime(), }) - deps.loop.registerLoopSession(effectiveSessionId, stoppedState.loopName) + deps.loop.service.registerLoopSession(effectiveSessionId, stoppedState.loopName) const restartVariant = promptAgent === 'auditor-loop' ? stoppedState.auditorVariant @@ -1678,10 +1685,10 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo deps.logger.error('loop-restart: failed to send prompt', promptResult.error) // Save section plans before deleteState (which cascades to section_plans) const savedPlans = deps.sectionPlansRepo?.list(ctx.projectId, stoppedState.loopName) ?? [] - deps.loop.deleteState(stoppedState.loopName) + deps.loop.service.deleteState(stoppedState.loopName) try { - deps.loop.setState(previousState.loopName, previousState) - if (previousState.active) deps.loop.registerLoopSession(previousState.sessionId, previousState.loopName) + deps.loop.service.setState(previousState.loopName, previousState) + if (previousState.active) deps.loop.service.registerLoopSession(previousState.sessionId, previousState.loopName) // Restore section plans after setState if (savedPlans.length > 0) { deps.sectionPlansRepo?.restoreAll(savedPlans) diff --git a/src/services/session-loop-resolver.ts b/src/services/session-loop-resolver.ts index e5c581ed3..3e9972ccf 100644 --- a/src/services/session-loop-resolver.ts +++ b/src/services/session-loop-resolver.ts @@ -1,11 +1,11 @@ import type { Logger } from '../types' +import type { LoopService } from '../loop/service' import { resolve } from 'path' export interface SessionLoopResolverDeps { loop: { - resolveLoopName(sessionId: string): string | null - getActiveState(name: string): { loopName: string; active: boolean; sandbox?: boolean; worktree?: boolean; worktreeDir?: string } | null - listActive?(): Array<{ loopName: string; worktreeDir: string; sandbox?: boolean; worktree?: boolean; active: boolean; workspaceId?: string }> + service: Pick + listActive(): Array<{ loopName: string; worktreeDir: string; sandbox?: boolean; worktree?: boolean; active: boolean; workspaceId?: string }> } getParentSessionId(sessionId: string): Promise getSessionDirectory?(sessionId: string): Promise @@ -25,8 +25,8 @@ export function createSessionLoopResolver(deps: SessionLoopResolverDeps): { } { return { async resolveActiveLoopForSession(sessionId: string): Promise { - const directLoopName = deps.loop.resolveLoopName(sessionId) - const directState = directLoopName ? deps.loop.getActiveState(directLoopName) : null + const directLoopName = deps.loop.service.resolveLoopName(sessionId) + const directState = directLoopName ? deps.loop.service.getActiveState(directLoopName) : null deps.logger.debug( `[session-resolver] session=${sessionId} direct=${directLoopName ?? 'none'} parent=checking active=${directState?.loopName ?? 'none'}`, @@ -41,15 +41,15 @@ export function createSessionLoopResolver(deps: SessionLoopResolverDeps): { ) if (parentId) { - const parentLoopName = deps.loop.resolveLoopName(parentId) - const parentState = parentLoopName ? deps.loop.getActiveState(parentLoopName) : null + const parentLoopName = deps.loop.service.resolveLoopName(parentId) + const parentState = parentLoopName ? deps.loop.service.getActiveState(parentLoopName) : null if (parentState?.active) { deps.logger.log(`[session-resolver] session=${sessionId} resolved via parent=${parentId} loop=${parentState.loopName}`) return parentState } } - if (parentId && deps.getSessionDirectory && deps.loop.listActive) { + if (parentId && deps.getSessionDirectory) { const dir = await deps.getSessionDirectory(sessionId) if (dir) { const normalized = resolve(dir) @@ -57,7 +57,7 @@ export function createSessionLoopResolver(deps: SessionLoopResolverDeps): { if (!state.worktree) continue if (resolve(state.worktreeDir) === normalized) { deps.logger.log(`[session-resolver] session=${sessionId} resolved via directory match loop=${state.loopName}`) - const full = deps.loop.getActiveState(state.loopName) + const full = deps.loop.service.getActiveState(state.loopName) if (full?.active) return full } } diff --git a/src/tools/loop.ts b/src/tools/loop.ts index c822ff27d..9790e75bc 100644 --- a/src/tools/loop.ts +++ b/src/tools/loop.ts @@ -260,7 +260,7 @@ export function createLoopTools(ctx: ToolContext): Record 0 ? `${s.iteration} / ${s.maxIterations}` : `${s.iteration} (unlimited)` // Check if any session registered to this loop is busy (main + child/subagent sessions) const isBusy = Object.entries(statuses).some(([sid, v]) => - ctx.loop.resolveLoopName(sid) === s.loopName && v.type === 'busy', + ctx.loop.service.resolveLoopName(sid) === s.loopName && v.type === 'busy', ) const sessionStatus = isBusy ? 'busy' : (statuses[s.sessionId]?.type ?? 'unavailable') const stallInfo = loopHandler.getStallInfo(s.loopName!) @@ -392,7 +392,7 @@ export function createLoopTools(ctx: ToolContext): Record | undefined // Check if any session registered to this loop is busy (main + child/subagent sessions) const isBusy = Object.entries(statuses ?? {}).some(([sid, s]) => - ctx.loop.resolveLoopName(sid) === state.loopName && s.type === 'busy', + ctx.loop.service.resolveLoopName(sid) === state.loopName && s.type === 'busy', ) if (isBusy) { sessionStatus = 'busy' diff --git a/src/tools/plan-kv.ts b/src/tools/plan-kv.ts index c48530701..4a88c0cac 100644 --- a/src/tools/plan-kv.ts +++ b/src/tools/plan-kv.ts @@ -68,7 +68,7 @@ export function createPlanTools(ctx: ToolContext): Record 0) { row.sectionIndex = loopState.currentSectionIndex } @@ -65,7 +65,7 @@ export function createReviewTools(ctx: ToolContext): Record 0) { if (args.sectionIndex < 0 || args.sectionIndex >= loopState.totalSections) { return `Invalid sectionIndex ${args.sectionIndex}: must be between 0 and ${loopState.totalSections - 1}.` @@ -107,7 +107,7 @@ export function createReviewTools(ctx: ToolContext): Record 0 && loopState.phase !== 'final_auditing') { if (args.crossSection) { // crossSection: return only cross-section findings (sectionIndex === null) @@ -172,7 +172,7 @@ export function createReviewTools(ctx: ToolContext): Record 0 && loopState.phase !== 'final_auditing') { sectionIndex = loopState.currentSectionIndex } diff --git a/src/tools/section-read.ts b/src/tools/section-read.ts index 8577da672..1acece085 100644 --- a/src/tools/section-read.ts +++ b/src/tools/section-read.ts @@ -13,13 +13,13 @@ export function createSectionReadTool(ctx: ToolContext): ReturnType }, execute: async (args, toolCtx) => { const sessionId = toolCtx?.sessionID ?? '' - const loopName = loop.resolveLoopName(sessionId) + const loopName = loop.service.resolveLoopName(sessionId) if (!loopName) { return JSON.stringify({ error: 'Not in a loop session. This tool can only be used within an active loop session.' }) } - const state = loop.getAnyState(loopName) + const state = loop.service.getAnyState(loopName) if (!state) return JSON.stringify({ error: `Loop "${loopName}" not found.` }) if (state.totalSections === 0) { @@ -28,7 +28,7 @@ export function createSectionReadTool(ctx: ToolContext): ReturnType const explicitIndex = args.section_index const selectedSection = explicitIndex === undefined - ? loop.getNextIncompleteSectionPlan(state) + ? loop.service.getNextIncompleteSectionPlan(state) : null const idx = explicitIndex ?? selectedSection?.sectionIndex ?? state.currentSectionIndex @@ -38,10 +38,10 @@ export function createSectionReadTool(ctx: ToolContext): ReturnType const section = explicitIndex === undefined && selectedSection?.sectionIndex === idx ? selectedSection - : loop.getSectionPlan(state, idx) + : loop.service.getSectionPlan(state, idx) if (!section) return JSON.stringify({ error: `Section ${idx} not found in loop "${loopName}".` }) - const digest = loop.getCompletedSectionDigest(state) + const digest = loop.service.getCompletedSectionDigest(state) const summary = digest.find(s => s.index === idx) const result = { diff --git a/src/tui.tsx b/src/tui.tsx index 3e13c6fb1..1cd3db19d 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -2,10 +2,11 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui' import { createEffect, createMemo, createSignal, Show, untrack } from 'solid-js' import { VERSION } from './version' -import { loadPluginConfig } from './setup' +import { loadPluginConfig, resolveBundledContainerDir } from './setup' import type { ExecutionContextCache } from './utils/tui-execution-context-cache' import { createExecutionContextCache } from './utils/tui-execution-context-cache' import type { PluginConfig } from './types' +import { createDockerService } from './sandbox/docker' import { connectForgeProject, type ForgeProjectClient } from './utils/tui-client' import { ExecutePlanPanel } from './tui/execute-plan-panel' import { attachLoopSessionFollower, getCurrentRouteSessionId } from './tui/session-follow' @@ -169,6 +170,88 @@ function ExecutionDialog(props: { ) } +function SandboxBuildDialog(props: { + api: TuiPluginApi + buildContextDir: string + image: string +}) { + const theme = () => props.api.theme.current + + const doBuild = async () => { + props.api.ui.dialog.clear() + props.api.ui.toast({ message: `Building sandbox image ${props.image}...`, variant: 'info', duration: 5000 }) + + const logger = { log: () => {}, error: () => {}, debug: () => {} } + const docker = createDockerService(logger) + + try { + await docker.buildImage(props.buildContextDir, props.image) + props.api.ui.toast({ + message: `Sandbox image ${props.image} built successfully`, + variant: 'success', + duration: 5000, + }) + } catch (err) { + const rawMessage = err instanceof Error ? err.message : String(err) + const message = rawMessage.includes('spawn docker ENOENT') + ? 'Docker CLI not found. Is Docker installed and running?' + : rawMessage.split('\n').filter(Boolean).at(-1)?.trim() || rawMessage.slice(0, 200) + props.api.ui.toast({ message, variant: 'error', duration: 10_000 }) + } + } + + return ( + + + + Build sandbox Docker image + + + + + + This will build the sandbox image from the bundled Dockerfile. + + + + Image: {props.image} + + + Context: {props.buildContextDir} + + + +