mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-05 01:01:42 +00:00
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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user