feat(opencode): open a fresh session on clear (#1300)

* test(opencode): specify fresh-session clear

* feat(opencode): open a fresh session on clear

* fix(opencode): release clear latch on cancel

* fix(opencode): retry transient clear handoffs

* fix(opencode): confirm clear archive delivery

* fix(web): preserve superseded session access

* fix(clear): invalidate transferred schedules

* fix(runner): restore live spawn dedupe

* fix(clear): preserve latched scheduled prompts

* fix(runner): quarantine unverified children

* fix(clear): retain handoff retry ownership

* fix(runner): release recovered spawn dedupe

* fix(clear): retain archive retry ownership

* fix(clear): settle rejected immediate prompts

* fix(clear): block reopening replaced sources

* fix(clear): settle prompts when clear is cancelled

* fix(clear): make fresh-session handoff durable

* fix(clear): finalize only after native cleanup

* fix(clear): abort failed native handoffs

* fix(clear): gate recovery on cleanup proof

* fix(clear): retry metadata persistence failures

* fix(clear): preserve handoff ownership through teardown

* fix(clear): abort incomplete cleanup reservations

* fix(clear): require explicit exit before abort

* fix(clear): verify owner exit before recovery

* fix(clear): guard recovery handoff races

* fix(clear): serialize cleanup callbacks

* fix(clear): make callback retries idempotent

* fix(clear): bind callbacks to reservations

* fix(clear): recover pending spawns

* fix(clear): deduplicate held prompts

* fix(clear): validate redirect ownership

* fix(clear): replay prompts in FIFO order

* fix(clear): gate replacement delivery
This commit is contained in:
Junmo Kim
2026-08-03 18:06:39 +08:00
committed by GitHub
parent 1b8cc334ea
commit f44c9ff3e6
41 changed files with 3209 additions and 73 deletions
+36
View File
@@ -106,6 +106,42 @@ describe('API extra headers integration', () => {
})
})
it('uses the CLI REST bridge to request a fresh OpenCode session after source cleanup', async () => {
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
const client = await ApiClient.create()
await expect(client.clearOpenCodeSession('source-session')).resolves.toBe('fresh-session')
expect(axiosPostMock).toHaveBeenCalledWith(
'https://hapi.example.com/cli/sessions/source-session/clear-opencode',
{},
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer cli-token' })
})
)
})
it('uses the CLI REST bridge to reserve before source cleanup', async () => {
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
const client = await ApiClient.create()
await expect(client.reserveOpenCodeClearSession('source-session')).resolves.toBe('fresh-session')
expect(axiosPostMock).toHaveBeenCalledWith(
'https://hapi.example.com/cli/sessions/source-session/clear-opencode/reserve', {},
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer cli-token' }) })
)
})
it.each(['confirmOpenCodeClearCleanup', 'abortOpenCodeClearSession'] as const)('sends reservation identity with %s', async (method) => {
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
const client = await ApiClient.create()
await expect(client[method]('source-session', 'fresh-session')).resolves.toBe('fresh-session')
expect(axiosPostMock).toHaveBeenCalledWith(
expect.stringContaining('/clear-opencode/'),
{ replacementSessionId: 'fresh-session' },
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer cli-token' }) })
)
})
it('adds extra headers to socket transport options', () => {
configuration._setExtraHeaders({
Cookie: 'CF_Authorization=token'
+50 -1
View File
@@ -1,8 +1,9 @@
import axios from 'axios'
import type { AgentState, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
import type { AgentState, ClearOpencodeSessionCallbackRequest, ClearOpencodeSessionResponse, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import {
AgentStateSchema,
ClearOpencodeSessionResponseSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
GetSessionResponseSchema,
@@ -272,6 +273,54 @@ export class ApiClient {
}
}
async clearOpenCodeSession(sessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode`,
{},
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/:id/clear-opencode response', response)
}
return parsed.data.sessionId
}
async reserveOpenCodeClearSession(sessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/reserve`, {},
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear reservation response', response)
return parsed.data.sessionId
}
async abortOpenCodeClearSession(sessionId: string, replacementSessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/abort`,
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear abort response', response)
return parsed.data.sessionId
}
async confirmOpenCodeClearCleanup(sessionId: string, replacementSessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/confirm-cleanup`,
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear cleanup confirmation response', response)
return parsed.data.sessionId
}
sessionSyncClient(session: Session, options?: ApiSessionClientOptions): ApiSessionClient {
return new ApiSessionClient(this.token, session, options)
}
+15
View File
@@ -372,6 +372,21 @@ describe('ApiSessionClient lazy materialization', () => {
expect(socket.emitted.some((entry) => entry.event === 'session-end')).toBe(true)
client.close()
})
it('reports an unconfirmed final flush when the socket cannot reconnect before the deadline', async () => {
socketHarness.sockets.length = 0
const client = new ApiSessionClient('token', createSession({ namespace: 'default' }))
const socket = socketHarness.sockets[0]
if (!socket) throw new Error('expected socket')
socket.connected = false
socket.connectImmediately = false
client.sendSessionDeath('cleared')
await expect(client.flush({ timeoutMs: 20 })).resolves.toBe(false)
expect(socket.connectCalls).toBeGreaterThan(0)
client.close()
})
})
describe('ApiSessionClient incoming user messages', () => {
+16 -9
View File
@@ -1098,7 +1098,7 @@ export class ApiSessionClient extends EventEmitter {
return await this.drainLock(this.metadataLock, timeoutMs)
}
async flush(options?: { timeoutMs?: number }): Promise<void> {
async flush(options?: { timeoutMs?: number }): Promise<boolean> {
const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000)
const remainingMs = () => Math.max(0, deadlineMs - Date.now())
@@ -1106,37 +1106,44 @@ export class ApiSessionClient extends EventEmitter {
if (materializationTask) {
this.materializationDrainRequested = true
this.materializationRetryAbortController?.abort()
await this.waitForPromise(materializationTask, remainingMs())
if (!await this.waitForPromise(materializationTask, remainingMs())) {
return false
}
}
if (this.state !== 'active') {
return
return false
}
if (!this.socket.connected) {
const connected = await this.waitForConnected(remainingMs())
if (!connected) {
return
return false
}
}
await this.drainLock(this.metadataLock, remainingMs())
await this.drainLock(this.agentStateLock, remainingMs())
if (!await this.drainLock(this.metadataLock, remainingMs())) {
return false
}
if (!await this.drainLock(this.agentStateLock, remainingMs())) {
return false
}
if (remainingMs() === 0) {
return
return false
}
const pingTimeoutMs = remainingMs()
if (pingTimeoutMs === 0) {
return
return false
}
try {
await this.socket.timeout(pingTimeoutMs).emitWithAck('ping')
this.awaitingMaterializedConnection = false
return true
} catch {
// best effort
return false
}
}
+8
View File
@@ -6,6 +6,8 @@ import {
RunnerStateSchema
} from '@hapi/protocol/schemas'
import {
ClearOpencodeSessionResponseSchema,
ClearOpencodeSessionCallbackRequestSchema,
CliMessagesResponseSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
@@ -13,6 +15,8 @@ import {
LocalHandoffResponseSchema,
LocalResumeTargetResponseSchema,
ResumableSessionsResponseSchema,
type ClearOpencodeSessionResponse,
type ClearOpencodeSessionCallbackRequest,
type CliMessagesResponse,
type CreateMachineResponse,
type CreateSessionResponse,
@@ -45,6 +49,8 @@ export type SessionEffort = string | null
export { AgentStateSchema, AttachmentMetadataSchema, MachineMetadataSchema, MetadataSchema, RunnerStateSchema }
export {
ClearOpencodeSessionCallbackRequestSchema,
ClearOpencodeSessionResponseSchema,
CliMessagesResponseSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
@@ -55,6 +61,8 @@ export {
}
export type {
ClearOpencodeSessionCallbackRequest,
ClearOpencodeSessionResponse,
CliMessagesResponse,
CreateMachineResponse,
CreateSessionResponse,