Skip to content

Commit 77b6318

Browse files
feat(tui): restart loops from persisted progress with auditor model override
1 parent 15ab7c6 commit 77b6318

26 files changed

Lines changed: 1898 additions & 63 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ node_modules/
66
graph/
77
.forge-tmp/
88
.tmp/
9+
.zvec-grep/

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-forge",
3-
"version": "0.9.3",
3+
"version": "0.9.4",
44
"type": "module",
55
"oc-plugin": [
66
"server",

‎src/index.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ForgeClientError } from './client/port'
55
import { buildAgents } from './agents'
66
import { createConfigHandler } from './config'
77
import { createSessionHooks, createLoopEventHandler } from './hooks'
8-
import { initializeDatabase, resolveDataDir, resolveOpencodeToolOutputDir, closeDatabase, createLoopsRepo, createPlansRepo, createReviewFindingsRepo, createSectionPlansRepo, createLoopSessionUsageRepo, createFeatureGroupsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, createSessionSandboxPreferencesRepo } from './storage'
8+
import { initializeDatabase, resolveDataDir, resolveOpencodeToolOutputDir, closeDatabase, createLoopsRepo, createPlansRepo, createReviewFindingsRepo, createSectionPlansRepo, createLoopSessionUsageRepo, createFeatureGroupsRepo, createLoopTransitionsRepo, createPlanAmendmentsRepo, createSessionSandboxPreferencesRepo, createTuiLoopRestartRepo } from './storage'
99
import type { LoopChangeNotifier } from './loop'
1010
import { loadPluginConfig, resolveBundledContainerDir, resolvePromptsDir } from './setup'
1111
import { resolveLogPath } from './storage'
@@ -49,6 +49,7 @@ import { classifyArchitectOutput, inspectArchitectPlanReadiness } from './utils/
4949
import { resolveSessionPlanOfRecord } from './services/plan-capture'
5050
import { PLAN_CAPTURE_MESSAGE_LIMIT } from './utils/marked-plan-parser'
5151
import { createForgeExecutionService, type ForgeExecutionRequestContext } from './services/execution'
52+
import { createTuiLoopRestartController, type TuiLoopRestartController } from './services/tui-loop-restart-controller'
5253

