Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5782e55
plan: route-safe FreshOpenCode restart recovery
codex Jun 22, 2026
3694be4
plan: validate FreshOpenCode routes at mutation time
codex Jun 22, 2026
656b8fc
plan: harden FreshOpenCode route recovery
codex Jun 22, 2026
a50eab6
plan: reconcile FreshOpenCode route authorization
codex Jun 22, 2026
7818072
Guard recovered opencode mutations by route
codex Jun 22, 2026
c4d45b8
Fix opencode recovered status reconciliation
codex Jun 22, 2026
002d87c
Add scoped FreshOpenCode runtime recovery
codex Jun 22, 2026
8767d8f
fix: enforce FreshOpenCode runtime route proof
codex Jun 22, 2026
15a7daa
test: prove resumed OpenCode sessions need route proof
codex Jun 22, 2026
4aaf345
fix: mark forked OpenCode children provider-owned
codex Jun 22, 2026
4360080
fix: keep resumed OpenCode sessions route-gated
codex Jun 22, 2026
d9784d7
Add route-aware fresh-agent WS authorization
codex Jun 22, 2026
f145c25
Fix fresh-agent materialization cleanup
codex Jun 22, 2026
b8768b4
fix: clear pending fresh-agent attaches on close
codex Jun 22, 2026
2f39b1a
fix: require route before opencode ws mutation auth
codex Jun 22, 2026
08a16df
Route freshopencode UI actions by cwd
codex Jun 22, 2026
3e3e943
fix: preserve freshopencode route cwd for cleanup
codex Jun 22, 2026
a995132
fix: upgrade fresh-agent subscription routes
codex Jun 22, 2026
232584b
fix: route-gate freshopencode kills
codex Jun 22, 2026
9e2fb87
fix: validate freshopencode attach routes
codex Jun 22, 2026
df4c88b
fix: skip route proof for opencode placeholders
codex Jun 22, 2026
a4e1881
Fix fresh agent recovery refresh scope
codex Jun 22, 2026
ff801ee
fix: harden fresh-agent refresh coalescing
codex Jun 22, 2026
ef9882a
Coalesce retention stream replacements
codex Jun 22, 2026
d425826
test: cover FreshOpenCode restart route recovery
codex Jun 22, 2026
2e4a542
test: harden FreshOpenCode restart smoke
codex Jun 22, 2026
69ffb2c
fix: close FreshOpenCode recovery proof gaps
codex Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,136 changes: 1,136 additions & 0 deletions docs/superpowers/plans/2026-06-22-freshopencode-restart-recovery.md

Large diffs are not rendered by default.

