From d55ad7884c4071d8c78637e16d08331bab722b7b Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:30:50 -0400 Subject: [PATCH 1/6] refactor: extract loop service seam by splitting runtime into dedicated modules --- src/hooks/loop.ts | 2 +- src/hooks/plan-approval.ts | 4 +- src/loop/runtime-prompt.ts | 136 ++++++ src/loop/runtime-usage.ts | 123 +++++ src/loop/runtime-workspace.ts | 152 +++++++ src/loop/runtime.ts | 427 +----------------- src/loop/service.ts | 82 +--- src/sandbox/reconcile.ts | 4 +- src/services/execution.ts | 34 +- src/services/session-loop-resolver.ts | 16 +- src/tools/loop.ts | 4 +- src/tools/plan-kv.ts | 2 +- src/tools/review.ts | 8 +- src/tools/section-read.ts | 10 +- test/hooks/plan-approval-dedupe.test.ts | 2 +- .../plan-approval-worktree-timing.test.ts | 6 +- test/loop-service-notify.test.ts | 32 +- test/loop-service.test.ts | 90 ++-- test/loop/runtime-service-seam.test.ts | 152 +++++++ test/loop/runtime.test.ts | 2 +- test/plan-approval.test.ts | 88 ++-- test/plan-kv.test.ts | 14 +- test/review.test.ts | 4 +- test/sandbox/reconcile.test.ts | 2 +- test/services/attach-loop.test.ts | 35 +- .../services/execution-attach-cleanup.test.ts | 7 +- .../execution-in-flight-guard.test.ts | 24 +- test/services/execution-restart.test.ts | 56 ++- test/services/execution.start-loop.test.ts | 84 +++- test/session-loop-resolver.test.ts | 94 ++-- test/tools/review-section-scope.test.ts | 4 +- test/tools/section-read.test.ts | 2 +- 32 files changed, 988 insertions(+), 714 deletions(-) create mode 100644 src/loop/runtime-prompt.ts create mode 100644 src/loop/runtime-usage.ts create mode 100644 src/loop/runtime-workspace.ts create mode 100644 test/loop/runtime-service-seam.test.ts diff --git a/src/hooks/loop.ts b/src/hooks/loop.ts index 62ab19c7dc..c19d9999a6 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 dc1136210a..c77d5000fd 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 } diff --git a/src/loop/runtime-prompt.ts b/src/loop/runtime-prompt.ts new file mode 100644 index 0000000000..010075e526 --- /dev/null +++ b/src/loop/runtime-prompt.ts @@ -0,0 +1,136 @@ +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 PromptDispatch { + 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 }> + + 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: { + 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' } + } + } + + return { + sendPromptWithFallback, + getLastAssistantInfo, + } +} diff --git a/src/loop/runtime-usage.ts b/src/loop/runtime-usage.ts new file mode 100644 index 0000000000..9d6eb347e7 --- /dev/null +++ b/src/loop/runtime-usage.ts @@ -0,0 +1,123 @@ +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 } 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: { + 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) + } + } + + return { + getFallbackModelForSession, + captureLoopSessionUsage, + } +} diff --git a/src/loop/runtime-workspace.ts b/src/loop/runtime-workspace.ts new file mode 100644 index 0000000000..b1ee2aa0cb --- /dev/null +++ b/src/loop/runtime-workspace.ts @@ -0,0 +1,152 @@ +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 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 } + } + + return { + detachFromWorkspace, + recoverFromMissingWorkspace, + ensureWorkspaceForLoop, + } +} diff --git a/src/loop/runtime.ts b/src/loop/runtime.ts index 2c299e50d1..68397957c7 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 f090fe4e89..3a71b261be 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/sandbox/reconcile.ts b/src/sandbox/reconcile.ts index e2dc5b446e..557cccba4c 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 9c7c83bcfe..e762155f6d 100644 --- a/src/services/execution.ts +++ b/src/services/execution.ts @@ -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) { @@ -1258,8 +1258,8 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo 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 section = deps.loop.service.getSectionPlan(state, i) + const digest = deps.loop.service.getCompletedSectionDigest(state) const summary = digest?.find(s => s.index === i) return { index: i, @@ -1449,7 +1449,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) @@ -1591,9 +1591,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 +1621,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 +1678,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 e5c581ed3d..35b82d56ed 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,8 +41,8 @@ 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 @@ -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 c822ff27d9..9790e75bc4 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 c485307014..4a88c0cac8 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 8577da672e..1acece0852 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/test/hooks/plan-approval-dedupe.test.ts b/test/hooks/plan-approval-dedupe.test.ts index 338ac788e2..4cd35e4663 100644 --- a/test/hooks/plan-approval-dedupe.test.ts +++ b/test/hooks/plan-approval-dedupe.test.ts @@ -59,7 +59,7 @@ function buildToolContext(opts: { db: undefined, dataDir: '/tmp', loopHandler: undefined, - loop: { resolveLoopName: () => null, getActiveState: () => null }, + loop: { service: { resolveLoopName: () => null, getActiveState: () => null } }, cleanup: async () => {}, sandboxManager: null, reviewFindingsRepo: {}, diff --git a/test/hooks/plan-approval-worktree-timing.test.ts b/test/hooks/plan-approval-worktree-timing.test.ts index ace238e668..73d3e99bf6 100644 --- a/test/hooks/plan-approval-worktree-timing.test.ts +++ b/test/hooks/plan-approval-worktree-timing.test.ts @@ -81,8 +81,10 @@ function buildToolContext(opts: { dataDir: '/tmp', loopHandler: undefined, loop: { - resolveLoopName: () => null, - getActiveState: () => null, + service: { + resolveLoopName: () => null, + getActiveState: () => null, + }, generateUniqueLoopName: (name: string) => `${name}-unique`, }, cleanup: async () => {}, diff --git a/test/loop-service-notify.test.ts b/test/loop-service-notify.test.ts index 8091652da1..313d1feea9 100644 --- a/test/loop-service-notify.test.ts +++ b/test/loop-service-notify.test.ts @@ -84,7 +84,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.setState('test-loop', baseState as any) + loop.service.setState('test-loop', baseState as any) expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('insert') @@ -112,7 +112,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.deleteState('test-loop') + loop.service.deleteState('test-loop') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('delete') @@ -171,7 +171,7 @@ describe('LoopChangeNotifier', () => { notify: () => {}, }) - loop.deleteState('test-loop') + loop.service.deleteState('test-loop') expect(deletedPlans.length).toBe(1) expect(deletedPlans[0].projectId).toBe('proj-test') @@ -199,7 +199,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.setStatus('test-loop', 'running') + loop.service.setStatus('test-loop', 'running') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('status') @@ -227,7 +227,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.registerLoopSession('s1', 'test-loop') + loop.service.registerLoopSession('s1', 'test-loop') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('session') @@ -255,7 +255,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.incrementError('test-loop') + loop.service.incrementError('test-loop') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('error') @@ -283,7 +283,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.resetError('test-loop') + loop.service.resetError('test-loop') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('error') @@ -339,7 +339,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.setPhaseAndResetError('test-loop', 'auditing') + loop.service.setPhaseAndResetError('test-loop', 'auditing') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('phase') @@ -367,7 +367,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.setModelFailed('test-loop', true) + loop.service.setModelFailed('test-loop', true) expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('model-failed') @@ -395,7 +395,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.setLastAuditResult('test-loop', 'audit result text') + loop.service.setLastAuditResult('test-loop', 'audit result text') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('audit-result') @@ -425,7 +425,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.replaceSession('test-loop', { newSessionId: 's5', phase: 'auditing' }) + loop.service.replaceSession('test-loop', { newSessionId: 's5', phase: 'auditing' }) expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('rotate') @@ -453,7 +453,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.terminateLoop('test-loop', { status: 'completed', reason: 'done', completedAt: Date.now() }) + loop.service.terminate('test-loop', { status: 'completed', reason: 'done', completedAt: Date.now() }) expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('terminate') @@ -481,7 +481,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.setSandboxContainer('test-loop', 'container-123') + loop.service.setSandboxContainer('test-loop', 'container-123') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('sandbox') @@ -509,7 +509,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.clearWorkspaceId('test-loop') + loop.service.clearWorkspaceId('test-loop') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('workspace') @@ -537,7 +537,7 @@ describe('LoopChangeNotifier', () => { notify, }) - loop.setWorkspaceId('test-loop', 'ws-123') + loop.service.setWorkspaceId('test-loop', 'ws-123') expect(notifyCalls.length).toBe(1) expect(notifyCalls[0].reason).toBe('workspace') @@ -628,7 +628,7 @@ describe('LoopChangeNotifier', () => { // Should not throw expect(() => { - loop.setState('test-loop', baseState as any) + loop.service.setState('test-loop', baseState as any) }).not.toThrow() }) }) diff --git a/test/loop-service.test.ts b/test/loop-service.test.ts index 54ed169348..a04975a82f 100644 --- a/test/loop-service.test.ts +++ b/test/loop-service.test.ts @@ -109,7 +109,7 @@ describe('Loop', () => { }) expect(loop.hasOutstandingFindings('b2', 'bug')).toBe(true) - const bugFindings = loop.getOutstandingFindings('b2', 'bug') + const bugFindings = loop.service.getOutstandingFindings('b2', 'bug') expect(bugFindings.length).toBe(1) expect(bugFindings[0].severity).toBe('bug') }) @@ -137,10 +137,10 @@ describe('Loop', () => { loopName: 'b2', }) - const allFindings = loop.getOutstandingFindings('b2') + const allFindings = loop.service.getOutstandingFindings('b2') expect(allFindings.length).toBe(2) - const warningFindings = loop.getOutstandingFindings('b2', 'warning') + const warningFindings = loop.service.getOutstandingFindings('b2', 'warning') expect(warningFindings.length).toBe(1) expect(warningFindings[0].severity).toBe('warning') }) @@ -163,7 +163,7 @@ describe('Loop', () => { auditCount: 0, } - const prompt = loop.buildAuditPrompt(state as any) + const prompt = loop.service.buildAuditPrompt(state as any) expect(prompt).toContain('Plan completeness check:') expect(prompt).toContain('severity: "bug"') @@ -207,7 +207,7 @@ describe('Loop', () => { auditCount: 0, } - const prompt = loop.buildContinuationPrompt(state as any) + const prompt = loop.service.buildContinuationPrompt(state as any) expect(prompt).toContain('Outstanding Review Findings') expect(prompt).toContain('test.ts:1') @@ -229,7 +229,7 @@ describe('Loop', () => { auditCount: 1, } - const prompt = loop.buildContinuationPrompt(state as any, 'audit findings text') + const prompt = loop.service.buildContinuationPrompt(state as any, 'audit findings text') expect(prompt).not.toContain('ORIGINAL_PLAN_BODY_SHOULD_NOT_APPEAR') expect(prompt).toContain('audit findings text') @@ -244,12 +244,12 @@ describe('Loop', () => { projectId, file: 'src/bug.ts', line: 10, severity: 'bug', description: 'Bug', scenario: 'test', loopName: 'test-loop', }) - const findings = loop.getOutstandingFindings('test-loop', 'bug') + const findings = loop.service.getOutstandingFindings('test-loop', 'bug') expect(findings.length).toBe(1) // First bump → count=1 - loop.bumpFindingRecurrence('test-loop', findings) - const prompt1 = loop.buildContinuationPrompt({ + loop.service.bumpFindingRecurrence('test-loop', findings) + const prompt1 = loop.service.buildContinuationPrompt({ active: true, sessionId: 's1', loopName: 'test-loop', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 1, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'coding', errorCount: 0, auditCount: 0, @@ -260,8 +260,8 @@ describe('Loop', () => { expect(prompt1).not.toContain('Recurring findings — re-evaluate') // Second bump → count=2 - loop.bumpFindingRecurrence('test-loop', findings) - const prompt2 = loop.buildContinuationPrompt({ + loop.service.bumpFindingRecurrence('test-loop', findings) + const prompt2 = loop.service.buildContinuationPrompt({ active: true, sessionId: 's1', loopName: 'test-loop', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 2, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'coding', errorCount: 0, auditCount: 0, @@ -271,8 +271,8 @@ describe('Loop', () => { expect(prompt2).not.toContain('Recurring blocking findings') // Third bump → count=3 (threshold reached) - loop.bumpFindingRecurrence('test-loop', findings) - const prompt3 = loop.buildContinuationPrompt({ + loop.service.bumpFindingRecurrence('test-loop', findings) + const prompt3 = loop.service.buildContinuationPrompt({ active: true, sessionId: 's1', loopName: 'test-loop', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 3, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'coding', errorCount: 0, auditCount: 0, @@ -283,7 +283,7 @@ describe('Loop', () => { expect(prompt3).toContain('recurred 3×') // Also surfaces in audit prompt - const auditPrompt = loop.buildAuditPrompt({ + const auditPrompt = loop.service.buildAuditPrompt({ active: true, sessionId: 's1', loopName: 'test-loop', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 3, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'coding', errorCount: 0, auditCount: 0, @@ -301,28 +301,28 @@ describe('Loop', () => { projectId, file: 'src/bug.ts', line: 10, severity: 'bug', description: 'Bug', scenario: 'test', loopName: 'test-loop-2', }) - const findings1 = loop.getOutstandingFindings('test-loop-2', 'bug') - loop.bumpFindingRecurrence('test-loop-2', findings1) // count=1 - loop.bumpFindingRecurrence('test-loop-2', findings1) // count=2 + const findings1 = loop.service.getOutstandingFindings('test-loop-2', 'bug') + loop.service.bumpFindingRecurrence('test-loop-2', findings1) // count=1 + loop.service.bumpFindingRecurrence('test-loop-2', findings1) // count=2 // Now resolve the finding (remove it) reviewFindingsRepo.delete(projectId, 'src/bug.ts', 10, { loopName: 'test-loop-2' }) - const findings2 = loop.getOutstandingFindings('test-loop-2', 'bug') + const findings2 = loop.service.getOutstandingFindings('test-loop-2', 'bug') expect(findings2.length).toBe(0) // Bump with empty list — should reset - loop.bumpFindingRecurrence('test-loop-2', findings2) + loop.service.bumpFindingRecurrence('test-loop-2', findings2) // Re-add the same finding reviewFindingsRepo.write({ projectId, file: 'src/bug.ts', line: 10, severity: 'bug', description: 'Bug', scenario: 'test', loopName: 'test-loop-2', }) - const findings3 = loop.getOutstandingFindings('test-loop-2', 'bug') - loop.bumpFindingRecurrence('test-loop-2', findings3) // should start at 1 again + const findings3 = loop.service.getOutstandingFindings('test-loop-2', 'bug') + loop.service.bumpFindingRecurrence('test-loop-2', findings3) // should start at 1 again - const prompt = loop.buildContinuationPrompt({ + const prompt = loop.service.buildContinuationPrompt({ active: true, sessionId: 's1', loopName: 'test-loop-2', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 4, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'coding', errorCount: 0, auditCount: 0, @@ -339,7 +339,7 @@ describe('Loop', () => { projectId, file: 'src/final-bug.ts', line: 42, severity: 'bug', description: 'Final audit bug', scenario: 'test', loopName: 'test-loop-final', }) - const findings = loop.getOutstandingFindings('test-loop-final', 'bug') + const findings = loop.service.getOutstandingFindings('test-loop-final', 'bug') expect(findings.length).toBe(1) const finalAuditState = { @@ -350,24 +350,24 @@ describe('Loop', () => { } as any // Bump once — below threshold, no escalation - loop.bumpFindingRecurrence('test-loop-final', findings) - const fixPrompt1 = loop.buildFinalAuditFixPrompt(finalAuditState, 'final audit feedback') + loop.service.bumpFindingRecurrence('test-loop-final', findings) + const fixPrompt1 = loop.service.buildFinalAuditFixPrompt(finalAuditState, 'final audit feedback') expect(fixPrompt1).not.toContain('Recurring blocking findings') // Bump twice — still below threshold - loop.bumpFindingRecurrence('test-loop-final', findings) - const fixPrompt2 = loop.buildFinalAuditFixPrompt(finalAuditState, 'final audit feedback') + loop.service.bumpFindingRecurrence('test-loop-final', findings) + const fixPrompt2 = loop.service.buildFinalAuditFixPrompt(finalAuditState, 'final audit feedback') expect(fixPrompt2).not.toContain('Recurring blocking findings') // Bump third time — threshold reached, escalation appears - loop.bumpFindingRecurrence('test-loop-final', findings) - const fixPrompt3 = loop.buildFinalAuditFixPrompt(finalAuditState, 'final audit feedback') + loop.service.bumpFindingRecurrence('test-loop-final', findings) + const fixPrompt3 = loop.service.buildFinalAuditFixPrompt(finalAuditState, 'final audit feedback') expect(fixPrompt3).toContain('Recurring blocking findings') expect(fixPrompt3).toContain('src/final-bug.ts:42') expect(fixPrompt3).toContain('recurred 3×') // Also surfaces in the final-audit prompt - const auditPrompt = loop.buildFinalAuditPrompt(finalAuditState) + const auditPrompt = loop.service.buildFinalAuditPrompt(finalAuditState) expect(auditPrompt).toContain('Recurring findings — re-evaluate') expect(auditPrompt).toContain('src/final-bug.ts:42') }) @@ -385,20 +385,20 @@ describe('Loop', () => { projectId, file: 'src/section1.ts', line: 2, severity: 'bug', description: 'Bug section 1', scenario: 'test', loopName: 'reset-test', sectionIndex: 1, }) - const allBugs = loop.getOutstandingFindings('reset-test', 'bug') + const allBugs = loop.service.getOutstandingFindings('reset-test', 'bug') // Bump all bugs together → both keys coexist in the map at count=2 - loop.bumpFindingRecurrence('reset-test', allBugs) // s0:1, s1:1 - loop.bumpFindingRecurrence('reset-test', allBugs) // s0:2, s1:2 + loop.service.bumpFindingRecurrence('reset-test', allBugs) // s0:1, s1:1 + loop.service.bumpFindingRecurrence('reset-test', allBugs) // s0:2, s1:2 // Reset section 0 — should only remove s0 keys, leaving s1:2 - loop.resetSectionRecurrence('reset-test', 0) + loop.service.resetSectionRecurrence('reset-test', 0) // Bump ALL bugs again: s0 starts fresh (0+1=1), s1 continues (2+1=3) - loop.bumpFindingRecurrence('reset-test', allBugs) + loop.service.bumpFindingRecurrence('reset-test', allBugs) // Check escalation via audit prompt: only s1 (count=3) should surface - const prompt = loop.buildAuditPrompt({ + const prompt = loop.service.buildAuditPrompt({ active: true, sessionId: 's1', loopName: 'reset-test', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 3, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'auditing', errorCount: 0, auditCount: 0, @@ -412,7 +412,7 @@ describe('Loop', () => { expect(prompt).not.toContain('src/section0.ts:1 (') // NOT in recurrence format // Verify the continuation prompt also shows only section1 escalated - const contPrompt = loop.buildContinuationPrompt({ + const contPrompt = loop.service.buildContinuationPrompt({ active: true, sessionId: 's1', loopName: 'reset-test', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 3, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'coding', errorCount: 0, auditCount: 0, @@ -429,19 +429,19 @@ describe('Loop', () => { projectId, file: 'src/bug.ts', line: 10, severity: 'bug', description: 'Bug', scenario: 'test', loopName: 'reset-fresh', sectionIndex: 0, }) - const findings = loop.getOutstandingFindings('reset-fresh', 'bug') + const findings = loop.service.getOutstandingFindings('reset-fresh', 'bug') // Build up recurrence to count 2 - loop.bumpFindingRecurrence('reset-fresh', findings) // count=1 - loop.bumpFindingRecurrence('reset-fresh', findings) // count=2 + loop.service.bumpFindingRecurrence('reset-fresh', findings) // count=1 + loop.service.bumpFindingRecurrence('reset-fresh', findings) // count=2 // Reset section 0 (clean audit) - loop.resetSectionRecurrence('reset-fresh', 0) + loop.service.resetSectionRecurrence('reset-fresh', 0) // Bump again — should start at 1, not 3 - loop.bumpFindingRecurrence('reset-fresh', findings) // count=1 (fresh) + loop.service.bumpFindingRecurrence('reset-fresh', findings) // count=1 (fresh) - const prompt = loop.buildContinuationPrompt({ + const prompt = loop.service.buildContinuationPrompt({ active: true, sessionId: 's1', loopName: 'reset-fresh', worktreeDir: '/tmp/test', projectDir: '/tmp/test', iteration: 3, maxIterations: 5, startedAt: new Date().toISOString(), phase: 'coding', errorCount: 0, auditCount: 0, @@ -452,11 +452,11 @@ describe('Loop', () => { }) test('resetting nonexistent section does not throw', () => { - expect(() => loop.resetSectionRecurrence('nonexistent-loop', 0)).not.toThrow() + expect(() => loop.service.resetSectionRecurrence('nonexistent-loop', 0)).not.toThrow() }) test('resetting section with no recurrence data does not throw', () => { - expect(() => loop.resetSectionRecurrence('clean-loop', 0)).not.toThrow() + expect(() => loop.service.resetSectionRecurrence('clean-loop', 0)).not.toThrow() }) }) diff --git a/test/loop/runtime-service-seam.test.ts b/test/loop/runtime-service-seam.test.ts new file mode 100644 index 0000000000..91d76d53df --- /dev/null +++ b/test/loop/runtime-service-seam.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi } from 'vitest' +import { createLoop } from '../../src/loop/runtime' +import type { LoopService } from '../../src/loop/service' +import type { ForgeClient } from '../../src/client/port' +import type { Logger } from '../../src/types' + +/** + * Build a minimal fake LoopService that records calls to setState and + * registerLoopSession. All other methods are no-ops that satisfy the + * interface contract at runtime. + */ +function makeFakeLoopService(): LoopService { + return { + getActiveState: vi.fn(() => null), + getAnyState: vi.fn(() => null), + setState: vi.fn(), + deleteState: vi.fn(), + registerLoopSession: vi.fn(), + resolveLoopName: vi.fn(() => null), + buildContinuationPrompt: vi.fn(() => ''), + buildAuditPrompt: vi.fn(() => ''), + listActive: vi.fn(() => []), + listRecent: vi.fn(() => []), + findMatchByName: vi.fn(() => ({ match: null, candidates: [] })), + getStallTimeoutMs: vi.fn(() => 60_000), + getMaxConsecutiveStalls: vi.fn(() => 5), + terminateAll: vi.fn(() => Promise.resolve()), + hasOutstandingFindings: vi.fn(() => false), + getOutstandingFindings: vi.fn(() => []), + setCoderDecisions: vi.fn(), + bumpFindingRecurrence: vi.fn(), + resetSectionRecurrence: vi.fn(), + generateUniqueLoopName: vi.fn(() => ''), + getPlanText: vi.fn(() => null), + incrementError: vi.fn(() => 1), + resetError: vi.fn(), + setPhase: vi.fn(), + setPhaseAndResetError: vi.fn(), + setModelFailed: vi.fn(), + setLastAuditResult: vi.fn(), + clearLastAuditResult: vi.fn(), + setSandboxContainer: vi.fn(), + setStatus: vi.fn(), + clearWorkspaceId: vi.fn(), + setWorkspaceId: vi.fn(), + terminate: vi.fn(), + replaceSession: vi.fn(), + getSectionPlan: vi.fn(() => null), + getNextIncompleteSectionPlan: vi.fn(() => null), + getCompletedSectionDigest: vi.fn(() => []), + parseSectionSummary: vi.fn(() => null), + buildSectionInitialPrompt: vi.fn(() => ''), + buildSectionAuditPrompt: vi.fn(() => ''), + buildSectionContinuationPrompt: vi.fn(() => ''), + buildFinalAuditPrompt: vi.fn(() => ''), + buildFinalAuditFixPrompt: vi.fn(() => ''), + completeSection: vi.fn(), + incrementSectionAttempts: vi.fn(), + resetSectionForRewind: vi.fn(), + setCurrentSectionIndex: vi.fn(), + setFinalAuditDone: vi.fn(), + startSection: vi.fn(), + bulkInsertSections: vi.fn(), + setTotalSections: vi.fn(), + } +} + +function makeFakeForgeClient(): ForgeClient { + return { + session: { + create: vi.fn(async () => ({ id: 'sess' }) as any), + get: vi.fn(async () => ({}) as any), + update: vi.fn(async () => {}), + messages: vi.fn(async () => []), + status: vi.fn(async () => ({}) as any), + list: vi.fn(async () => []), + promptAsync: vi.fn(async () => {}), + abort: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + }, + workspace: { + create: vi.fn(async () => ({ id: 'w' }) as any), + list: vi.fn(async () => []), + status: vi.fn(async () => ({}) as any), + syncList: vi.fn(async () => {}), + remove: vi.fn(async () => {}), + warp: vi.fn(async () => {}), + }, + project: { + list: vi.fn(async () => []), + }, + provider: { + list: vi.fn(async () => ({ all: [], default: {}, connected: [] }) as any), + }, + tui: { + publish: vi.fn(async () => {}), + selectSession: vi.fn(async () => {}), + }, + sync: { + start: vi.fn(async () => {}), + }, + } +} + +const mockLogger: Logger = { + log: vi.fn(), + error: vi.fn(), + debug: vi.fn(), +} + +describe('LoopService seam', () => { + it('accepts an injected LoopService and delegates start calls to it', () => { + const fakeService = makeFakeLoopService() + const client = makeFakeForgeClient() + + const loop = createLoop({ + loopsRepo: {} as any, + plansRepo: {} as any, + reviewFindingsRepo: {} as any, + projectId: 'test-project', + client, + logger: mockLogger, + getConfig: () => ({}) as any, + loopService: fakeService, + }) + + const state = { + active: true, + sessionId: 'sess-1', + loopName: 'test-loop', + worktreeDir: '/tmp/wt', + iteration: 0, + maxIterations: 5, + startedAt: new Date().toISOString(), + errorCount: 0, + auditCount: 0, + status: 'running' as const, + currentSectionIndex: 0, + totalSections: 0, + finalAuditDone: false, + phase: 'coding' as const, + } + + loop.start({ state }) + + expect(fakeService.setState).toHaveBeenCalledTimes(1) + expect(fakeService.setState).toHaveBeenCalledWith('test-loop', state) + + expect(fakeService.registerLoopSession).toHaveBeenCalledTimes(1) + expect(fakeService.registerLoopSession).toHaveBeenCalledWith('sess-1', 'test-loop') + }) +}) diff --git a/test/loop/runtime.test.ts b/test/loop/runtime.test.ts index 1a23d1dc94..e62df5394d 100644 --- a/test/loop/runtime.test.ts +++ b/test/loop/runtime.test.ts @@ -585,7 +585,7 @@ describe('stall handling terminates with stall timeout when configured cap is re }, }) - const afterState = loop.getActiveState(state.loopName) + const afterState = loop.service.getActiveState(state.loopName) expect(afterState).not.toBeNull() expect(afterState!.active).toBe(true) diff --git a/test/plan-approval.test.ts b/test/plan-approval.test.ts index 764e8220d4..043cb34ae0 100644 --- a/test/plan-approval.test.ts +++ b/test/plan-approval.test.ts @@ -316,9 +316,11 @@ describe('Plan Approval Tool Interception', () => { test('Matches metadata answer exactly', async () => { const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: createMockLogger(), client: { @@ -395,9 +397,11 @@ describe('Plan Approval Tool Interception', () => { test('Matches metadata answer by prefix', async () => { const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: createMockLogger(), client: { @@ -474,9 +478,11 @@ describe('Plan Approval Tool Interception', () => { test('Does not match middle-of-string text', async () => { const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: createMockLogger(), client: { @@ -552,9 +558,11 @@ describe('Plan Approval Tool Interception', () => { test('Falls back to output when metadata answers are missing', async () => { const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: createMockLogger(), client: { @@ -632,9 +640,11 @@ describe('Plan Approval Tool Interception', () => { test('Execute here approval schedules source abort and returns without throwing', async () => { const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: createMockLogger(), client: { @@ -711,9 +721,11 @@ describe('Plan Approval Tool Interception', () => { test('New session approval schedules source abort and returns without throwing', async () => { const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: createMockLogger(), client: { @@ -794,7 +806,7 @@ describe('Plan Approval Tool Interception', () => { const loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, createMockLogger()) const ctx = { - loopService, + loop: { service: loopService }, logger: createMockLogger(), client: { session: { @@ -875,7 +887,7 @@ describe('Plan Approval Tool Interception', () => { const uniqueSessionId = `loop-dispatch-${Date.now()}` const ctx = { - loopService, + loop: { service: loopService }, logger: createMockLogger(), client: { session: { @@ -973,9 +985,11 @@ describe('Tool blocking hook', () => { function createContextForLoopState(state: { active: boolean; sessionId: string; phase?: string } | null): ToolContext { return { - loopService: { - resolveLoopName: () => state ? loopName : null, - getActiveState: () => state, + loop: { + service: { + resolveLoopName: () => state ? loopName : null, + getActiveState: () => state, + }, }, logger: createMockLogger(), } as unknown as ToolContext @@ -1113,7 +1127,7 @@ describe('Execute here bypass', () => { config: mockConfig, logger: mockLogger, db, - loopService, + loop: { service: loopService }, plansRepo, loopsRepo, reviewFindingsRepo, @@ -1428,7 +1442,7 @@ describe('Execute here bypass', () => { plansRepo, loopsRepo, reviewFindingsRepo, - loopService, + loop: { service: loopService }, client: { session: { abort: abortSpy, @@ -1527,7 +1541,7 @@ describe('Execute here bypass', () => { config: { executionModel: 'test-provider/test-model' } as PluginConfig, logger: createMockLogger(), plansRepo, - loopService, + loop: { service: loopService }, client: { session: { abort: abortSpy, @@ -1609,7 +1623,7 @@ describe('Execute here bypass', () => { config: { executionModel: 'test-provider/test-model', loop: { defaultMaxIterations: 5 } } as PluginConfig, logger: createMockLogger(), plansRepo, - loopService, + loop: { service: loopService }, loopHandler: { startWatchdog: vi.fn(() => {}), }, @@ -1704,7 +1718,7 @@ describe('Execute here bypass', () => { config: { executionModel: 'test-provider/test-model', loop: { defaultMaxIterations: 5 } } as PluginConfig, logger: createMockLogger(), plansRepo, - loopService, + loop: { service: loopService }, loopHandler: { startWatchdog: vi.fn(() => {}), }, @@ -1879,7 +1893,7 @@ describe('Fire-and-forget dispatch behavior', () => { config: mockConfig, logger: mockLogger, db, - loopService, + loop: { service: loopService }, plansRepo, loopsRepo, reviewFindingsRepo, @@ -2341,9 +2355,11 @@ describe('Fire-and-forget dispatch behavior', () => { const testReviewFindingsRepo = createReviewFindingsRepo(db) const abortSpy = vi.fn(() => Promise.resolve()) const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: createMockLogger(), client: { @@ -2424,9 +2440,11 @@ describe('Fire-and-forget dispatch behavior', () => { const abortSpy = vi.fn(() => Promise.reject(abortError)) const errors: unknown[] = [] const ctx = { - loopService: { - resolveLoopName: () => 'test-loop', - getActiveState: () => null, + loop: { + service: { + resolveLoopName: () => 'test-loop', + getActiveState: () => null, + }, }, logger: { log: () => {}, diff --git a/test/plan-kv.test.ts b/test/plan-kv.test.ts index c63659d890..7036b0b887 100644 --- a/test/plan-kv.test.ts +++ b/test/plan-kv.test.ts @@ -39,7 +39,9 @@ describe('plan-read', () => { logger: mockLogger, loopService: {} as any, loop: { - resolveLoopName: () => null, + service: { + resolveLoopName: () => null, + }, }, directory: TEST_DIR, sessionID: 'test-session', @@ -184,8 +186,10 @@ describe('plan-read with loop session', () => { logger: mockLogger, loopService: {} as any, loop: { - resolveLoopName: (sessionID: string) => - sessionID === 'loop-session-123' ? 'my-loop' : null, + service: { + resolveLoopName: (sessionID: string) => + sessionID === 'loop-session-123' ? 'my-loop' : null, + }, }, directory: TEST_DIR, sessionID: 'test-session', @@ -240,7 +244,9 @@ describe('plan-read with recent plans', () => { logger: mockLogger, loopService: {} as any, loop: { - resolveLoopName: () => null, + service: { + resolveLoopName: () => null, + }, }, directory: TEST_DIR, sessionID: 'test-session', diff --git a/test/review.test.ts b/test/review.test.ts index dec8f7c9ff..5a290c617f 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -28,7 +28,7 @@ function createToolContext(db: Database, reviewFindingsRepo: ReturnType loopService.listActive() }, getParentSessionId: async () => null, getSessionDirectory: async () => TEST_DIR, logger: mockLogger, @@ -39,7 +39,7 @@ function createToolContext(db: Database, reviewFindingsRepo: ReturnType { deps = { sandboxManager: mockSandboxManager as SandboxManager, - loop: mockLoopService as unknown as import('../../src/loop').Loop, + loop: { service: mockLoopService, listActive: mockLoopService.listActive } as unknown as import('../../src/loop').Loop, logger: mockLogger as Logger, } }) diff --git a/test/services/attach-loop.test.ts b/test/services/attach-loop.test.ts index 5e3d06124b..f0d8abc557 100644 --- a/test/services/attach-loop.test.ts +++ b/test/services/attach-loop.test.ts @@ -176,7 +176,12 @@ describe('attachLoopToSession', () => { loopsRepo, reviewFindingsRepo, sectionPlansRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: { runExclusive: async (name: string, fn: () => Promise) => fn(), startWatchdog: vi.fn(() => {}), @@ -224,7 +229,7 @@ describe('attachLoopToSession', () => { expect(result.ok).toBe(true) // Verify loop state was persisted - const state = (deps.loop as any).getActiveState('my-loop') + const state = (deps.loop.service as any).getActiveState('my-loop') expect(state).not.toBeNull() expect(state!.sessionId).toBe('sess_abc') expect(state!.worktreeDir).toBe('/tmp/wt/abc') @@ -313,7 +318,7 @@ describe('attachLoopToSession', () => { } // State should be cleaned up on failure - const state = (deps.loop as any).getActiveState('fail-loop') + const state = (deps.loop.service as any).getActiveState('fail-loop') expect(state).toBeNull() }) @@ -321,10 +326,10 @@ describe('attachLoopToSession', () => { const { deps } = buildDeps() let deleteStateCalled = false - const originalDeleteState = deps.loop.deleteState.bind(deps.loop) - deps.loop.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } + const originalDeleteState = deps.loop.service.deleteState.bind(deps.loop.service) + deps.loop.service.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } - ;(deps.loop as any).setState = vi.fn((...args: any[]) => { + ;(deps.loop.service as any).setState = vi.fn((...args: any[]) => { throw new Error('setState: loop "my-feature" already exists') }) @@ -387,8 +392,8 @@ describe('attachLoopToSession', () => { expect(existingBefore?.status).toBe('cancelled') let deleteStateCalled = false - const originalDeleteState = deps.loop.deleteState.bind(deps.loop) - deps.loop.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } + const originalDeleteState = deps.loop.service.deleteState.bind(deps.loop.service) + deps.loop.service.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } const { attachLoopToSession } = await import('../../src/services/execution') @@ -446,8 +451,8 @@ describe('attachLoopToSession', () => { } as any) let deleteStateCalled = false - const originalDeleteState = deps.loop.deleteState.bind(deps.loop) - deps.loop.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } + const originalDeleteState = deps.loop.service.deleteState.bind(deps.loop.service) + deps.loop.service.deleteState = (...args: any[]) => { deleteStateCalled = true; return originalDeleteState(...args) } const { attachLoopToSession } = await import('../../src/services/execution') @@ -516,7 +521,7 @@ describe('attachLoopToSession', () => { expect(result.ok).toBe(true) - const state = (deps.loop as any).getActiveState('sections-loop') + const state = (deps.loop.service as any).getActiveState('sections-loop') expect(state).not.toBeNull() expect(state!.phase).toBe('coding') expect(state!.currentSectionIndex).toBe(0) @@ -561,7 +566,7 @@ describe('attachLoopToSession', () => { expect(result.ok).toBe(true) - const state = (deps.loop as any).getActiveState('phase-loop') + const state = (deps.loop.service as any).getActiveState('phase-loop') expect(state).not.toBeNull() expect(state!.totalSections).toBe(0) // The prompt sent to the code agent equals the raw plan text (legacy single-prompt mode) @@ -597,7 +602,7 @@ describe('attachLoopToSession', () => { expect(result.ok).toBe(true) - const state = (deps.loop as any).getActiveState('raw-loop') + const state = (deps.loop.service as any).getActiveState('raw-loop') expect(state).not.toBeNull() expect(state!.totalSections).toBe(0) @@ -638,7 +643,7 @@ describe('attachLoopToSession', () => { expect(promptCallArgs.agent).toBe('code') expect(promptCallArgs.sessionID).toBe('sess_nodecomp') - const state = (deps.loop as any).getActiveState('nodecomp-loop') + const state = (deps.loop.service as any).getActiveState('nodecomp-loop') expect(state).not.toBeNull() expect(state!.phase).toBe('coding') }) @@ -706,7 +711,7 @@ describe('attachLoopToSession', () => { expect(result.ok).toBe(true) // Verify loop state was persisted with variants - const state = (deps.loop as any).getActiveState('variant-loop') + const state = (deps.loop.service as any).getActiveState('variant-loop') expect(state).not.toBeNull() expect(state!.sessionId).toBe('sess_variant') expect(state!.executionVariant).toBe('thinking-max') diff --git a/test/services/execution-attach-cleanup.test.ts b/test/services/execution-attach-cleanup.test.ts index 1f02ec6225..1c224afc03 100644 --- a/test/services/execution-attach-cleanup.test.ts +++ b/test/services/execution-attach-cleanup.test.ts @@ -66,7 +66,12 @@ describe('attachLoopToSession', () => { loopsRepo, reviewFindingsRepo, sectionPlansRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...a: any[]) => loopService.listActive(...a as any), + generateUniqueLoopName: (...a: any[]) => loopService.generateUniqueLoopName(...a as any), + findMatchByName: (...a: any[]) => loopService.findMatchByName(...a as any), + } as any, loopHandler: { runExclusive: async (name: string, fn: () => Promise) => fn(), startWatchdog: vi.fn(), diff --git a/test/services/execution-in-flight-guard.test.ts b/test/services/execution-in-flight-guard.test.ts index 2ce0a343a8..e5fd8eb7ba 100644 --- a/test/services/execution-in-flight-guard.test.ts +++ b/test/services/execution-in-flight-guard.test.ts @@ -140,7 +140,13 @@ describe('execution in-flight guard', () => { dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...a: any[]) => loopService.listActive(...a as any), + listRecent: (...a: any[]) => loopService.listRecent(...a as any), + generateUniqueLoopName: (...a: any[]) => loopService.generateUniqueLoopName(...a as any), + findMatchByName: (...a: any[]) => loopService.findMatchByName(...a as any), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry, @@ -253,7 +259,13 @@ describe('execution in-flight guard', () => { dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...a: any[]) => loopService.listActive(...a as any), + listRecent: (...a: any[]) => loopService.listRecent(...a as any), + generateUniqueLoopName: (...a: any[]) => loopService.generateUniqueLoopName(...a as any), + findMatchByName: (...a: any[]) => loopService.findMatchByName(...a as any), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry, @@ -350,7 +362,13 @@ describe('execution in-flight guard', () => { dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...a: any[]) => loopService.listActive(...a as any), + listRecent: (...a: any[]) => loopService.listRecent(...a as any), + generateUniqueLoopName: (...a: any[]) => loopService.generateUniqueLoopName(...a as any), + findMatchByName: (...a: any[]) => loopService.findMatchByName(...a as any), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry, diff --git a/test/services/execution-restart.test.ts b/test/services/execution-restart.test.ts index 06bce17b7a..1d41016e3f 100644 --- a/test/services/execution-restart.test.ts +++ b/test/services/execution-restart.test.ts @@ -209,7 +209,13 @@ describe('handleLoopRestart from stall_timeout', () => { plansRepo, loopsRepo, - loop: mockLoopService as any, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, @@ -318,7 +324,13 @@ describe('handleLoopRestart from stall_timeout', () => { plansRepo, loopsRepo, - loop: mockLoopService as any, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, @@ -407,7 +419,13 @@ describe('handleLoopRestart from stall_timeout', () => { plansRepo, loopsRepo, - loop: mockLoopService as any, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, @@ -514,7 +532,13 @@ describe('handleLoopRestart from stall_timeout', () => { plansRepo, loopsRepo, - loop: mockLoopService as any, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, @@ -613,7 +637,13 @@ describe('handleLoopRestart from stall_timeout', () => { plansRepo, loopsRepo, - loop: mockLoopService as any, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, @@ -710,7 +740,13 @@ describe('handleLoopRestart from stall_timeout', () => { plansRepo, loopsRepo, - loop: mockLoopService as any, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, @@ -913,7 +949,13 @@ describe('handleLoopRestart restartability rules', () => { plansRepo, loopsRepo, - loop: mockLoopService as any, + loop: { + service: mockLoopService, + listActive: (...args: any[]) => (mockLoopService.listActive as any)(...args), + listRecent: (...args: any[]) => (mockLoopService.listRecent as any)(...args), + setPhase: (...args: any[]) => (mockLoopService.setPhase as any)(...args), + generateUniqueLoopName: (...args: any[]) => (mockLoopService.generateUniqueLoopName as any)(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, workspaceStatusRegistry: mockWorkspaceStatusRegistry as any, diff --git a/test/services/execution.start-loop.test.ts b/test/services/execution.start-loop.test.ts index 409fac09e5..ca0ad251e7 100644 --- a/test/services/execution.start-loop.test.ts +++ b/test/services/execution.start-loop.test.ts @@ -128,7 +128,12 @@ describe('handleStartLoop builtin worktree workspace', () => { dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, sandboxManager: mockSandboxManager as any, @@ -230,7 +235,12 @@ describe('handleStartLoop builtin worktree workspace', () => { dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, // No sandboxManager passed — simulates Docker not available @@ -311,7 +321,12 @@ describe('handleStartLoop builtin worktree workspace', () => { dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, sandboxManager: mockSandboxManager as any, @@ -387,7 +402,12 @@ describe('handleStartLoop builtin worktree workspace', () => { dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, sandboxManager: mockSandboxManager as any, @@ -491,7 +511,12 @@ describe('handleStartLoop concurrent-start dedupe', () => { projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, + plansRepo, loopsRepo, loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, client: mocks.client, @@ -547,7 +572,12 @@ describe('handleStartLoop concurrent-start dedupe', () => { projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, + plansRepo, loopsRepo, loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, client: mocks.client, @@ -603,7 +633,12 @@ describe('handleStartLoop concurrent-start dedupe', () => { projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, + plansRepo, loopsRepo, loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, client: mocks.client, @@ -693,7 +728,12 @@ describe('handleStartLoop select-session ordering', () => { projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, + plansRepo, loopsRepo, loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, client: mocks.client, @@ -749,7 +789,12 @@ describe('handleStartLoop select-session ordering', () => { projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, + plansRepo, loopsRepo, loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, client: mocks.client, @@ -801,7 +846,12 @@ describe('handleStartLoop select-session ordering', () => { projectId: PROJECT_ID, directory: '/tmp/test', config: { loop: { enabled: true }, executionModel: 'prov/exec', auditorModel: 'prov/aud' }, logger: mockLogger, dataDir: '/tmp', - plansRepo, loopsRepo, loop: loopService as any, loopHandler: mocks.mockLoopHandler as any, + plansRepo, loopsRepo, loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mocks.mockLoopHandler as any, sectionPlansRepo, sandboxManager: mocks.mockSandboxManager as any, workspaceStatusRegistry: mockWorkspaceStatusRegistry, client: mocks.client, @@ -920,7 +970,12 @@ describe('handleStartLoop selectSessionBestEffort retry on connection errors', ( dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, sandboxManager: mockSandboxManager as any, @@ -1023,7 +1078,12 @@ describe('handleStartLoop selectSessionBestEffort retry on connection errors', ( dataDir: '/tmp', plansRepo, loopsRepo, - loop: loopService as any, + loop: { + service: loopService, + listActive: (...args: any[]) => loopService.listActive(...args), + generateUniqueLoopName: (...args: any[]) => loopService.generateUniqueLoopName(...args), + findMatchByName: (...args: any[]) => loopService.findMatchByName(...args), + } as any, loopHandler: mockLoopHandler as any, sectionPlansRepo, sandboxManager: mockSandboxManager as any, diff --git a/test/session-loop-resolver.test.ts b/test/session-loop-resolver.test.ts index fd06493b9d..2f04ad52cc 100644 --- a/test/session-loop-resolver.test.ts +++ b/test/session-loop-resolver.test.ts @@ -19,7 +19,7 @@ describe('createSessionLoopResolver', () => { } const resolver = createSessionLoopResolver({ - loop: loopService, + loop: { service: loopService }, getParentSessionId, logger: mockLogger, }) @@ -54,7 +54,7 @@ describe('createSessionLoopResolver', () => { } const resolver = createSessionLoopResolver({ - loop: loopService, + loop: { service: loopService }, getParentSessionId, logger: mockLogger, }) @@ -76,7 +76,7 @@ describe('createSessionLoopResolver', () => { } const resolver = createSessionLoopResolver({ - loop: loopService, + loop: { service: loopService }, getParentSessionId, logger: mockLogger, }) @@ -100,7 +100,7 @@ describe('createSessionLoopResolver', () => { } const resolver = createSessionLoopResolver({ - loop: loopService, + loop: { service: loopService }, getParentSessionId, logger: mockLogger, }) @@ -138,7 +138,7 @@ describe('createSessionLoopResolver', () => { } const resolver = createSessionLoopResolver({ - loop: loopService, + loop: { service: loopService }, getParentSessionId, logger: mockLogger, }) @@ -156,22 +156,24 @@ describe('createSessionLoopResolver', () => { return null } - const loopService = { - resolveLoopName: (sessionId: string) => { - if (sessionId === 'session-a') return 'loop-1' - if (sessionId === 'parent-session-1') return 'loop-2' - return null - }, - getActiveState: (name: string) => { - if (name === 'loop-1') return { loopName: 'loop-1', active: false, sandbox: true } - if (name === 'loop-2') return { loopName: 'loop-2', active: false, sandbox: true } - return null + const loop = { + service: { + resolveLoopName: (sessionId: string) => { + if (sessionId === 'session-a') return 'loop-1' + if (sessionId === 'parent-session-1') return 'loop-2' + return null + }, + getActiveState: (name: string) => { + if (name === 'loop-1') return { loopName: 'loop-1', active: false, sandbox: true } + if (name === 'loop-2') return { loopName: 'loop-2', active: false, sandbox: true } + return null + }, }, listActive: () => [], } const resolver = createSessionLoopResolver({ - loop: loopService, + loop, getParentSessionId, logger: mockLogger, }) @@ -185,17 +187,19 @@ describe('createSessionLoopResolver', () => { it('resolves child session when directory matches an active loop worktreeDir', async () => { const getParentSessionId = async (sessionId: string) => sessionId === 'session-subagent' ? 'parent-session' : null - const loopService = { - resolveLoopName: () => null, - getActiveState: (name: string) => - name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + const loop = { + service: { + resolveLoopName: () => null, + getActiveState: (name: string) => + name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + }, listActive: () => [{ loopName: 'active-loop', worktreeDir: '/worktree', sandbox: true, worktree: true, active: true }], } const getSessionDirectory = async (_sessionId: string) => '/worktree' const resolver = createSessionLoopResolver({ - loop: loopService, + loop, getParentSessionId, getSessionDirectory, logger: mockLogger, @@ -208,17 +212,19 @@ describe('createSessionLoopResolver', () => { it('does not resolve a top-level new session by directory alone', async () => { const getParentSessionId = async () => null - const loopService = { - resolveLoopName: () => null, - getActiveState: (name: string) => - name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + const loop = { + service: { + resolveLoopName: () => null, + getActiveState: (name: string) => + name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + }, listActive: () => [{ loopName: 'active-loop', worktreeDir: '/worktree', sandbox: true, worktree: true, active: true }], } const getSessionDirectory = async (_sessionId: string) => '/worktree' const resolver = createSessionLoopResolver({ - loop: loopService, + loop, getParentSessionId, getSessionDirectory, logger: mockLogger, @@ -231,17 +237,19 @@ describe('createSessionLoopResolver', () => { it('directory-fallback: directory does not match any active loop returns null', async () => { const getParentSessionId = async () => null - const loopService = { - resolveLoopName: () => null, - getActiveState: (name: string) => - name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + const loop = { + service: { + resolveLoopName: () => null, + getActiveState: (name: string) => + name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + }, listActive: () => [{ loopName: 'active-loop', worktreeDir: '/worktree', sandbox: true, worktree: true, active: true }], } const getSessionDirectory = async (_sessionId: string) => '/some-other-dir' const resolver = createSessionLoopResolver({ - loop: loopService, + loop, getParentSessionId, getSessionDirectory, logger: mockLogger, @@ -254,15 +262,17 @@ describe('createSessionLoopResolver', () => { it('getSessionDirectory undefined behaves exactly like today', async () => { const getParentSessionId = async () => null - const loopService = { - resolveLoopName: (sessionId: string) => (sessionId === 'session-a' ? 'loop-1' : null), - getActiveState: (name: string) => - name === 'loop-1' ? { loopName: 'loop-1', active: false, sandbox: true } : null, + const loop = { + service: { + resolveLoopName: (sessionId: string) => (sessionId === 'session-a' ? 'loop-1' : null), + getActiveState: (name: string) => + name === 'loop-1' ? { loopName: 'loop-1', active: false, sandbox: true } : null, + }, listActive: () => [{ loopName: 'loop-1', worktreeDir: '/worktree', sandbox: true, active: false }], } const resolver = createSessionLoopResolver({ - loop: loopService, + loop, getParentSessionId, logger: mockLogger, }) @@ -274,17 +284,19 @@ describe('createSessionLoopResolver', () => { it('resolves via directory with path normalization', async () => { const getParentSessionId = async (sessionId: string) => sessionId === 'session-subagent' ? 'parent-session' : null - const loopService = { - resolveLoopName: () => null, - getActiveState: (name: string) => - name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + const loop = { + service: { + resolveLoopName: () => null, + getActiveState: (name: string) => + name === 'active-loop' ? { loopName: 'active-loop', active: true, sandbox: true, worktreeDir: '/worktree' } : null, + }, listActive: () => [{ loopName: 'active-loop', worktreeDir: '/worktree/', sandbox: true, worktree: true, active: true }], } const getSessionDirectory = async (_sessionId: string) => '/worktree' const resolver = createSessionLoopResolver({ - loop: loopService, + loop, getParentSessionId, getSessionDirectory, logger: mockLogger, diff --git a/test/tools/review-section-scope.test.ts b/test/tools/review-section-scope.test.ts index 493db52c7f..97b9dec935 100644 --- a/test/tools/review-section-scope.test.ts +++ b/test/tools/review-section-scope.test.ts @@ -141,7 +141,7 @@ describe('review section scoping', () => { sectionPlansRepo = createSectionPlansRepo(db) loopService = createLoopService(loopsRepo, plansRepo, reviewFindingsRepo, projectId, mockLogger, undefined, undefined, sectionPlansRepo) const sessionLoopResolver = createSessionLoopResolver({ - loop: loopService, + loop: { service: loopService, listActive: () => loopService.listActive() }, getParentSessionId: async (sessionId: string) => parentSessions[sessionId] ?? null, getSessionDirectory: async () => tempDir, logger: mockLogger, @@ -152,7 +152,7 @@ describe('review section scoping', () => { loopsRepo, projectId, logger: mockLogger, - loop: loopService, + loop: { service: loopService }, directory: tempDir, resolveActiveLoopForSession: sessionLoopResolver.resolveActiveLoopForSession, } as any diff --git a/test/tools/section-read.test.ts b/test/tools/section-read.test.ts index 6e4f4af1bf..9dccfd69f7 100644 --- a/test/tools/section-read.test.ts +++ b/test/tools/section-read.test.ts @@ -190,7 +190,7 @@ describe('section-read tool', () => { } async function executeSectionRead(args?: { section_index?: number }, sessionID?: string): Promise { - const tool = createSectionReadTool({ loop: loopService } as any) + const tool = createSectionReadTool({ loop: { service: loopService } } as any) const result = await tool.execute(args ?? {}, makeToolContext(sessionID ?? '')) return typeof result === 'string' ? result : result.output } From 2c25b302fb28dee6d0f16bc641771e260e784ecb Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:52:17 -0400 Subject: [PATCH 2/6] refactor: extract SendPromptInput interface and rename state conversion helpers --- docs/modules.md | 5 ++-- src/loop/runtime-prompt.ts | 27 +++++++----------- src/loop/runtime-usage.ts | 16 ++--------- src/loop/token-usage.ts | 12 ++++++++ src/services/execution.ts | 41 +++++++++++++++------------ src/services/session-loop-resolver.ts | 2 +- 6 files changed, 52 insertions(+), 51 deletions(-) diff --git a/docs/modules.md b/docs/modules.md index 13bed2b5dd..abe1254df5 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/src/loop/runtime-prompt.ts b/src/loop/runtime-prompt.ts index 010075e526..cc26bc6d46 100644 --- a/src/loop/runtime-prompt.ts +++ b/src/loop/runtime-prompt.ts @@ -13,15 +13,17 @@ export interface PromptDispatchDeps { 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: { - 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 }> + 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 }> } @@ -29,14 +31,7 @@ export interface PromptDispatch { export function createPromptDispatch(deps: PromptDispatchDeps): PromptDispatch { const { client, logger, getConfig, loopService } = deps - 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 }> { + async function sendPromptWithFallback(input: SendPromptInput): Promise<{ error?: unknown; usedModel?: { providerID: string; modelID: string } | undefined }> { const { loopName, sessionId, promptText, agent } = input if (agent === 'auditor-loop') { diff --git a/src/loop/runtime-usage.ts b/src/loop/runtime-usage.ts index 9d6eb347e7..352223592c 100644 --- a/src/loop/runtime-usage.ts +++ b/src/loop/runtime-usage.ts @@ -2,7 +2,7 @@ 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 } from './token-usage' +import { summarizeAssistantUsage, type UsageAttribution, type AssistantMessageInfo } from './token-usage' export interface UsageCaptureDeps { client: ForgeClient @@ -67,19 +67,7 @@ export function createUsageCapture(deps: UsageCaptureDeps): UsageCapture { 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 - } - }> + }) as Array<{ info: AssistantMessageInfo }> const attribution: UsageAttribution = { role: input.role, diff --git a/src/loop/token-usage.ts b/src/loop/token-usage.ts index 688e764277..d55b31580e 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/services/execution.ts b/src/services/execution.ts index e762155f6d..400b80e1b4 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) } // ============================================================================ @@ -1256,23 +1256,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.service.getSectionPlan(state, i) + const sectionViews = state.totalSections > 0 + ? (() => { const digest = deps.loop.service.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 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 diff --git a/src/services/session-loop-resolver.ts b/src/services/session-loop-resolver.ts index 35b82d56ed..3e9972ccfa 100644 --- a/src/services/session-loop-resolver.ts +++ b/src/services/session-loop-resolver.ts @@ -49,7 +49,7 @@ export function createSessionLoopResolver(deps: SessionLoopResolverDeps): { } } - if (parentId && deps.getSessionDirectory && deps.loop.listActive) { + if (parentId && deps.getSessionDirectory) { const dir = await deps.getSessionDirectory(sessionId) if (dir) { const normalized = resolve(dir) From 083e9a486ea2b017d2d687360f89077a67a90986 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:28:32 -0400 Subject: [PATCH 3/6] refactor: wire sandbox image build UX through shared Docker service --- README.md | 6 ++- src/index.ts | 28 +++++++++++++ src/sandbox/docker.ts | 34 +++++++--------- src/tui.tsx | 91 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 134 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6dd482e050..5d25e0491f 100644 --- a/README.md +++ b/README.md @@ -563,7 +563,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 +665,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/src/index.ts b/src/index.ts index 5b263b0ecf..663dd2c10d 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/sandbox/docker.ts b/src/sandbox/docker.ts index 75e5a7f4e1..8d438a712d 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/tui.tsx b/src/tui.tsx index 3e13c6fb10..066bf03e68 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,64 @@ 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} + + + + { void doBuild() }}>Build + props.api.ui.dialog.clear()}>Cancel (esc) + + + ) +} + const id = 'oc-forge' const tui: TuiPlugin = async (api) => { @@ -221,6 +280,20 @@ const tui: TuiPlugin = async (api) => { } }) + const runBuildSandboxImage = () => { + const buildContextDir = resolveBundledContainerDir() + const image = pluginConfig.sandbox?.image ?? 'oc-forge-sandbox:latest' + + api.ui.dialog.setSize('medium') + api.ui.dialog.replace(() => ( + + )) + } + api.keymap.registerLayer({ commands: [ { @@ -231,10 +304,20 @@ const tui: TuiPlugin = async (api) => { namespace: 'palette', run: () => { runOpenDashboard() }, }, + { + name: 'forge.sandbox.buildImage', + title: 'Forge: Build sandbox image', + desc: 'Build the Docker sandbox image from the bundled Dockerfile', + category: 'Forge', + namespace: 'palette', + run: () => { runBuildSandboxImage() }, + }, + ], + bindings: [ + ...(opts.keybinds.dashboard + ? [{ key: opts.keybinds.dashboard, cmd: 'forge.dashboard' as const }] + : []), ], - bindings: opts.keybinds.dashboard - ? [{ key: opts.keybinds.dashboard, cmd: 'forge.dashboard' }] - : [], }) if (!opts.sidebar) return From eede34b7e0c3eef96d43ad154bd07626125473f9 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:55:33 -0400 Subject: [PATCH 4/6] docs: mark workspace integration as required; bump to 0.5.0-beta.2 --- README.md | 43 +++++++++++++++++++++++++++------- docs/api/README.md | 43 +++++++++++++++++++++++++++------- package.json | 2 +- src/hooks/host-side-effects.ts | 42 +++++++++++++++++++++++++++++---- src/tui.tsx | 30 +++++++++++++++++++++--- src/version.ts | 2 +- 6 files changed, 135 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5d25e0491f..af1573b732 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`: +**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 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. + +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. 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. diff --git a/docs/api/README.md b/docs/api/README.md index f71edc923a..906c2d26cb 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/package.json b/package.json index 717b4295a8..f1dfc1266d 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 5fb7dca3e8..ba99835a44 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/tui.tsx b/src/tui.tsx index 066bf03e68..1cd3db19d9 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -220,9 +220,33 @@ function SandboxBuildDialog(props: { Context: {props.buildContextDir} - - { void doBuild() }}>Build - props.api.ui.dialog.clear()}>Cancel (esc) + +