5354
export interface CreateParentSessionLookupOptions {
5455
client: ForgeClient
@@ -508,6 +509,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
508509

509510
let sessionSandboxProjectId: string | null = null
510511
let sessionSandboxProvider: SessionSandboxProvider | null = null
512+
let tuiLoopRestartController: TuiLoopRestartController | null = null
511513

512514
const cleanup = (): Promise<void> => {
513515
if (cleanupPromise) {
@@ -524,6 +526,7 @@ export function createForgePlugin(config: PluginConfig): Plugin {
524526
logger.log('Loop: active loops preserved during plugin cleanup')
525527

526528
loopHandler.clearAllRetryTimeouts()
529+
await tuiLoopRestartController?.dispose()
527530

528531
// Disposal and DB close must both be exception-safe: a rejected controller disposal (e.g.
529532
// a failed container removal or acknowledgement persistence) must never prevent the SQLite
@@ -731,6 +734,32 @@ export function createForgePlugin(config: PluginConfig): Plugin {
731734
pendingTeardowns,
732735
})
733736

737+
if (!isForgeWorktreeDir(dataDir, directory)) {
738+
tuiLoopRestartController = createTuiLoopRestartController({
739+
projectId,
740+
repo: createTuiLoopRestartRepo(db),
741+
logger,
742+
async restart(request) {
743+
const response = await groupExecService.dispatch(
744+
{ surface: 'api', projectId, directory: projectRoot },
745+
{
746+
type: 'loop.restart',
747+
selector: { kind: 'exact', name: request.loopName },
748+
force: true,
749+
auditorModel: request.auditorModel,
750+
auditorVariant: request.auditorVariant,
751+
},
752+
)
753+
return response.ok
754+
? { sessionId: response.data.sessionId }
755+
: { error: response.error.message }
756+
},
757+
})
758+
void tuiLoopRestartController.start().catch((err) => {
759+
logger.error('TUI loop restart controller failed to start', err)
760+
})
761+
}
762+
734763
// ── Real GroupEffects ─────────────────────────────────────────────────────
735764
const effects: GroupEffects = {
736765
async spawnSplitterSession(prdText) {

‎src/loop/provider-limit.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ export interface ProviderErrorSignal {
66

77
const USAGE_LIMIT_PATTERN = /usage\s*limit|quota\s*(?:exceeded|reached)/i
88

9+
function selectMessage(primary: unknown, fallback: unknown): string | undefined {
10+
const primaryMessage = typeof primary === 'string' ? primary : undefined
11+
const fallbackMessage = typeof fallback === 'string' ? fallback : undefined
12+
if (primaryMessage && USAGE_LIMIT_PATTERN.test(primaryMessage)) return primaryMessage
13+
if (fallbackMessage && USAGE_LIMIT_PATTERN.test(fallbackMessage)) return fallbackMessage
14+
return primaryMessage ?? fallbackMessage
15+
}
16+
917
/**
1018
* Extract a {@link ProviderErrorSignal} from any error value, including
1119
* `ForgeClientError` instances whose `cause` carries the original SDK error
@@ -26,9 +34,7 @@ export function extractErrorSignal(err: unknown): ProviderErrorSignal {
2634
const c = cause as Record<string, unknown>
2735
const causeName = typeof c.name === 'string' ? c.name : undefined
2836
const causeData = c.data && typeof c.data === 'object' ? c.data as Record<string, unknown> : undefined
29-
const causeMessage = typeof c.message === 'string'
30-
? c.message
31-
: typeof causeData?.message === 'string' ? causeData.message : undefined
37+
const causeMessage = selectMessage(causeData?.message, c.message)
3238
const causeStatusCode = typeof causeData?.statusCode === 'number' ? causeData.statusCode : undefined
3339

3440
if (causeName || causeMessage || causeStatusCode) {
@@ -38,9 +44,7 @@ export function extractErrorSignal(err: unknown): ProviderErrorSignal {
3844

3945
const name = typeof obj.name === 'string' ? obj.name : undefined
4046
const data = obj.data && typeof obj.data === 'object' ? obj.data as Record<string, unknown> : undefined
41-
const message = typeof obj.message === 'string'
42-
? obj.message
43-
: typeof data?.message === 'string' ? data.message : undefined
47+
const message = selectMessage(data?.message, obj.message)
4448
const statusCode = typeof data?.statusCode === 'number' ? data.statusCode : undefined
4549

4650
return { name, message, statusCode }

‎src/loop/restartability.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@
44
*/
55

66
import { existsSync } from 'fs'
7-
import type { LoopState } from '../loop/state'
87
import { parseTerminationReasonString } from '../loop'
98

9+
export interface RestartableLoopState {
10+
loopName: string
11+
status: 'running' | 'completed' | 'cancelled' | 'errored' | 'stalled'
12+
terminationReason?: string | null
13+
worktree?: boolean
14+
worktreeDir?: string
15+
active: boolean
16+
}
17+
1018
export type RestartBlockedReason =
1119
| 'completed'
1220
| 'missing_worktree'
@@ -35,7 +43,7 @@ export interface RestartabilityResult {
3543
* missing worktree directory blocks restart (legacy behavior).
3644
*/
3745
export function getRestartability(
38-
state: LoopState,
46+
state: RestartableLoopState,
3947
opts?: { force?: boolean; worktreeExists?: (path: string) => boolean; branchExists?: () => boolean }
4048
): RestartabilityResult {
4149
const worktreeExists = opts?.worktreeExists ?? existsSync

‎src/services/execution.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ export interface RestartLoopCommand {
244244
type: 'loop.restart'
245245
selector: LoopSelector
246246
force?: boolean
247+
auditorModel?: string
248+
auditorVariant?: string
247249
}
248250

249251
export interface CancelLoopCommand {
@@ -1792,7 +1794,11 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo
17921794
if (stoppedState.active && latestState) {
17931795
// The pre-lock snapshot was active — the original code already ran this
17941796
// block to abort and refresh from latestState.
1795-
try { await deps.client.session.abort({ sessionID: latestState.sessionId }) } catch {}
1797+
try {
1798+
await deps.client.session.abort({ sessionID: latestState.sessionId })
1799+
} catch (err) {
1800+
return { ok: false, error: `Could not abort active loop session ${latestState.sessionId}: ${err instanceof Error ? err.message : String(err)}` }
1801+
}
17961802
await deps.loopHandler!.clearLoopTimers(stoppedState.loopName)
17971803
Object.assign(stoppedState, latestState)
17981804
Object.assign(previousState, latestState)
@@ -1807,6 +1813,15 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo
18071813
}
18081814
}
18091815

1816+
const overrideAuditorModel = normalizeModelString(command.auditorModel)
1817+
const overrideAuditorVariant = normalizeModelString(command.auditorVariant)
1818+
if (overrideAuditorModel) {
1819+
stoppedState.auditorModel = overrideAuditorModel
1820+
}
1821+
if (command.auditorVariant !== undefined) {
1822+
stoppedState.auditorVariant = overrideAuditorVariant
1823+
}
1824+
18101825
if (stoppedState.phase === 'post_action' && !resolvePostActionConfig(deps.config).enabled) {
18111826
deps.logger.log(`loop-restart: ${stoppedState.loopName} was in post_action but postAction is disabled; marking completed without restart`)
18121827
// Persist the terminal transition row so the disabled post-action
@@ -2026,6 +2041,7 @@ export function createForgeExecutionService(deps: ForgeExecutionServiceDeps): Fo
20262041
sandboxContainer: newState.sandboxContainer ?? null,
20272042
workspaceId: newState.workspaceId ?? null,
20282043
auditorModel: restartAuditorState.auditorModel ?? null,
2044+
auditorVariant: command.auditorVariant === undefined ? undefined : overrideAuditorVariant ?? null,
20292045
currentSectionIndex: newState.currentSectionIndex,
20302046
totalSections: newState.totalSections,
20312047
finalAuditDone: newState.finalAuditDone,
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { Logger } from '../types'
2+
import { randomUUID } from 'node:crypto'
3+
import type { TuiLoopRestartAppliedState, TuiLoopRestartRepo } from '../storage'
4+
5+
export interface TuiLoopRestartRequest {
6+
loopName: string
7+
auditorModel: string
8+
auditorVariant: string
9+
}
10+
11+
export type TuiLoopRestartResult = { sessionId: string } | { error: string }
12+
13+
export interface TuiLoopRestartControllerDeps {
14+
projectId: string
15+
repo: TuiLoopRestartRepo
16+
restart(request: TuiLoopRestartRequest): Promise<TuiLoopRestartResult>
17+
logger: Logger
18+
pollIntervalMs?: number
19+
maxRequestAgeMs?: number
20+
processingLeaseMs?: number
21+
processingHeartbeatMs?: number
22+
}
23+
24+
export interface TuiLoopRestartController {
25+
start(): Promise<void>
26+
dispose(): Promise<void>
27+
}
28+
29+
const DEFAULT_POLL_INTERVAL_MS = 250
30+
const DEFAULT_MAX_REQUEST_AGE_MS = 30_000
31+
const DEFAULT_PROCESSING_LEASE_MS = 600_000
32+
const DEFAULT_PROCESSING_HEARTBEAT_MS = 30_000
33+
34+
export function createTuiLoopRestartController(deps: TuiLoopRestartControllerDeps): TuiLoopRestartController {
35+
const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
36+
const maxRequestAgeMs = deps.maxRequestAgeMs ?? DEFAULT_MAX_REQUEST_AGE_MS
37+
const processingLeaseMs = deps.processingLeaseMs ?? DEFAULT_PROCESSING_LEASE_MS
38+
const processingHeartbeatMs = deps.processingHeartbeatMs ?? DEFAULT_PROCESSING_HEARTBEAT_MS
39+
const ownerId = randomUUID()
40+
41+
let timer: ReturnType<typeof setInterval> | null = null
42+
let disposed = false
43+
let disposing = false
44+
let inFlight: Promise<void> | null = null
45+
let completed: { revision: string; result: TuiLoopRestartResult; claim: TuiLoopRestartAppliedState } | null = null
46+
47+
const completedState = (revision: string, sessionId: string | null, error: string | null): TuiLoopRestartAppliedState => ({
48+
version: 1,
49+
revision,
50+
status: 'completed',
51+
ownerId: null,
52+
sessionId,
53+
error,
54+
appliedAt: Date.now(),
55+
})
56+
57+
const processingState = (revision: string): TuiLoopRestartAppliedState => ({
58+
version: 1,
59+
revision,
60+
status: 'processing',
61+
ownerId,
62+
sessionId: null,
63+
error: null,
64+
appliedAt: Date.now(),
65+
})
66+
67+
const reconcileOnce = async (): Promise<void> => {
68+
if (disposed || disposing) return
69+
const { desired, applied } = deps.repo.getPair(deps.projectId)
70+
if (!desired) return
71+
72+
if (completed?.revision === desired.revision) {
73+
const pending = completed
74+
const result = pending.result
75+
const state = 'sessionId' in result
76+
? completedState(desired.revision, result.sessionId, null)
77+
: completedState(desired.revision, null, result.error)
78+
deps.repo.compareAndSetApplied(deps.projectId, pending.claim, state)
79+
completed = null
80+
return
81+
}
82+
completed = null
83+
if (applied && applied.revision === desired.revision) {
84+
if (applied.status === 'processing' && Date.now() - applied.appliedAt > processingLeaseMs) {
85+
deps.repo.compareAndSetApplied(
86+
deps.projectId,
87+
applied,
88+
completedState(desired.revision, null, 'restart outcome is unknown because its controller stopped before acknowledging completion'),
89+
)
90+
}
91+
return
92+
}
93+
94+
const age = Date.now() - desired.requestedAt
95+
if (age > maxRequestAgeMs) {
96+
deps.logger.debug(`[tui-loop-restart] rejecting stale request revision=${desired.revision} age=${age}ms`)
97+
const claim = processingState(desired.revision)
98+
if (deps.repo.claim(deps.projectId, claim)) {
99+
deps.repo.compareAndSetApplied(
100+
deps.projectId,
101+
claim,
102+
completedState(desired.revision, null, 'restart request expired before it could be applied'),
103+
)
104+
}
105+
return
106+
}
107+
108+
let claim = processingState(desired.revision)
109+
const claimed = deps.repo.claim(deps.projectId, claim)
110+
if (!claimed) return
111+
112+
let result: TuiLoopRestartResult
113+
const heartbeat = setInterval(() => {
114+
const next = { ...claim, appliedAt: Date.now() }
115+
try {
116+
if (deps.repo.compareAndSetApplied(deps.projectId, claim, next)) claim = next
117+
} catch (err) {
118+
deps.logger.error('TUI loop restart heartbeat failed', err)
119+
}
120+
}, processingHeartbeatMs)
121+
try {
122+
result = await deps.restart({
123+
loopName: desired.loopName,
124+
auditorModel: desired.auditorModel,
125+
auditorVariant: desired.auditorVariant,
126+
})
127+
} catch (err) {
128+
result = { error: err instanceof Error ? err.message : String(err) }
129+
} finally {
130+
clearInterval(heartbeat)
131+
}
132+
if (disposed) return
133+
134+
const current = deps.repo.getDesired(deps.projectId)
135+
if (!current || current.revision !== desired.revision) return
136+
137+
completed = { revision: desired.revision, result, claim }
138+
const state = 'sessionId' in result
139+
? completedState(desired.revision, result.sessionId, null)
140+
: completedState(desired.revision, null, result.error)
141+
if (deps.repo.compareAndSetApplied(deps.projectId, claim, state)) completed = null
142+
}
143+
144+
const reconcile = (): Promise<void> => {
145+
if (inFlight) return inFlight
146+
const run = reconcileOnce()
147+
.catch((err: unknown) => {
148+
deps.logger.error(`[tui-loop-restart] reconciliation failed: ${err instanceof Error ? err.message : String(err)}`)
149+
})
150+
.finally(() => {
151+
if (inFlight === run) inFlight = null
152+
})
153+
inFlight = run
154+
return run
155+
}
156+
157+
const start = async (): Promise<void> => {
158+
if (disposed || disposing) return
159+
if (!timer) {
160+
timer = setInterval(() => { void reconcile() }, pollIntervalMs)
161+
}
162+
await reconcile()
163+
}
164+
165+
const dispose = async (): Promise<void> => {
166+
if (disposed) return
167+
disposing = true
168+
if (timer) {
169+
clearInterval(timer)
170+
timer = null
171+
}
172+
await inFlight
173+
disposed = true
174+
}
175+
176+
return { start, dispose }
177+
}

‎src/storage/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,6 @@ export type { FeatureGroupRow, GroupFeatureRow } from './repos/feature-groups-re
2626

2727
export { createSessionSandboxPreferencesRepo, SESSION_SANDBOX_DESIRED_KEY, SESSION_SANDBOX_APPLIED_KEY, SESSION_SANDBOX_CONTROLLER_KEY } from './repos/session-sandbox-preferences-repo'
2828
export type { SessionSandboxDesiredState, SessionSandboxAppliedState, SessionSandboxPreferencesRepo } from './repos/session-sandbox-preferences-repo'
29+
30+
export { createTuiLoopRestartRepo, TUI_LOOP_RESTART_DESIRED_KEY, TUI_LOOP_RESTART_APPLIED_KEY } from './repos/tui-loop-restart-repo'
31+
export type { TuiLoopRestartDesiredState, TuiLoopRestartAppliedState, TuiLoopRestartRepo, TuiLoopRestartPair } from './repos/tui-loop-restart-repo'

0 commit comments

Comments
 (0)