107 changes: 103 additions & 4 deletions server/fresh-agent/adapters/opencode/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events'
import { stat } from 'node:fs/promises'
import { realpath, stat } from 'node:fs/promises'
import path from 'node:path'
import type {
FreshAgentCreateRequest,
Expand Down Expand Up @@ -37,6 +37,8 @@ type OpencodeSessionState = {
placeholderId: string
realSessionId?: string
cwd?: string
routeValidatedCwd?: string
providerCreatedInThisAdapter?: boolean
model?: string
effort?: string
status: string
Expand All @@ -53,6 +55,7 @@ type CreateOpencodeFreshAgentAdapterOptions = {
dataHome?: string
turnTimeoutMs?: number
validateCwd?: (cwd: string) => Promise<void>
canonicalizePath?: (cwd: string) => Promise<string>
}

function makePlaceholderId(requestId: string): string {
Expand All @@ -77,6 +80,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
const serveManager = options.serveManager
const turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS
const validateCwd = options.validateCwd ?? defaultValidateCwd
const canonicalizePath = options.canonicalizePath ?? realpath
const dbPath = options.dbPath ?? path.join(options.dataHome ?? defaultOpencodeDataHome(), 'opencode.db')
// Lazily create the legacy reader only if a legacy placeholder resume is attempted.
let historyReader: OpencodeHistoryReader | undefined = options.historyReader
Expand Down Expand Up @@ -104,6 +108,79 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
return typeof cwd === 'string' && cwd.trim().length > 0 ? { cwd } : undefined
}

async function validateSessionRoute(realId: string, cwd: string): Promise<string> {
const expected = await canonicalizePath(cwd)
await validateCwd(cwd)
const session = await serveManager.getSession(realId, { cwd })
if (typeof session?.id === 'string' && session.id !== realId) {
throw new FreshAgentLostSessionError(`OpenCode session lookup for ${realId} returned ${session.id}.`)
}
const reportedDirectory = typeof session?.directory === 'string' ? session.directory : undefined
if (!reportedDirectory) {
throw new FreshAgentLostSessionError(`OpenCode session ${realId} did not report a directory.`)
}
const actual = await canonicalizePath(reportedDirectory)
if (expected !== actual) {
throw new FreshAgentLostSessionError(`OpenCode session ${realId} belongs to ${reportedDirectory}, not ${cwd}.`)
}
return expected
}

async function ensureMutableRoute(state: OpencodeSessionState): Promise<void> {
const realId = state.realSessionId
if (!realId) return
const cwd = state.cwd
if (state.providerCreatedInThisAdapter && (!cwd || cwd.trim().length === 0)) return
if (!cwd || cwd.trim().length === 0) {
throw new FreshAgentLostSessionError(`OpenCode session ${realId} requires a cwd before it can be mutated after recovery.`)
}
const expected = await canonicalizePath(cwd)
if (state.routeValidatedCwd === expected) return
state.routeValidatedCwd = await validateSessionRoute(realId, cwd)
}

async function reconcileStatus(state: OpencodeSessionState): Promise<void> {
const realId = state.realSessionId
if (!realId) return
state.status = 'idle'
const getSessionStatus = (serveManager as { getSessionStatus?: (sessionId: string, route?: { cwd?: string }) => Promise<{ type?: unknown } | undefined> }).getSessionStatus
const logContext = {
provider: 'opencode',
sessionIdHash: hashForLogs(realId),
...(state.cwd ? { cwdHash: hashForLogs(state.cwd) } : {}),
}
if (typeof getSessionStatus !== 'function') {
log.warn({
...logContext,
reason: 'missing_get_session_status',
}, 'opencode status reconciliation skipped')
return
}
try {
const status = await getSessionStatus.call(serveManager, realId, cwdRoute(state.cwd) ?? {})
if (!status || typeof status !== 'object' || Array.isArray(status) || typeof status.type !== 'string') {
log.warn({
...logContext,
reason: 'malformed_session_status',
status,
}, 'opencode status reconciliation received malformed status')
return
}
const type = status.type
if (type === 'busy' || type === 'retry') {
state.status = 'running'
return
}
if (type === 'idle') return
} catch (err) {
log.warn({
...logContext,
err,
reason: 'get_session_status_failed',
}, 'opencode status reconciliation failed')
}
}

async function promptAsyncForState(
state: OpencodeSessionState,
realId: string,
Expand All @@ -119,6 +196,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen

async function abortForState(state: OpencodeSessionState): Promise<void> {
if (!state.realSessionId) return
await ensureMutableRoute(state)
const route = cwdRoute(state.cwd)
if (route) {
await serveManager.abort(state.realSessionId, route)
Expand All @@ -129,6 +207,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen

async function compactForState(state: OpencodeSessionState, input?: { instructions?: string }): Promise<void> {
if (!state.realSessionId) return
await ensureMutableRoute(state)
const route = cwdRoute(state.cwd)
if (route) {
await serveManager.compact(state.realSessionId, input, route)
Expand All @@ -145,6 +224,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
if (!state.realSessionId) {
throw new FreshAgentLostSessionError(`OpenCode session ${state.placeholderId} has not materialized; cannot fork.`)
}
await ensureMutableRoute(state)
const route = cwdRoute(state.cwd)
return route
? await serveManager.fork(state.realSessionId, route)
Expand Down Expand Up @@ -214,14 +294,19 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
if (effectiveCwd) await validateCwd(effectiveCwd)
const session = await serveManager.createSession({ title: undefined, ...(effectiveCwd ? { directory: effectiveCwd } : {}) })
state.realSessionId = session.id
state.providerCreatedInThisAdapter = true
if (typeof session.directory === 'string' && session.directory.length > 0) state.cwd = session.directory
else if (effectiveCwd) state.cwd = effectiveCwd
if (typeof session.directory === 'string' && session.directory.length > 0 && state.cwd) {
state.routeValidatedCwd = await canonicalizePath(state.cwd)
}
remember(state)
bindServeStream(state)
emitMaterialized(state)
}

const realId = state.realSessionId!
await ensureMutableRoute(state)
const idleRoute = cwdRoute(state.cwd)
const idle = idleRoute
? serveManager.onceIdle(realId, turnTimeoutMs, idleRoute)
Expand Down Expand Up @@ -307,6 +392,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
}
remember(state)
bindServeStream(state)
await reconcileStatus(state)
return { sessionId: real, sessionRef: { provider: 'opencode', sessionId: real } }
}
if (!isRealOpencodeSessionId(sessionId)) {
Expand All @@ -318,16 +404,23 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
}
remember(state)
bindServeStream(state)
await reconcileStatus(state)
return { sessionId, sessionRef: { provider: 'opencode', sessionId } }
},

async attach(locator) {
const existing = sessions.get(locator.sessionId)
if (existing) {
if (locator.cwd) {
if (locator.cwd && existing.realSessionId) {
const routeValidatedCwd = await validateSessionRoute(existing.realSessionId, locator.cwd)
if (existing.cwd !== locator.cwd) existing.routeValidatedCwd = undefined
existing.cwd = locator.cwd
existing.routeValidatedCwd = routeValidatedCwd
} else if (locator.cwd) {
existing.cwd = locator.cwd
}
remember(existing)
await reconcileStatus(existing)
return { sessionId: locator.sessionId, sessionRef: { provider: 'opencode', sessionId: locator.sessionId } }
}
if (isPlaceholderOpencodeSessionId(locator.sessionId) || !isRealOpencodeSessionId(locator.sessionId)) {
Expand All @@ -340,8 +433,12 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
status: 'idle',
events: new EventEmitter(), sendQueue: Promise.resolve(),
}
if (locator.cwd) {
state.routeValidatedCwd = await validateSessionRoute(locator.sessionId, locator.cwd)
}
remember(state)
bindServeStream(state)
await reconcileStatus(state)
return { sessionId: locator.sessionId, sessionRef: { provider: 'opencode', sessionId: locator.sessionId } }
},

Expand All @@ -364,7 +461,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen

async interrupt(sessionId) {
const state = requireState(sessionId)
await abortForState(state).catch((err) => log.warn({ err }, 'abort failed'))
await abortForState(state)
emitStatus(state, 'idle')
},

Expand All @@ -388,6 +485,7 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
placeholderId: child.id,
realSessionId: child.id,
cwd: child.directory ?? state.cwd,
providerCreatedInThisAdapter: true,
model: state.model,
effort: state.effort,
status: 'idle',
Expand All @@ -399,8 +497,9 @@ export function createOpencodeFreshAgentAdapter(options: CreateOpencodeFreshAgen
return { sessionId: child.id, sessionRef: { provider: 'opencode', sessionId: child.id } }
},

kill(sessionId) {
async kill(sessionId) {
const state = requireState(sessionId)
await ensureMutableRoute(state)
try { state.unsubscribeServe?.() } catch { /* ignore */ }
sessions.delete(state.placeholderId)
if (state.realSessionId) sessions.delete(state.realSessionId)
Expand Down
5 changes: 5 additions & 0 deletions server/fresh-agent/adapters/opencode/serve-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,11 @@ export class OpencodeServeManager {
return this.json<OpencodeStatusMap>(withRoute('/session/status', route), { method: 'GET', ...init })
}

async getSessionStatus(sessionId: string, route: ServeRoute = {}): Promise<{ type?: unknown } | undefined> {
const statuses = await this.getSessionStatusMap(route)
return statuses[sessionId]
}

async createSession(input: { title?: string; parentID?: string; directory?: string } = {}): Promise<{ id: string; directory?: string; title?: string }> {
const body: { title?: string; parentID?: string } = {}
if (input.title !== undefined) body.title = input.title
Expand Down
Loading
Loading