Files
hapi/hub/src/sync/syncEngine.ts
T
KorenKritaandGitHub abf9cb02a5 fix(pi): resume archived sessions safely (#1308)
* fix(pi): resume archived sessions safely

* fix(pi): harden native resume startup

* fix(pi): harden resume termination evidence

* fix(runner): persist resume process evidence

* fix(runner): track resume process generations

* fix(runner): verify full session tree shutdown

* fix(pi): block pre-mapping resume dedup
2026-08-02 21:15:52 +08:00

2426 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Sync Engine for HAPI Telegram Bot (Direct Connect)
*
* In the direct-connect architecture:
* - hapi-hub is the hub (Socket.IO + REST)
* - hapi CLI connects directly to the hub (no relay)
* - No E2E encryption; data is stored as JSON in SQLite
*/
import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol'
import type { CursorChatStoreStatus, CursorMigrateOutcome, CursorMigrateToAcpRequest, MessagesResponse, QueuedStateResponse, SlashCommandsResponse } from '@hapi/protocol/apiTypes'
import type { AgentFlavor, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types'
import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages'
import type { Server } from 'socket.io'
import type { Store, CancelQueuedMessageResult } from '../store'
import type { HapiSessionExportResult } from '@hapi/protocol/sessionExport'
import type { RpcRegistry } from '../socket/rpcRegistry'
import type { SSEManager } from '../sse/sseManager'
import { CursorLegacyMigrator, type CursorLegacyMigratorOptions } from '../cursor/cursorLegacyMigrator'
import { EventPublisher, type SyncEventListener } from './eventPublisher'
import { MachineCache, type Machine } from './machineCache'
import { MessageService } from './messageService'
import {
RpcGateway,
RpcTargetMissingError,
type RpcCodexModel,
type RpcCommandResponse,
type RpcDeleteUploadResponse,
type RpcGeneratedImageResponse,
type RpcListDirectoryResponse,
type RpcStatFilesResponse,
type RpcListCodexModelsResponse,
type RpcArchiveCodexSessionResponse,
type RpcListCursorModelsResponse,
type RpcListOpencodeModelsResponse,
type RpcListGrokModelsResponse,
type RpcListGrokReasoningEffortOptionsResponse,
type RpcListOpencodeReasoningEffortOptionsResponse,
type RpcCursorModel,
type RpcCursorChatStoreStatus,
type RpcOpencodeModel,
type RpcPathExistsResponse,
type RpcReadFileResponse,
type RpcUploadFileResponse
} from './rpcGateway'
import { SessionCache } from './sessionCache'
type PiResumeAttempt = NonNullable<NonNullable<Session['metadata']>['piResumeAttempt']>
export type { Session, SyncEvent } from '@hapi/protocol/types'
export type { Machine } from './machineCache'
export type { SyncEventListener } from './eventPublisher'
export type {
RpcCodexModel,
RpcCommandResponse,
RpcDeleteUploadResponse,
RpcGeneratedImageResponse,
RpcListDirectoryResponse,
RpcStatFilesResponse,
RpcListCodexModelsResponse,
RpcListCursorModelsResponse,
RpcListOpencodeModelsResponse,
RpcListGrokModelsResponse,
RpcListGrokReasoningEffortOptionsResponse,
RpcListOpencodeReasoningEffortOptionsResponse,
RpcCursorModel,
RpcCursorChatStoreStatus,
RpcOpencodeModel,
RpcPathExistsResponse,
RpcReadFileResponse,
RpcUploadFileResponse
} from './rpcGateway'
export type ResumeSessionResult =
| { type: 'success'; sessionId: string }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed'; rollbackSafe?: boolean }
export type ReopenSessionResult =
| { type: 'success'; sessionId: string; resumed: boolean; cursorSessionProtocol?: 'acp' | 'stream-json' }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' | 'metadata_conflict' }
| { type: 'incomplete'; message: string; missing: [string, ...string[]] }
export type LocalResumeTargetResult =
| { type: 'success'; target: LocalResumeTarget }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' }
export type LocalHandoffResult =
| { type: 'success' }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' }
export type CursorChatStoreStatusResult =
| { type: 'success'; status: CursorChatStoreStatus }
| { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'resume_unavailable' | 'no_machine_online' | 'probe_failed' }
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function normalizeUserMessageText(value: string): string | undefined {
const text = value.trim().replace(/\s+/g, ' ')
return text.length > 0 ? text : undefined
}
function extractUserMessageText(content: unknown): string | undefined {
if (typeof content === 'string') {
return normalizeUserMessageText(content)
}
if (Array.isArray(content)) {
const parts = content
.map((block) => {
const record = asRecord(block)
return record?.type === 'text' && typeof record.text === 'string'
? record.text
: null
})
.filter((text): text is string => text !== null)
return normalizeUserMessageText(parts.join(' '))
}
const record = asRecord(content)
if (record?.type === 'text' && typeof record.text === 'string') {
return normalizeUserMessageText(record.text)
}
return undefined
}
function extractClaudeUserMessageTextFromAgentOutput(content: unknown): string | undefined {
const record = asRecord(content)
if (record?.type !== 'output') return undefined
const data = asRecord(record.data)
if (data?.type !== 'user') return undefined
const message = asRecord(data.message)
if (message?.role !== 'user') return undefined
return extractUserMessageText(message.content)
}
export class SyncEngine {
private readonly eventPublisher: EventPublisher
private readonly sessionCache: SessionCache
private readonly machineCache: MachineCache
private readonly messageService: MessageService
private readonly rpcGateway: RpcGateway
private inactivityTimer: NodeJS.Timeout | null = null
/** Sessions that emitted `session-ready` (Cursor ACP or validated Pi get_state). */
private readonly sessionReadyIds = new Set<string>()
/** Original Pi rows with a native resume currently in flight. */
private readonly piResumeInFlightIds = new Set<string>()
/** Pi rows whose runner child could not be confirmed terminated. */
private readonly piResumeQuarantinedIds = new Set<string>()
/** Unexpected version-skew temp child -> original row whose retry is blocked until child ends. */
private readonly piUnexpectedTempOriginalIds = new Map<string, string>()
/** Serialize scratchlist uploads per session so disk-byte caps cannot race. */
private readonly scratchlistUploadTails = new Map<string, Promise<unknown>>()
constructor(
private readonly store: Store,
io: Server,
rpcRegistry: RpcRegistry,
sseManager: SSEManager
) {
this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event))
this.sessionCache = new SessionCache(store, this.eventPublisher)
this.machineCache = new MachineCache(store, this.eventPublisher)
this.messageService = new MessageService(
store,
io,
this.eventPublisher,
(sessionId, updatedAt) => this.recordSessionActivity(sessionId, updatedAt)
)
this.rpcGateway = new RpcGateway(io, rpcRegistry)
this.reloadAll()
this.inactivityTimer = setInterval(() => this.expireInactive(), 5_000)
}
stop(): void {
if (this.inactivityTimer) {
clearInterval(this.inactivityTimer)
this.inactivityTimer = null
}
}
subscribe(listener: SyncEventListener): () => void {
return this.eventPublisher.subscribe(listener)
}
private resolveNamespace(event: SyncEvent): string | undefined {
if (event.namespace) {
return event.namespace
}
if ('sessionId' in event) {
return this.getSession(event.sessionId)?.namespace
}
if ('machineId' in event) {
return this.machineCache.getMachine(event.machineId)?.namespace
}
return undefined
}
getSessions(): Session[] {
return this.sessionCache.getSessions()
}
private resolveOnlineMachineForSession(
session: Session,
namespace: string,
options?: { strictMachineId?: boolean }
): Machine | null {
const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace)
if (session.metadata?.machineId) {
const exact = onlineMachines.find((machine) => machine.id === session.metadata?.machineId)
if (exact) return exact
if (options?.strictMachineId) return null
}
if (session.metadata?.host) {
const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === session.metadata?.host)
if (hostMatch) return hostMatch
}
return null
}
async getCursorChatStoreStatus(sessionId: string, namespace: string): Promise<CursorChatStoreStatusResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
return {
type: 'error',
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
}
}
const metadata = access.session.metadata
if (metadata?.flavor !== 'cursor' || !metadata.path || !metadata.cursorSessionId) {
return {
type: 'error',
message: 'Cursor resume metadata is unavailable',
code: 'resume_unavailable'
}
}
const targetMachine = this.resolveOnlineMachineForSession(
access.session,
namespace,
{ strictMachineId: true }
)
if (!targetMachine) {
return { type: 'error', message: 'No machine online', code: 'no_machine_online' }
}
try {
const status = await this.rpcGateway.getCursorChatStoreStatus(
targetMachine.id,
metadata.path,
metadata.cursorSessionId,
metadata.homeDir
)
return { type: 'success', status }
} catch (error) {
return {
type: 'error',
message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store',
code: 'probe_failed'
}
}
}
getSessionsByNamespace(namespace: string): Session[] {
return this.sessionCache.getSessionsByNamespace(namespace)
}
getFutureScheduledMessageCounts(sessionIds: string[], now: number = Date.now()): Map<string, number> {
return this.store.messages.countFutureScheduledBySessionIds(sessionIds, now)
}
getNextScheduledAtBySessionIds(sessionIds: string[], now: number = Date.now()): Map<string, number> {
return this.store.messages.minFutureScheduledAtBySessionIds(sessionIds, now)
}
getSession(sessionId: string): Session | undefined {
return this.sessionCache.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) ?? undefined
}
getSessionByNamespace(sessionId: string, namespace: string): Session | undefined {
const session = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!session || session.namespace !== namespace) {
return undefined
}
return session
}
resolveSessionAccess(
sessionId: string,
namespace: string
): { ok: true; sessionId: string; session: Session } | { ok: false; reason: 'not-found' | 'access-denied' } {
return this.sessionCache.resolveSessionAccess(sessionId, namespace)
}
getActiveSessions(): Session[] {
return this.sessionCache.getActiveSessions()
}
getMachines(): Machine[] {
return this.machineCache.getMachines()
}
getMachinesByNamespace(namespace: string): Machine[] {
return this.machineCache.getMachinesByNamespace(namespace)
}
getMachine(machineId: string): Machine | undefined {
return this.machineCache.getMachine(machineId)
}
getMachineByNamespace(machineId: string, namespace: string): Machine | undefined {
return this.machineCache.getMachineByNamespace(machineId, namespace)
}
getOnlineMachines(): Machine[] {
return this.machineCache.getOnlineMachines()
}
getOnlineMachinesByNamespace(namespace: string): Machine[] {
return this.machineCache.getOnlineMachinesByNamespace(namespace)
}
async renameMachine(machineId: string, displayName: string): Promise<void> {
return this.machineCache.renameMachine(machineId, displayName)
}
getMessagesPage(
sessionId: string,
options: {
limit: number
before?: { at: number; seq: number } | null
after?: { at: number; seq: number } | null
until?: { at: number; seq: number } | null
epoch?: number | null
}
): MessagesResponse {
return this.messageService.getMessagesPage(sessionId, options)
}
getQueuedState(sessionId: string, localIds: string[]): QueuedStateResponse {
return this.messageService.getQueuedState(sessionId, localIds)
}
getSessionExport(sessionId: string, session: Session): HapiSessionExportResult {
return this.messageService.getSessionExport(sessionId, session)
}
getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] {
return this.messageService.getDeliverableMessagesAfter(sessionId, options)
}
handleRealtimeEvent(event: SyncEvent): void {
if (event.type === 'session-updated' && event.sessionId) {
// Snapshot agent session IDs before refresh — safe because JS is single-threaded
// and refreshSession replaces the Map entry with a new object.
const before = this.sessionCache.getSession(event.sessionId)
this.sessionCache.refreshSession(event.sessionId)
const after = this.sessionCache.getSession(event.sessionId)
if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) {
if (!this.canRunCursorDedup(after)) {
return
}
void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => {
// best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates
})
}
return
}
if (event.type === 'machine-updated' && event.machineId) {
this.machineCache.refreshMachine(event.machineId)
return
}
if (event.type === 'message-received' && event.sessionId) {
if (!this.getSession(event.sessionId)) {
this.sessionCache.refreshSession(event.sessionId)
}
}
this.eventPublisher.emit(event)
}
handleSessionAlive(payload: {
sid: string
time: number
thinking?: boolean
mode?: 'local' | 'remote'
permissionMode?: PermissionMode
model?: string | null
modelReasoningEffort?: string | null
effort?: string | null
serviceTier?: string | null
collaborationMode?: CodexCollaborationMode
}): void {
this.sessionCache.handleSessionAlive(payload)
this.triggerDedupIfNeeded(payload.sid)
}
handleSessionReady(payload: { sid: string; time: number }): void {
this.sessionReadyIds.add(payload.sid)
const session = this.sessionCache.getSession(payload.sid)
if (session?.metadata?.piResumeAttempt) {
void this.writePiResumeAttempt(payload.sid, session.namespace, null)
.then(() => {
this.piResumeQuarantinedIds.delete(payload.sid)
this.triggerDedupIfNeeded(payload.sid)
})
.catch(() => {})
}
this.triggerDedupIfNeeded(payload.sid)
}
clearQueuedThinkingGrace(sessionId: string): void {
this.sessionCache.clearQueuedThinkingGrace(sessionId)
}
handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void {
const before = this.sessionCache.getSession(payload.sid)
const ownsPiAttempt = before?.metadata?.piResumeAttempt !== undefined
const isPiAttemptChild = this.sessionCache.getSessions().some(
(session) => session.metadata?.piResumeAttempt?.childSessionId === payload.sid
)
const restorePiArchive = ownsPiAttempt && !this.sessionReadyIds.has(payload.sid)
const isCursorAcp = before?.metadata?.flavor === 'cursor'
&& before.metadata.cursorSessionProtocol === 'acp'
const shouldRetryDedup = !ownsPiAttempt && !isPiAttemptChild && (!isCursorAcp || this.sessionReadyIds.has(payload.sid))
this.sessionCache.handleSessionEnd(payload)
this.eventPublisher.emit({
type: 'session-ended',
sessionId: payload.sid,
reason: payload.reason
})
// Retry dedup now that this session is inactive — a prior dedup may have
// skipped it because it was still active at the time. Cursor ACP rows that
// never reached session-ready must not dedup-merge the original on failure.
if (shouldRetryDedup) {
this.triggerDedupIfNeeded(payload.sid)
}
this.sessionReadyIds.delete(payload.sid)
this.piResumeQuarantinedIds.delete(payload.sid)
this.piUnexpectedTempOriginalIds.delete(payload.sid)
if (ownsPiAttempt || isPiAttemptChild) {
void this.clearPiAttemptForEndedSession(payload.sid, restorePiArchive)
}
}
handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void {
this.sessionCache.applyBackgroundTaskDelta(sessionId, delta)
}
recordSessionActivity(sessionId: string, updatedAt: number): void {
this.sessionCache.recordSessionActivity(sessionId, updatedAt)
}
/**
* tiann/hapi#893 (scratchlist v2). Read-side: list entries for a
* session. Auth / namespace check is the route layer's job (via
* `requireSessionFromParam`); by the time we get here the caller
* already proved access.
*/
listScratchlistEntries(sessionId: string): Array<{
entryId: string
text: string
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}> {
return this.store.scratchlist.list(sessionId).map((row) => ({
entryId: row.entryId,
text: row.text,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
attachments: row.attachments,
}))
}
countScratchlistEntries(sessionId: string): number {
return this.store.scratchlist.count(sessionId)
}
sumScratchlistAttachmentBytes(sessionId: string): number {
return this.store.scratchlist.sumAttachmentBytes(sessionId)
}
/**
* Read a single entry by id. The route layer uses this to short-
* circuit duplicate POSTs (migration retry) BEFORE running the
* server-side cap check; otherwise an idempotent retry against a
* session that has hit `SCRATCHLIST_MAX_ENTRIES` would 409 when it
* should 200 with the existing row.
*/
getScratchlistEntry(
sessionId: string,
entryId: string
): {
entryId: string
text: string
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
} | null {
const row = this.store.scratchlist.get(sessionId, entryId)
if (!row) return null
return {
entryId: row.entryId,
text: row.text,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
attachments: row.attachments,
}
}
/**
* Insert a scratchlist entry. Returns the canonical row on success
* (so the route layer can serialise it without a follow-up read).
* Emits a `session-updated` SSE patch carrying `scratchlistUpdatedAt`
* so other clients viewing the same session refetch.
*
* `outcome: 'duplicate'` covers the migration path's idempotency:
* the web client may retry pushing a localStorage entry after a
* partial failure; the second attempt should be a no-op rather than
* a hard error. Route layer maps duplicate → 200/conflict per its
* own contract; this layer just reports it.
*/
createScratchlistEntry(
sessionId: string,
text: string,
options?: {
entryId?: string
createdAt?: number
attachments?: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}
): {
outcome: 'created' | 'duplicate'
entry: {
entryId: string
text: string
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}
} | { outcome: 'session-not-found' } {
const result = this.store.scratchlist.create(sessionId, text, options)
if (result.outcome === 'session-not-found') {
return result
}
if (result.outcome === 'created') {
this.sessionCache.emitScratchlistChanged(sessionId, result.entry.updatedAt)
}
return {
outcome: result.outcome,
entry: {
entryId: result.entry.entryId,
text: result.entry.text,
createdAt: result.entry.createdAt,
updatedAt: result.entry.updatedAt,
attachments: result.entry.attachments,
}
}
}
updateScratchlistEntry(
sessionId: string,
entryId: string,
patch: {
text?: string
attachments?: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}
): {
entryId: string
text: string
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
} | null {
const updated = this.store.scratchlist.update(sessionId, entryId, patch)
if (!updated) return null
this.sessionCache.emitScratchlistChanged(sessionId, updated.updatedAt)
return {
entryId: updated.entryId,
text: updated.text,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
attachments: updated.attachments,
}
}
deleteScratchlistEntry(sessionId: string, entryId: string): boolean {
const existing = this.store.scratchlist.get(sessionId, entryId)
const removed = this.store.scratchlist.delete(sessionId, entryId)
if (removed && existing) {
// Attachment ids may be shared across entries (direct REST).
// Only delete blobs that no remaining entry still references.
const remainingIds = new Set(
this.store.scratchlist
.list(sessionId)
.flatMap((entry) => entry.attachments.map((att) => att.id))
)
const orphaned = existing.attachments.filter((att) => !remainingIds.has(att.id))
if (orphaned.length > 0) {
void import('../scratchlistAttachments/storage').then(({ deleteScratchlistAttachmentFiles, getHapiHomeDir }) =>
deleteScratchlistAttachmentFiles(getHapiHomeDir(), orphaned)
)
}
this.sessionCache.emitScratchlistChanged(sessionId, Date.now())
}
return removed
}
private async withScratchlistUploadLock<T>(
namespace: string,
sessionId: string,
fn: () => Promise<T>
): Promise<T> {
const key = `${namespace}:${sessionId}`
const previous = this.scratchlistUploadTails.get(key) ?? Promise.resolve()
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const tail = previous.catch(() => undefined).then(() => gate)
this.scratchlistUploadTails.set(key, tail)
await previous.catch(() => undefined)
try {
return await fn()
} finally {
release()
if (this.scratchlistUploadTails.get(key) === tail) {
this.scratchlistUploadTails.delete(key)
}
}
}
async uploadScratchlistAttachment(
sessionId: string,
namespace: string,
filename: string,
contentBase64: string,
mimeType: string
): Promise<{ success: true; attachment: import('@hapi/protocol').ScratchlistAttachmentMetadata } | { success: false; error: string; code?: string }> {
const { loadScratchlistAttachmentLimitsFromEnv, isAllowedScratchlistMime } = await import('../config/scratchlistAttachmentLimits')
const {
estimateBase64Bytes,
writeScratchlistAttachmentFile,
getHapiHomeDir,
sumScratchlistAttachmentBytesOnDisk,
} = await import('../scratchlistAttachments/storage')
const { validateScratchlistAttachmentsForWrite } = await import('../scratchlistAttachments/validate')
const limits = loadScratchlistAttachmentLimitsFromEnv()
const estimated = estimateBase64Bytes(contentBase64)
if (estimated > limits.maxBytesPerFile) {
return { success: false, error: 'File too large', code: 'scratchlist_attachment_too_large' }
}
if (!isAllowedScratchlistMime(mimeType, limits)) {
return { success: false, error: 'Mime type not allowed', code: 'scratchlist_attachment_mime' }
}
const hapiHome = getHapiHomeDir()
const buffer = Buffer.from(contentBase64, 'base64')
return await this.withScratchlistUploadLock(namespace, sessionId, async () => {
const sessionBytes = await sumScratchlistAttachmentBytesOnDisk(hapiHome, namespace, sessionId)
const provisional = {
id: 'pending',
filename,
mimeType,
size: buffer.length,
path: 'pending',
}
const validation = validateScratchlistAttachmentsForWrite([provisional], limits, sessionBytes)
if (!validation.ok) {
return { success: false, error: validation.error, code: validation.code }
}
const attachment = await writeScratchlistAttachmentFile(
hapiHome,
namespace,
sessionId,
filename,
mimeType,
buffer
)
return { success: true, attachment }
})
}
async resolveScratchlistAttachmentsForSession(
sessionId: string,
namespace: string,
claimed: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
): Promise<
| { ok: true; attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] }
| { ok: false; error: string }
> {
const {
resolveScratchlistAttachmentsForSession: resolveAttachments,
getHapiHomeDir,
} = await import('../scratchlistAttachments/storage')
return resolveAttachments(getHapiHomeDir(), namespace, sessionId, claimed)
}
async sumScratchlistAttachmentBytesOnDisk(sessionId: string, namespace: string): Promise<number> {
const {
sumScratchlistAttachmentBytesOnDisk: sumOnDisk,
getHapiHomeDir,
} = await import('../scratchlistAttachments/storage')
return sumOnDisk(getHapiHomeDir(), namespace, sessionId)
}
async deleteScratchlistAttachmentById(
sessionId: string,
namespace: string,
attachmentId: string
): Promise<boolean> {
const {
deleteScratchlistAttachmentById: deleteById,
getHapiHomeDir,
} = await import('../scratchlistAttachments/storage')
return deleteById(getHapiHomeDir(), namespace, sessionId, attachmentId)
}
async readScratchlistAttachment(
hubPath: string
): Promise<{ buffer: Buffer; mimeType: string; filename: string } | null> {
const { readScratchlistAttachmentFile, getHapiHomeDir } = await import('../scratchlistAttachments/storage')
const read = await readScratchlistAttachmentFile(getHapiHomeDir(), hubPath)
if (!read) return null
return { buffer: read.buffer, mimeType: 'application/octet-stream', filename: 'attachment' }
}
handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void {
this.machineCache.handleMachineAlive(payload)
}
private expireInactive(): void {
const expired = this.sessionCache.expireInactive()
// Sort by most recent first so dedup keeps the newest session when multiple
// duplicates for the same agent thread expire in the same sweep.
const sorted = expired
.map((id) => this.sessionCache.getSession(id))
.filter((s): s is NonNullable<typeof s> => s != null)
.sort((a, b) => (b.activeAt - a.activeAt) || (b.updatedAt - a.updatedAt))
for (const session of sorted) {
this.triggerDedupIfNeeded(session.id)
}
this.machineCache.expireInactive()
// Piggybacked on the inactivity tick; not a logical part of expireInactive
// but shares its 5s cadence (avoids a second timer).
this.messageService.releaseMatureScheduledMessages(Date.now())
}
private reloadAll(): void {
this.sessionCache.reloadAll()
this.machineCache.reloadAll()
}
getOrCreateSession(
tag: string,
metadata: unknown,
agentState: unknown,
namespace: string,
model?: string,
effort?: string,
modelReasoningEffort?: string,
requestedId?: string
): Session {
return this.sessionCache.getOrCreateSession(
tag,
metadata,
agentState,
namespace,
model,
effort,
modelReasoningEffort,
requestedId
)
}
getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine {
return this.machineCache.getOrCreateMachine(id, metadata, runnerState, namespace)
}
async sendMessage(
sessionId: string,
payload: {
text: string
localId?: string | null
attachments?: Array<{
id: string
filename: string
mimeType: string
size: number
path: string
previewUrl?: string
}>
sentFrom?: 'telegram-bot' | 'webapp'
scheduledAt?: number | null
}
): Promise<void> {
await this.messageService.sendMessage(sessionId, payload)
this.sessionCache.markMessageQueued(sessionId)
this.sessionCache.recordSessionActivity(sessionId, Date.now())
}
async cancelQueuedMessage(
sessionId: string,
messageId: string
): Promise<CancelQueuedMessageResult> {
return this.messageService.cancelQueuedMessage(sessionId, messageId)
}
sweepImmediateQueuedOnSessionEnd(sessionId: string, invokedAt: number): void {
this.messageService.sweepImmediateQueuedOnSessionEnd(sessionId, invokedAt)
}
async approvePermission(
sessionId: string,
requestId: string,
mode?: PermissionMode,
allowTools?: string[],
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort',
answers?: Record<string, string[]> | Record<string, { answers: string[] }>
): Promise<void> {
await this.rpcGateway.approvePermission(sessionId, requestId, mode, allowTools, decision, answers)
}
async denyPermission(
sessionId: string,
requestId: string,
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
): Promise<void> {
await this.rpcGateway.denyPermission(sessionId, requestId, decision)
}
async abortSession(sessionId: string): Promise<void> {
await this.rpcGateway.abortSession(sessionId)
}
async archiveSession(sessionId: string): Promise<void> {
// tiann/hapi#916: when the CLI is already gone (e.g. after a
// hub-restart cascade SIGTERMed the runner but the in-memory
// `active` flag has not been reconciled yet) the kill-RPC throws
// and the route used to surface that as HTTP 500. Treat the
// missing target as a benign condition: still flip the session's
// lifecycleState to `archived` in the hub-side metadata so the
// UI does not see a half-cleaned zombie, and continue to mark
// it inactive in the cache. Real RPC errors (timeout, protocol
// failure) still propagate as 5xx.
try {
await this.rpcGateway.killSession(sessionId)
} catch (error) {
if (error instanceof RpcTargetMissingError) {
this.sessionCache.markSessionArchivedFromHub(sessionId, 'Archived from hub (CLI unreachable)')
} else {
throw error
}
}
this.handleSessionEnd({ sid: sessionId, time: Date.now() })
}
/**
* Apply the post-migration metadata flip in hapi.db:
* - metadata.cursorSessionProtocol = 'acp'
* - session.model = lastUsedModel (if provided)
*
* Returns 'success' on a clean write, 'version-mismatch' if the metadata
* version moved underneath us (caller retries) or 'not-found' if the row
* is gone.
*
* Used by CursorLegacyMigrator after the on-disk transplant + verify
* succeeds. Kept on the engine (not on the migrator) so that all hapi.db
* writes funnel through the existing cache-refresh path.
*/
flipCursorSessionProtocolToAcp(
sessionId: string,
namespace: string,
lastUsedModel: string | null
): { result: 'success' | 'version-mismatch' | 'not-found' | 'session-active' } {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) {
return { result: 'not-found' }
}
// Combined SSE-event payload contract (UX A++): clear the
// `cursorMigrationState='in_progress'` flag in the SAME metadata
// write that flips `cursorSessionProtocol` to 'acp'. The web
// banner keys off `cursorMigrationState`, so a single SSE
// session-updated event swaps both atomically — banner gone,
// protocol flipped — preventing a flicker window where the
// banner has already disappeared but the chat hasn't re-rendered
// to the ACP transport yet.
const carriedMigrationState = latest.metadata.cursorMigrationState
// Atomic active-check inside the same synchronous flip op so
// that a resume cannot land between the migrator's recheck
// and the actual DB update. Bun is single-threaded — once
// we've read `latest` and the row is inactive, no other JS
// can mutate active=true until this method returns. Codex
// review #34 P1 v2: the migrator's recheck is best-effort;
// this is the authoritative gate.
//
// Codex review #34 P2 v5: only block on `active === true`,
// NOT on lifecycleState === 'running'. After a force-archive
// flow archiveSession() synchronously sets active=false but
// the cleanup metadata write that flips lifecycleState
// 'running' → 'archived' may still be in-flight, and that
// is OUR archive completing, not a resume race. The active
// flag is the authoritative live-runner signal.
if (latest.active === true) {
return { result: 'session-active' }
}
// Codex review #34 P2 v7: ALSO clear a stale lifecycleState
// value if it still says 'running'. The migrator now skips
// archiveSession() for stale-running rows (active=false but
// lifecycle=running with --force-archive-running) because
// there's no live runner to archive. Without this fixup,
// successfully migrated stale rows would retain lifecycle=
// running forever and any downstream code that filters by
// lifecycleState (not the cache active flag) would keep
// treating archived ACP sessions as live.
const oldLifecycle = typeof latest.metadata.lifecycleState === 'string' ? latest.metadata.lifecycleState : undefined
const nextMetadata: typeof latest.metadata = {
...latest.metadata,
cursorSessionProtocol: 'acp' as const,
...(oldLifecycle === 'running' ? { lifecycleState: 'archived' as const } : {})
}
// Drop the migration-in-progress flag in the same write (see
// header comment). Safe whether or not it was set.
if (carriedMigrationState !== undefined) {
delete nextMetadata.cursorMigrationState
}
const result = this.store.sessions.updateSessionMetadata(
sessionId,
nextMetadata,
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'version-mismatch') {
this.sessionCache.refreshSession(sessionId)
continue
}
if (result.result !== 'success') {
return { result: 'not-found' }
}
this.sessionCache.refreshSession(sessionId)
if (lastUsedModel && lastUsedModel.trim().length > 0) {
this.store.sessions.setSessionModel(sessionId, lastUsedModel.trim(), namespace, { touchUpdatedAt: false })
this.sessionCache.refreshSession(sessionId)
}
return { result: 'success' }
}
return { result: 'version-mismatch' }
}
/**
* Migrate a single legacy cursor session to ACP. Hub-side; runs on the
* operator's machine (the hub host); see tiann/hapi#824 design.
* Returns a structured outcome (ok or refusal); does not throw.
*/
async migrateLegacyCursorSession(
sessionId: string,
namespace: string,
request: CursorMigrateToAcpRequest
): Promise<CursorMigrateOutcome> {
const session = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!session) {
return { ok: false, sessionId, reason: 'internal_error', message: 'session not found in namespace', durationMs: 0 }
}
const migrator = this.buildMigratorForRequest(request)
return migrator.migrateOne(session, {
keepSource: request.keepSource,
forceArchiveRunning: request.forceArchiveRunning,
skipVerify: request.skipVerify
})
}
private buildMigratorForRequest(_request: CursorMigrateToAcpRequest): CursorLegacyMigrator {
const migratorOpts: CursorLegacyMigratorOptions = {}
return new CursorLegacyMigrator(migratorOpts, {
archiveSession: async (sessionId) => {
await this.archiveSession(sessionId)
},
// NOTE: no awaitSessionInactive injection — handleSessionEnd()
// synchronously sets cache.active=false inside archiveSession,
// so any cache-based poll would return immediately and provide
// false reassurance. The migrator now relies on
// awaitLockRelease's minimum-dwell + SQLite busy-probe +
// size-stability combination instead. Codex review #34 P1 v3.
getCurrentSession: (sessionId, namespace) => {
const s = this.sessionCache.getSessionByNamespace(sessionId, namespace)
if (!s) return null
return {
active: s.active === true,
lifecycleState: typeof s.metadata?.lifecycleState === 'string' ? s.metadata.lifecycleState : undefined,
cursorSessionProtocol: typeof s.metadata?.cursorSessionProtocol === 'string' ? s.metadata.cursorSessionProtocol : undefined
}
},
updateSessionAfterMigrate: (sessionId, namespace, lastUsedModel) => {
const result = this.flipCursorSessionProtocolToAcp(sessionId, namespace, lastUsedModel)
if (result.result === 'success') return { ok: true }
if (result.result === 'session-active') return { ok: false, reason: 'session_active' as const }
return { ok: false, reason: 'version_mismatch_or_missing' as const }
},
// tiann/hapi#872: size sanity check needs to compare HAPI's known
// message history against the candidate legacy store's blob
// count. The store-handle stays on the engine; we only thread
// the count through so the migrator stays free of a direct
// hub.Store dependency.
getHapiMessageCount: (sessionId, _namespace) => {
try {
return this.store.messages.countMessages(sessionId)
} catch (err) {
// tiann/hapi#873 cold review: a silent 0 here trips
// the migrator's "skip sanity" branch and chronically
// disables the floor. Warn so a broken countMessages
// (lock contention pattern, schema drift) is visible
// in journalctl.
console.warn('[auto-migrate] countMessages threw; size sanity skipped', {
sessionId,
err: err instanceof Error ? err.message : String(err)
})
return 0
}
}
})
}
async switchSession(sessionId: string, to: 'remote' | 'local'): Promise<void> {
await this.rpcGateway.switchSession(sessionId, to)
}
async renameSession(sessionId: string, name: string): Promise<void> {
await this.sessionCache.renameSession(sessionId, name)
}
async deleteSession(sessionId: string): Promise<void> {
await this.sessionCache.deleteSession(sessionId)
}
async applySessionConfig(
sessionId: string,
config: {
permissionMode?: PermissionMode
model?: { provider: string; modelId: string } | string | null
modelReasoningEffort?: string | null
effort?: string | null
serviceTier?: string | null
collaborationMode?: CodexCollaborationMode
}
): Promise<void> {
const session = this.sessionCache.getSession(sessionId)
if (!session?.active) {
// For inactive sessions, update the in-memory cache directly without
// an RPC call — the CLI is not running yet. The updated value will be
// passed to the spawned process when the session is resumed.
this.sessionCache.applySessionConfig(sessionId, config)
return
}
const result = await this.rpcGateway.requestSessionConfig(sessionId, config) as Record<string, unknown>
if (!result || typeof result !== 'object') {
throw new Error('Invalid response from session config RPC')
}
const obj = result as {
error?: string
applied?: {
permissionMode?: Session['permissionMode']
model?: Session['model']
modelReasoningEffort?: Session['modelReasoningEffort']
effort?: Session['effort']
serviceTier?: Session['serviceTier']
collaborationMode?: Session['collaborationMode']
}
}
if (typeof obj.error === 'string' && obj.error.trim().length > 0) {
throw new Error(obj.error)
}
const applied = obj.applied
if (!applied || typeof applied !== 'object') {
throw new Error(`Missing applied session config, got: ${JSON.stringify(result)}`)
}
const requestedKeys = Object.keys(config) as Array<keyof typeof config>
for (const key of requestedKeys) {
if (!(key in applied)) {
throw new Error(`Session did not apply ${key}`)
}
}
this.sessionCache.applySessionConfig(sessionId, applied)
}
async spawnSession(
machineId: string,
directory: string,
agent: AgentFlavor = 'claude',
model?: string,
modelReasoningEffort?: string,
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string,
resumeSessionId?: string,
effort?: string,
permissionMode?: PermissionMode,
serviceTier?: string,
existingSessionId?: string,
collaborationMode?: CodexCollaborationMode
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
return await this.rpcGateway.spawnSession(
machineId,
directory,
agent,
model,
modelReasoningEffort,
yolo,
sessionType,
worktreeName,
resumeSessionId,
effort,
permissionMode,
serviceTier,
existingSessionId,
collaborationMode
)
}
private resolveFlavor(session: Session): AgentFlavor {
const flavor = session.metadata?.flavor
return isKnownFlavor(flavor) ? flavor : 'claude'
}
private resolveAgentResumeId(session: Session, namespace: string): string | null {
const metadata = session.metadata
if (!metadata) {
return null
}
const flavor = this.resolveFlavor(session)
if (flavor === 'codex') {
return metadata.codexSessionId ?? this.recoverCodexSessionIdFromMessages(session.id, namespace)
}
if (flavor === 'gemini') return metadata.geminiSessionId ?? null
if (flavor === 'opencode') return metadata.opencodeSessionId ?? null
if (flavor === 'grok') return metadata.grokSessionId ?? null
if (flavor === 'cursor') return metadata.cursorSessionId ?? null
if (flavor === 'kimi') return metadata.kimiSessionId ?? null
if (flavor === 'pi') return metadata.piSessionId ?? null
return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace)
}
resolveLocalResumeTarget(sessionId: string, namespace: string): LocalResumeTargetResult {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
return {
type: 'error',
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
}
}
const session = access.session
const metadata = session.metadata
if (!metadata || typeof metadata.path !== 'string' || metadata.path.length === 0) {
return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' }
}
const agentSessionId = this.resolveAgentResumeId(session, namespace)
if (!agentSessionId) {
return {
type: 'error',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
}
}
return {
type: 'success',
target: {
sessionId: access.sessionId,
flavor: this.resolveFlavor(session),
directory: metadata.path,
machineId: metadata.machineId,
host: metadata.host,
active: session.active,
thinking: session.thinking,
controlledByUser: session.agentState?.controlledByUser === true,
agentSessionId,
model: session.model ?? null,
effort: session.effort ?? null,
modelReasoningEffort: session.modelReasoningEffort ?? null,
permissionMode: session.permissionMode,
collaborationMode: session.collaborationMode
}
}
}
listLocalResumableSessions(namespace: string, opts?: { machineId?: string }): ResumableSession[] {
return this.getSessionsByNamespace(namespace)
.map((session) => this.resolveLocalResumeTarget(session.id, namespace))
.filter((result): result is { type: 'success'; target: LocalResumeTarget } => result.type === 'success')
.map(({ target }) => {
const session = this.getSessionByNamespace(target.sessionId, namespace)
return {
sessionId: target.sessionId,
flavor: target.flavor,
directory: target.directory,
machineId: target.machineId,
host: target.host,
active: target.active,
thinking: target.thinking,
controlledByUser: target.controlledByUser,
agentSessionId: target.agentSessionId,
model: target.model,
effort: target.effort,
modelReasoningEffort: target.modelReasoningEffort,
permissionMode: target.permissionMode,
collaborationMode: target.collaborationMode,
updatedAt: session?.updatedAt ?? 0,
name: session?.metadata?.name,
summary: session?.metadata?.summary?.text,
firstUserMessage: this.resolveFirstUserMessage(target.sessionId)
}
})
.filter((session) => !opts?.machineId || session.machineId === opts.machineId)
.sort((a, b) => b.updatedAt - a.updatedAt)
}
private resolveFirstUserMessage(sessionId: string): string | undefined {
for (const message of this.store.messages.getFirstMessages(sessionId, 50)) {
const roleWrapped = unwrapRoleWrappedRecordEnvelope(message.content)
const text = roleWrapped?.role === 'user'
? extractUserMessageText(roleWrapped.content)
: roleWrapped?.role === 'agent'
? extractClaudeUserMessageTextFromAgentOutput(roleWrapped.content)
: undefined
if (text) return text
}
return undefined
}
/**
* tiann/hapi#824 — sync-on-open auto-migration. Returns the (possibly
* refreshed-from-cache) session. If the session is a legacy stream-json
* Cursor session AND the env flag is on, attempts a transplant migration
* synchronously before the caller spawns the runner.
*
* The migrator's verify probe runs in an isolated HAPI_HOME (see
* verifyInTempHome), so this method is safe to call even when other ACP
* transports are alive on the host: per tiann/hapi#832, two `agent acp`
* processes coexist on the same host without conflict, and swear01's
* tiann/hapi#835 refactors the agent-acp-active lock into a cross-process
* refcount that explicitly supports this. We rely on #835 landing before
* this PR — see manifest layer ordering and the dependency note in
* PR #34's body.
*
* Failure modes are all soft — the session is returned unchanged and the
* caller proceeds with the legacy launcher.
*/
private async maybeAutoMigrateLegacyCursorSession(session: Session, namespace: string): Promise<Session> {
const md = session.metadata
const flagRaw = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE?.trim().toLowerCase() ?? ''
console.info('[auto-migrate] considering', {
sessionId: session.id,
flavor: md?.flavor ?? null,
proto: md?.cursorSessionProtocol ?? null,
hasCursorId: typeof md?.cursorSessionId === 'string' && md.cursorSessionId.length > 0,
envFlag: flagRaw === '' ? '(unset; default on)' : flagRaw
})
if (flagRaw === '0' || flagRaw === 'false' || flagRaw === 'no' || flagRaw === 'off') {
console.info('[auto-migrate] skipped: env flag disabled', { sessionId: session.id })
return session
}
if (!md || md.flavor !== 'cursor') {
console.info('[auto-migrate] skipped: not a cursor session', { sessionId: session.id, flavor: md?.flavor ?? null })
return session
}
if (md.cursorSessionProtocol === 'acp') {
console.info('[auto-migrate] skipped: already ACP', { sessionId: session.id })
return session
}
if (typeof md.cursorSessionId !== 'string' || md.cursorSessionId.length === 0) {
console.info('[auto-migrate] skipped: no cursorSessionId', { sessionId: session.id })
return session
}
console.info('[auto-migrate] starting transplant', { sessionId: session.id, cursorSessionId: md.cursorSessionId })
// UX A++: surface the migration to the user via a banner in the
// web UI. We set `cursorMigrationState='in_progress'` on the row
// BEFORE the long-running transplant; the sessionCache.refresh()
// call emits a `session-updated` SSE event the web client uses to
// render the banner. The flag is cleared on success by the same
// metadata write that flips cursorSessionProtocol to 'acp' (see
// flipCursorSessionProtocolToAcp) so the banner disappears in the
// same render tick the chat re-renders as ACP — no flicker. On
// failure we clear the flag explicitly in the catch path below.
const flagSet = this.setCursorMigrationStateInProgress(session.id, namespace)
let bannerCleanupNeeded = flagSet
try {
const migrator = this.buildMigratorForRequest({})
// Codex #34 P2 (round 13): for inactive rows whose metadata
// still reads `lifecycleState === 'running'` (e.g. orphaned by
// a hub crash where the lifecycle transition didn't land), the
// migrator's preflight refuses with `running_refused` unless
// `forceArchiveRunning` is true. resumeSession's caller-side
// guard already ensured `session.active === false` (see the
// early-return above), so we know there's no runner to yank;
// the `running` lifecycle is stale metadata, not a live agent.
// This is exactly the stale-row case the sync-on-open path is
// meant to clean up — refusing here would defeat the whole
// point and silently fall back to the legacy launcher forever.
const outcome = await migrator.migrateOne(session, { forceArchiveRunning: true })
if (outcome.ok) {
console.info('[auto-migrate] success', {
sessionId: session.id,
cursorSessionId: md.cursorSessionId,
acpSessionId: outcome.acpSessionId,
durationMs: outcome.durationMs,
sourceRemoved: outcome.sourceRemoved,
replayNotifications: outcome.replayNotifications,
lastUsedModelPreserved: outcome.lastUsedModelPreserved
})
// Successful migration already cleared the flag atomically
// in flipCursorSessionProtocolToAcp; skip the cleanup write.
bannerCleanupNeeded = false
const refreshed = this.sessionCache.getSessionByNamespace(session.id, namespace)
if (refreshed) return refreshed
return session
}
// tiann/hapi#872: ambiguous source store OR size-mismatch
// means the migrator refused to transplant likely-alien
// content. Surface this to the UI banner instead of silently
// clearing the in-progress flag, so the operator can act
// (verify which workspace-hash drawer holds the real history,
// delete the stale siblings, retry) rather than have us
// silently fall back to the legacy launcher and pretend the
// ambiguity never happened.
if (outcome.reason === 'ambiguous_legacy_store' || outcome.reason === 'size_mismatch') {
console.warn('[auto-migrate] refusing to transplant; surfacing ambiguous banner', {
sessionId: session.id,
reason: outcome.reason,
message: outcome.message
})
const promoted = this.setCursorMigrationStateAmbiguous(session.id, namespace)
if (promoted) {
// We replaced the in-progress flag with the
// ambiguous flag; the cleanup write below would
// wipe both, so suppress it.
bannerCleanupNeeded = false
} else {
// Promotion to 'ambiguous' failed (cache miss, repeated
// version-mismatch, or non-version write failure). The
// operator-facing warning above already fired; the
// finally{} block will fall through to clear the
// in-progress flag so the user is not left with a
// permanent "Upgrading..." banner. Log so the gap is
// diagnosable from journalctl. tiann/hapi#872.
console.warn('[auto-migrate] failed to promote cursorMigrationState to "ambiguous"; banner will clear via cleanup', {
sessionId: session.id,
reason: outcome.reason
})
}
return session
}
// Soft fail — log and let the legacy launcher handle it.
console.info('[auto-migrate] legacy cursor session left as stream-json', {
sessionId: session.id,
reason: outcome.reason,
message: outcome.message
})
} catch (err) {
console.warn('[auto-migrate] unexpected error; falling back to legacy launcher', {
sessionId: session.id,
err: err instanceof Error ? err.message : String(err)
})
} finally {
// Failure or exception path: clear the in-progress banner flag
// so the user isn't left with a permanent "Upgrading..." banner
// even though we silently fell back to the legacy launcher.
if (bannerCleanupNeeded) {
this.clearCursorMigrationState(session.id, namespace)
}
}
return session
}
/**
* Replace `cursorMigrationState='in_progress'` with `'ambiguous'` so
* the web banner can switch from "Upgrading..." to "Manual resolution
* needed". Returns true if the new flag persisted. tiann/hapi#872.
*/
private setCursorMigrationStateAmbiguous(sessionId: string, namespace: string): boolean {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) return false
if (latest.metadata.cursorMigrationState === 'ambiguous') return true
const nextMetadata = { ...latest.metadata, cursorMigrationState: 'ambiguous' as const }
const result = this.store.sessions.updateSessionMetadata(
sessionId,
nextMetadata,
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return true
}
if (result.result === 'version-mismatch') {
this.sessionCache.refreshSession(sessionId)
continue
}
return false
}
return false
}
/**
* Set `metadata.cursorMigrationState='in_progress'` on the session row
* with a single retry on version-mismatch. Returns true if the flag was
* persisted (so the caller knows the finally-cleanup is required), false
* if the write failed entirely — in which case the banner never appeared
* and there's nothing to clean up. UX A++ helper for the auto-migrate
* banner; see maybeAutoMigrateLegacyCursorSession.
*/
private setCursorMigrationStateInProgress(sessionId: string, namespace: string): boolean {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) return false
if (latest.metadata.cursorMigrationState === 'in_progress') return true
const nextMetadata = { ...latest.metadata, cursorMigrationState: 'in_progress' as const }
const result = this.store.sessions.updateSessionMetadata(
sessionId,
nextMetadata,
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return true
}
if (result.result === 'version-mismatch') {
this.sessionCache.refreshSession(sessionId)
continue
}
return false
}
return false
}
/**
* Clear `metadata.cursorMigrationState` (failure / exception cleanup).
* Idempotent; safe to call when the flag was never set. UX A++ helper.
*/
private clearCursorMigrationState(sessionId: string, namespace: string): void {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) return
if (latest.metadata.cursorMigrationState === undefined) return
const nextMetadata: typeof latest.metadata = { ...latest.metadata }
delete nextMetadata.cursorMigrationState
const result = this.store.sessions.updateSessionMetadata(
sessionId,
nextMetadata,
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return
}
if (result.result === 'version-mismatch') {
this.sessionCache.refreshSession(sessionId)
continue
}
return
}
}
/** Inactive session with directory path but no agent thread and no prior user turn. */
private canFreshSpawnNeverStartedSession(session: Session, sessionId: string, namespace: string): boolean {
const metadata = session.metadata
if (!metadata || typeof metadata.path !== 'string' || metadata.path.length === 0) {
return false
}
if (this.resolveAgentResumeId(session, namespace)) {
return false
}
return this.store.messages.getFirstMessages(sessionId, 1).length === 0
}
async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise<ResumeSessionResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
return {
type: 'error',
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
}
}
const initialSession = access.session
if (initialSession.active) {
return { type: 'success', sessionId: access.sessionId }
}
// tiann/hapi#824 — invisible, automatic, per-session ACP migration on
// first open. If this is a legacy stream-json Cursor session and we
// can safely migrate it right now (no other agent acp transport
// would block the post-migration ACP launcher), run the transplant
// synchronously before resuming. The user sees the regular session
// loading state for ~35s longer; the session opens as ACP.
const session = await this.maybeAutoMigrateLegacyCursorSession(initialSession, namespace)
const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace)
let flavor: AgentFlavor
let resumeToken: string | undefined
let directory: string
if (targetResult.type === 'success') {
flavor = targetResult.target.flavor
resumeToken = targetResult.target.agentSessionId
directory = targetResult.target.directory
} else if (
targetResult.code === 'resume_unavailable'
&& this.canFreshSpawnNeverStartedSession(session, access.sessionId, namespace)
) {
const metadata = session.metadata!
flavor = this.resolveFlavor(session)
resumeToken = undefined
directory = metadata.path
} else {
return targetResult
}
const metadata = session.metadata!
const targetMachine = this.resolveOnlineMachineForSession(
session,
namespace,
{ strictMachineId: flavor === 'cursor' }
)
if (!targetMachine) {
return { type: 'error', message: 'No machine online', code: 'no_machine_online' }
}
if (flavor === 'pi' && resumeToken && targetMachine.runnerState?.capabilities?.piExistingSessionResume !== true) {
return { type: 'error', message: 'Pi resume requires an upgraded runner', code: 'resume_failed' }
}
const requiresPiNativeReady = flavor === 'pi' && resumeToken !== undefined
if (requiresPiNativeReady) {
if (this.isPiResumeBlocked(access.sessionId)) {
return { type: 'error', message: 'Pi resume is already in progress', code: 'resume_failed' }
}
this.piResumeInFlightIds.add(access.sessionId)
this.sessionReadyIds.delete(access.sessionId)
try {
await this.writePiResumeAttempt(access.sessionId, namespace, {
state: 'resuming',
machineId: targetMachine.id,
startedAt: Date.now(),
archiveSnapshot: {
lifecycleState: metadata.lifecycleState,
lifecycleStateSince: metadata.lifecycleStateSince,
archivedBy: metadata.archivedBy,
archiveReason: metadata.archiveReason,
},
})
} catch {
this.piResumeInFlightIds.delete(access.sessionId)
return { type: 'error', message: 'Failed to record Pi resume attempt', code: 'resume_failed' }
}
}
if (flavor === 'cursor' && resumeToken) {
try {
const chatStatus = await this.rpcGateway.getCursorChatStoreStatus(
targetMachine.id,
directory,
resumeToken,
metadata.homeDir
)
if (!chatStatus.onDisk) {
return {
type: 'error',
message: 'Cursor chat data is no longer available on the recorded machine',
code: 'resume_unavailable'
}
}
} catch (error) {
return {
type: 'error',
message: error instanceof Error ? error.message : 'Failed to inspect Cursor chat store',
code: 'resume_failed'
}
}
}
const metadataPermissionMode = session.metadata?.preferredPermissionMode
const preferredPermissionMode = metadataPermissionMode === 'yolo' && opts?.permissionMode === 'default'
? metadataPermissionMode
: opts?.permissionMode
?? session.permissionMode
?? metadataPermissionMode
let piResumeSucceeded = false
try {
const spawnResult = await this.rpcGateway.spawnSession(
targetMachine.id,
directory,
flavor,
session.model ?? undefined,
session.modelReasoningEffort ?? undefined,
undefined,
undefined,
undefined,
resumeToken,
session.effort ?? undefined,
preferredPermissionMode,
session.serviceTier ?? undefined,
access.sessionId,
session.collaborationMode ?? undefined
)
if (spawnResult.type !== 'success') {
if (requiresPiNativeReady) {
const stopped = await this.terminateInPlacePiResume(
targetMachine.id,
access.sessionId,
namespace
)
if (!stopped) {
await this.quarantinePiResume(access.sessionId, namespace, targetMachine.id)
return {
type: 'error',
message: spawnResult.message,
code: 'resume_failed',
rollbackSafe: false,
}
}
}
return { type: 'error', message: spawnResult.message, code: 'resume_failed' }
}
if (requiresPiNativeReady && spawnResult.sessionId !== access.sessionId) {
const removed = await this.terminateUnexpectedPiTemp(
targetMachine.id,
spawnResult.sessionId,
access.sessionId,
namespace
)
return {
type: 'error',
message: removed
? 'Pi runner created an unexpected session; upgrade the runner and retry'
: 'Pi runner created an unexpected live session; upgrade the runner and retry',
code: 'resume_failed'
}
}
const becameActive = await this.waitForSessionActive(spawnResult.sessionId)
if (!becameActive) {
if (requiresPiNativeReady) {
const inactive = await this.terminateInPlacePiResume(
targetMachine.id,
access.sessionId,
namespace
)
if (!inactive) {
await this.quarantinePiResume(access.sessionId, namespace, targetMachine.id)
return { type: 'error', message: 'Pi resume failed and the child is still active', code: 'resume_failed', rollbackSafe: false }
}
}
return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' }
}
const needsReadyBeforeSuccess = requiresPiNativeReady
|| (
spawnResult.sessionId !== access.sessionId
&& flavor === 'cursor'
&& metadata.cursorSessionProtocol === 'acp'
)
if (needsReadyBeforeSuccess) {
const readyResult = await this.waitForSessionReady(spawnResult.sessionId)
if (readyResult !== 'ready') {
if (requiresPiNativeReady && readyResult !== 'ended') {
const inactive = await this.terminateInPlacePiResume(
targetMachine.id,
access.sessionId,
namespace
)
if (!inactive) {
await this.quarantinePiResume(access.sessionId, namespace, targetMachine.id)
return { type: 'error', message: 'Pi native resume timed out and the child is still active', code: 'resume_failed', rollbackSafe: false }
}
}
const message = flavor === 'pi'
? readyResult === 'ended'
? 'Pi session ended before native resume completed'
: 'Pi session failed to become native-ready'
: readyResult === 'ended'
? 'Session ended before Cursor ACP load completed'
: 'Session failed to become ready'
return { type: 'error', message, code: 'resume_failed' }
}
}
if (spawnResult.sessionId !== access.sessionId) {
const oldSession = this.sessionCache.getSessionByNamespace(access.sessionId, namespace)
if (oldSession) {
try {
await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to merge resumed session'
return { type: 'error', message, code: 'resume_failed' }
}
}
}
this.sessionCache.markSessionActive(spawnResult.sessionId)
piResumeSucceeded = true
if (requiresPiNativeReady) await this.writePiResumeAttempt(access.sessionId, namespace, null)
return { type: 'success', sessionId: spawnResult.sessionId }
} finally {
if (requiresPiNativeReady) {
this.piResumeInFlightIds.delete(access.sessionId)
if (!piResumeSucceeded && this.sessionCache.getSession(access.sessionId)?.metadata?.piResumeAttempt?.state === 'resuming') {
await this.writePiResumeAttempt(access.sessionId, namespace, null, true).catch(() => {})
}
if (piResumeSucceeded) {
this.triggerDedupIfNeeded(access.sessionId)
}
}
}
}
/**
* Revive an archived session so the web UI can reach it again.
*
* Behaviour:
* - Active session: idempotent no-op (`resumed: false`).
* - Non-archived inactive session: forwards to `resumeSession` without touching metadata.
* - Archived session: validates that the agent has enough metadata to resume (Cursor
* sessions require a `cursorSessionId` once they have any messages), clears the
* archive metadata (`lifecycleState`, `archivedBy`, `archiveReason`), defaults the
* Cursor protocol to `stream-json` for pre-#799 sessions, then forwards to
* `resumeSession`. The CLI's `sessionFactory` will re-stamp `lifecycleState='running'`
* when it boots, so we do not pre-write that here.
*
* Failure rollback: if `resumeSession` fails (no machine online, spawn timeout, etc.)
* the archive snapshot is restored so the operator can retry without losing
* `archiveReason`/`archivedBy`/`lifecycleState` and the UI still shows the row as
* archived rather than a dangling inactive non-archived ghost.
*
* Returns `incomplete` (HTTP 422 from the route layer) when the agent metadata
* needed to resume is missing.
*/
async reopenSession(sessionId: string, namespace: string): Promise<ReopenSessionResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
return {
type: 'error',
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
}
}
const session = access.session
const metadata = session.metadata
if (metadata?.flavor === 'pi' && this.isPiResumeBlocked(access.sessionId)) {
if (session.active) {
return { type: 'error', message: 'Pi resume is already in progress', code: 'resume_failed' }
}
if (metadata.piResumeAttempt && !this.piResumeInFlightIds.has(access.sessionId)) {
const reconciled = await this.reconcilePersistedPiResumeAttempt(session)
return {
type: 'error',
message: reconciled
? 'Previous Pi resume attempt was cleaned up; retry'
: 'Pi resume is already in progress',
code: 'resume_failed'
}
}
return { type: 'error', message: 'Pi resume is already in progress', code: 'resume_failed' }
}
if (session.active) {
return { type: 'success', sessionId: access.sessionId, resumed: false }
}
const isArchived = metadata?.lifecycleState === 'archived'
if (isArchived && metadata) {
if (metadata.flavor === 'cursor' && !metadata.cursorSessionId) {
const hasMessages = this.store.messages.getFirstMessages(access.sessionId, 1).length > 0
if (hasMessages) {
return {
type: 'incomplete',
message: 'Cursor session id is missing from metadata; reopen requires the original cursor chat id',
missing: ['cursorSessionId']
}
}
}
const archiveSnapshot = {
lifecycleState: metadata.lifecycleState,
archivedBy: metadata.archivedBy,
archiveReason: metadata.archiveReason,
lifecycleStateSince: metadata.lifecycleStateSince
}
let applied: { cursorSessionProtocol?: 'acp' | 'stream-json' } = {}
// Pi reuses the original HAPI row. Keep its archive snapshot persisted
// until the CLI successfully bootstraps that row as running; this avoids
// an inactive, non-archived gap if the Hub restarts before spawn.
if (metadata.flavor !== 'pi') {
try {
applied = await this.sessionCache.clearSessionArchiveMetadata(access.sessionId)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to clear archive metadata'
return { type: 'error', message, code: 'metadata_conflict' }
}
}
const resumeResult = await this.resumeSession(access.sessionId, namespace)
if (resumeResult.type === 'error') {
// Never restore archived metadata over a live Pi child. A live
// row blocks retry by itself and must remain visible as active.
const current = this.sessionCache.getSessionByNamespace(access.sessionId, namespace)
if (resumeResult.rollbackSafe !== false && !current?.active) {
try {
await this.sessionCache.restoreSessionArchiveMetadata(access.sessionId, archiveSnapshot)
} catch {
// Swallow restore failures - the resume error is the more important signal.
}
}
return resumeResult
}
return {
type: 'success',
sessionId: resumeResult.sessionId,
resumed: true,
...(applied.cursorSessionProtocol ? { cursorSessionProtocol: applied.cursorSessionProtocol } : {})
}
}
// Not active and not archived (e.g. brand-new session that has not yet connected,
// or one that ended without writing archive metadata). Forward to resume so the
// operator still gets one-click revival.
const resumeResult = await this.resumeSession(access.sessionId, namespace)
if (resumeResult.type === 'error') {
return resumeResult
}
return { type: 'success', sessionId: resumeResult.sessionId, resumed: true }
}
async handoffSessionToLocal(sessionId: string, namespace: string): Promise<LocalHandoffResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
return {
type: 'error',
message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found',
code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found'
}
}
if (!access.session.active) {
return { type: 'success' }
}
if (access.session.agentState?.controlledByUser === true) {
return {
type: 'error',
message: 'Session is already controlled by a local terminal',
code: 'already_local'
}
}
try {
await this.rpcGateway.handoffSessionToLocal(access.sessionId)
} catch (error) {
return {
type: 'error',
message: error instanceof Error ? error.message : String(error),
code: 'handoff_failed'
}
}
const inactive = await this.waitForSessionInactive(access.sessionId)
if (!inactive) {
return {
type: 'error',
message: 'Timed out waiting for remote session to hand off',
code: 'handoff_failed'
}
}
return { type: 'success' }
}
private recoverClaudeSessionIdFromMessages(sessionId: string, namespace: string): string | null {
const messages = this.messageService.getMessages(sessionId, 200)
for (let i = messages.length - 1; i >= 0; i -= 1) {
const found = this.extractClaudeSessionId(messages[i].content)
if (!found) continue
return this.persistRecoveredAgentSessionId(sessionId, namespace, 'claudeSessionId', found)
}
return null
}
private recoverCodexSessionIdFromMessages(sessionId: string, namespace: string): string | null {
const messages = this.messageService.getMessages(sessionId, 200)
for (let i = messages.length - 1; i >= 0; i -= 1) {
const content = messages[i].content
if (this.isCodexContextResetMessage(content)) return null
const found = this.extractCodexParentThreadId(content)
if (!found) continue
return this.persistRecoveredAgentSessionId(sessionId, namespace, 'codexSessionId', found)
}
return null
}
private isCodexContextResetMessage(value: unknown): boolean {
const message = asRecord(value)
const content = asRecord(message?.content)
const event = asRecord(content?.data)
return message?.role === 'agent'
&& content?.type === 'event'
&& event?.type === 'message'
&& event.message === 'Context was reset'
}
private extractCodexParentThreadId(value: unknown): string | null {
const message = asRecord(value)
const content = asRecord(message?.content)
const event = asRecord(content?.data)
if (message?.role !== 'agent' || content?.type !== 'codex' || !event) {
return null
}
const scope = asRecord(event.scope)
const roles = [event.scope_role, event.scopeRole, scope?.role]
.filter((role) => role !== undefined)
if (roles.length === 0 || roles.some((role) => role !== 'parent')) {
return null
}
const threadIds: string[] = []
for (const threadId of [event.thread_id, event.threadId, scope?.thread_id, scope?.threadId]) {
if (threadId === undefined) continue
const normalized = this.normalizeAgentSessionId(threadId)
if (!normalized) return null
threadIds.push(normalized)
}
const uniqueThreadIds = [...new Set(threadIds)]
return uniqueThreadIds.length === 1 ? uniqueThreadIds[0] : null
}
private extractClaudeSessionId(value: unknown): string | null {
if (!value || typeof value !== 'object') {
return null
}
const obj = value as Record<string, unknown>
const direct = this.normalizeAgentSessionId(obj.session_id) ?? this.normalizeAgentSessionId(obj.sessionId)
if (direct) {
return direct
}
const content = obj.content
if (content && typeof content === 'object') {
const found = this.extractClaudeSessionId(content)
if (found) return found
}
const data = obj.data
if (data && typeof data === 'object') {
const found = this.extractClaudeSessionId(data)
if (found) return found
}
return null
}
private normalizeAgentSessionId(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)
? trimmed
: null
}
private persistRecoveredAgentSessionId(
sessionId: string,
namespace: string,
field: 'claudeSessionId' | 'codexSessionId',
agentSessionId: string
): string {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) return agentSessionId
const existingAgentSessionId = latest.metadata[field]
if (typeof existingAgentSessionId === 'string') {
return existingAgentSessionId
}
const result = this.store.sessions.updateSessionMetadata(
sessionId,
{ ...latest.metadata, [field]: agentSessionId },
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return agentSessionId
}
if (result.result !== 'version-mismatch') {
return agentSessionId
}
this.sessionCache.refreshSession(sessionId)
const refreshed = this.sessionCache.getSessionByNamespace(sessionId, namespace)
const authoritativeAgentSessionId = refreshed?.metadata?.[field]
if (typeof authoritativeAgentSessionId === 'string') {
return authoritativeAgentSessionId
}
}
return agentSessionId
}
private hasSameAgentSessionIds(
prev: Session['metadata'] | null,
next: NonNullable<Session['metadata']>
): boolean {
return (prev?.codexSessionId ?? null) === (next.codexSessionId ?? null)
&& (prev?.claudeSessionId ?? null) === (next.claudeSessionId ?? null)
&& (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null)
&& (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null)
&& (prev?.grokSessionId ?? null) === (next.grokSessionId ?? null)
&& (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null)
&& (prev?.piSessionId ?? null) === (next.piSessionId ?? null)
&& (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null)
}
private canRunCursorDedup(session: Session): boolean {
if (this.piResumeInFlightIds.has(session.id) || this.piResumeQuarantinedIds.has(session.id)) return false
if (session.metadata?.piResumeAttempt) return false
if (this.sessionCache.getSessions().some((candidate) => candidate.metadata?.piResumeAttempt?.childSessionId === session.id)) return false
const piSessionId = session.metadata?.piSessionId
if (piSessionId && this.sessionCache.getSessions().some((candidate) =>
candidate.id !== session.id
&& candidate.namespace === session.namespace
&& candidate.metadata?.piResumeAttempt !== undefined
&& candidate.metadata.piSessionId === piSessionId
)) return false
if (session.metadata?.flavor !== 'cursor') {
return true
}
if (session.metadata?.cursorSessionProtocol !== 'acp') {
return true
}
return this.sessionReadyIds.has(session.id)
}
private async terminateInPlacePiResume(
machineId: string,
sessionId: string,
namespace: string
): Promise<boolean> {
const existingAttempt = this.sessionCache.getSession(sessionId)?.metadata?.piResumeAttempt
await this.writePiResumeAttempt(sessionId, namespace, {
...existingAttempt,
state: 'terminating',
machineId,
startedAt: Date.now(),
})
let status: 'stopped' | 'already_gone' | 'still_alive'
try {
status = await this.rpcGateway.stopRunnerSession(machineId, sessionId)
} catch {
status = 'still_alive'
}
await new Promise((resolve) => setTimeout(resolve, 0))
const session = this.sessionCache.refreshSession(sessionId) ?? this.sessionCache.getSession(sessionId)
const attemptClearedByEnd = session?.metadata?.piResumeAttempt === undefined
if (status === 'still_alive') {
if (attemptClearedByEnd) return true
return false
}
if (session?.active) this.handleSessionEnd({ sid: sessionId, time: Date.now(), reason: 'error' })
await this.writePiResumeAttempt(sessionId, namespace, null, true).catch(() => {})
return true
}
private async terminateUnexpectedPiTemp(
machineId: string,
sessionId: string,
originalSessionId: string,
namespace: string
): Promise<boolean> {
this.piUnexpectedTempOriginalIds.set(sessionId, originalSessionId)
const existingAttempt = this.sessionCache.getSession(originalSessionId)?.metadata?.piResumeAttempt
await this.writePiResumeAttempt(originalSessionId, namespace, {
...existingAttempt,
state: 'terminating',
machineId,
startedAt: Date.now(),
childSessionId: sessionId,
})
let status: 'stopped' | 'already_gone' | 'still_alive'
try {
status = await this.rpcGateway.stopRunnerSession(machineId, sessionId)
} catch {
status = 'still_alive'
}
await new Promise((resolve) => setTimeout(resolve, 0))
const session = this.sessionCache.refreshSession(sessionId) ?? this.sessionCache.getSession(sessionId)
const original = this.sessionCache.refreshSession(originalSessionId) ?? this.sessionCache.getSession(originalSessionId)
const attemptClearedByEnd = original?.metadata?.piResumeAttempt === undefined
if (status === 'still_alive' && !attemptClearedByEnd) {
await this.writePiResumeAttempt(originalSessionId, namespace, {
...existingAttempt,
state: 'quarantined',
machineId,
startedAt: Date.now(),
childSessionId: sessionId,
})
return false
}
if (session?.active) this.handleSessionEnd({ sid: sessionId, time: Date.now(), reason: 'error' })
const remaining = this.sessionCache.getSession(sessionId)
if (remaining && !remaining.active) await this.sessionCache.deleteSession(sessionId)
this.piUnexpectedTempOriginalIds.delete(sessionId)
await this.writePiResumeAttempt(originalSessionId, namespace, null, true).catch(() => {})
return true
}
private async quarantinePiResume(sessionId: string, namespace: string, machineId: string): Promise<void> {
this.piResumeQuarantinedIds.add(sessionId)
const existingAttempt = this.sessionCache.getSession(sessionId)?.metadata?.piResumeAttempt
await this.writePiResumeAttempt(sessionId, namespace, {
...existingAttempt,
state: 'quarantined',
machineId,
startedAt: Date.now(),
})
}
private isPiResumeBlocked(sessionId: string): boolean {
const metadataAttempt = this.sessionCache.getSession(sessionId)?.metadata?.piResumeAttempt
return this.piResumeInFlightIds.has(sessionId)
|| this.piResumeQuarantinedIds.has(sessionId)
|| metadataAttempt !== undefined
|| [...this.piUnexpectedTempOriginalIds.values()].includes(sessionId)
}
private async writePiResumeAttempt(
sessionId: string,
namespace: string,
attempt: PiResumeAttempt | null,
restoreArchive = false
): Promise<void> {
for (let i = 0; i < 5; i += 1) {
const current = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId)
if (!current?.metadata) return
const next = { ...current.metadata }
if (attempt) next.piResumeAttempt = attempt
else {
const snapshot = current.metadata.piResumeAttempt?.archiveSnapshot
delete next.piResumeAttempt
if (restoreArchive && snapshot) {
if (snapshot.lifecycleState === undefined) delete next.lifecycleState
else next.lifecycleState = snapshot.lifecycleState
if (snapshot.lifecycleStateSince === undefined) delete next.lifecycleStateSince
else next.lifecycleStateSince = snapshot.lifecycleStateSince
if (snapshot.archivedBy === undefined) delete next.archivedBy
else next.archivedBy = snapshot.archivedBy
if (snapshot.archiveReason === undefined) delete next.archiveReason
else next.archiveReason = snapshot.archiveReason
}
}
const result = this.store.sessions.updateSessionMetadata(sessionId, next, current.metadataVersion, namespace, { touchUpdatedAt: false })
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return
}
if (result.result !== 'version-mismatch') throw new Error('Failed to update Pi resume attempt')
this.sessionCache.refreshSession(sessionId)
}
throw new Error('Pi resume attempt metadata was modified concurrently')
}
private async clearPiAttemptForEndedSession(endedSessionId: string, restoreArchive: boolean): Promise<void> {
const ended = this.sessionCache.getSession(endedSessionId)
if (ended?.metadata?.piResumeAttempt) {
await this.writePiResumeAttempt(endedSessionId, ended.namespace, null, restoreArchive).catch(() => {})
return
}
for (const session of this.sessionCache.getSessions()) {
if (session.metadata?.piResumeAttempt?.childSessionId === endedSessionId) {
await this.writePiResumeAttempt(session.id, session.namespace, null, true).catch(() => {})
}
}
}
private async reconcilePersistedPiResumeAttempt(session: Session): Promise<boolean> {
const attempt = session.metadata?.piResumeAttempt
if (!attempt) return true
const childSessionId = attempt.childSessionId ?? session.id
let status: 'stopped' | 'already_gone' | 'still_alive'
try {
status = await this.rpcGateway.stopRunnerSession(attempt.machineId, childSessionId)
} catch {
return false
}
if (status === 'still_alive') return false
const child = this.sessionCache.getSession(childSessionId)
if (child?.active) this.handleSessionEnd({ sid: childSessionId, time: Date.now(), reason: 'error' })
if (childSessionId !== session.id) {
const remaining = this.sessionCache.getSession(childSessionId)
if (remaining && !remaining.active) await this.sessionCache.deleteSession(childSessionId)
}
await this.writePiResumeAttempt(session.id, session.namespace, null, true)
this.piResumeQuarantinedIds.delete(session.id)
this.piUnexpectedTempOriginalIds.delete(childSessionId)
return true
}
private triggerDedupIfNeeded(sessionId: string): void {
const session = this.sessionCache.getSession(sessionId)
if (session?.metadata) {
if (!this.canRunCursorDedup(session)) {
return
}
void this.sessionCache.deduplicateByAgentSessionId(sessionId).catch(() => {
// best-effort: web-side safety net hides remaining duplicates
})
}
}
async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const session = this.getSession(sessionId)
if (session?.active) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return false
}
async waitForSessionReady(sessionId: string, timeoutMs: number = 60_000): Promise<'ready' | 'ended' | 'timeout'> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (this.sessionReadyIds.has(sessionId)) {
return 'ready'
}
const session = this.getSession(sessionId)
if (!session?.active) {
return 'ended'
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return 'timeout'
}
async waitForSessionInactive(sessionId: string, timeoutMs: number = 15_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const session = this.getSession(sessionId)
if (!session?.active) {
return true
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return false
}
async checkPathsExist(machineId: string, paths: string[]): Promise<Record<string, boolean>> {
return await this.rpcGateway.checkPathsExist(machineId, paths)
}
async listMachineDirectory(machineId: string, path: string): Promise<RpcListDirectoryResponse> {
return await this.rpcGateway.listMachineDirectory(machineId, path)
}
async getGitStatus(sessionId: string, cwd?: string): Promise<RpcCommandResponse> {
return await this.rpcGateway.getGitStatus(sessionId, cwd)
}
async getGitDiffNumstat(sessionId: string, options: { cwd?: string; staged?: boolean }): Promise<RpcCommandResponse> {
return await this.rpcGateway.getGitDiffNumstat(sessionId, options)
}
async getGitDiffFile(sessionId: string, options: { cwd?: string; filePath: string; staged?: boolean }): Promise<RpcCommandResponse> {
return await this.rpcGateway.getGitDiffFile(sessionId, options)
}
async readSessionFile(sessionId: string, path: string): Promise<RpcReadFileResponse> {
return await this.rpcGateway.readSessionFile(sessionId, path)
}
async readGeneratedImage(sessionId: string, imageId: string): Promise<RpcGeneratedImageResponse> {
return await this.rpcGateway.readGeneratedImage(sessionId, imageId)
}
async listDirectory(sessionId: string, path: string): Promise<RpcListDirectoryResponse> {
return await this.rpcGateway.listDirectory(sessionId, path)
}
async statFiles(sessionId: string, paths: string[]): Promise<RpcStatFilesResponse> {
return await this.rpcGateway.statFiles(sessionId, paths)
}
async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise<RpcUploadFileResponse> {
return await this.rpcGateway.uploadFile(sessionId, filename, content, mimeType)
}
async deleteUploadFile(sessionId: string, path: string): Promise<RpcDeleteUploadResponse> {
return await this.rpcGateway.deleteUploadFile(sessionId, path)
}
async runRipgrep(sessionId: string, args: string[], cwd?: string): Promise<RpcCommandResponse> {
return await this.rpcGateway.runRipgrep(sessionId, args, cwd)
}
async listSlashCommands(sessionId: string, agent: string): Promise<SlashCommandsResponse> {
return await this.rpcGateway.listSlashCommands(sessionId, agent)
}
async listSkills(sessionId: string, flavor?: string): Promise<{
success: boolean
skills?: Array<{ name: string; description?: string }>
error?: string
}> {
return await this.rpcGateway.listSkills(sessionId, flavor)
}
async listCodexModelsForMachine(machineId: string): Promise<RpcListCodexModelsResponse> {
return await this.rpcGateway.listCodexModelsForMachine(machineId)
}
async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]) {
return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds)
}
async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise<RpcArchiveCodexSessionResponse> {
return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId)
}
async listCursorModelsForSession(sessionId: string): Promise<RpcListCursorModelsResponse> {
return await this.rpcGateway.listCursorModelsForSession(sessionId)
}
async listCursorModelsForMachine(machineId: string): Promise<RpcListCursorModelsResponse> {
return await this.rpcGateway.listCursorModelsForMachine(machineId)
}
async listOpencodeModelsForSession(sessionId: string): Promise<RpcListOpencodeModelsResponse> {
return await this.rpcGateway.listOpencodeModelsForSession(sessionId)
}
async listOpencodeModelsForCwd(machineId: string, cwd: string): Promise<RpcListOpencodeModelsResponse> {
return await this.rpcGateway.listOpencodeModelsForCwd(machineId, cwd)
}
async listGrokModelsForCwd(machineId: string, cwd: string): Promise<RpcListGrokModelsResponse> {
return await this.rpcGateway.listGrokModelsForCwd(machineId, cwd)
}
async listGrokModelsForSession(sessionId: string): Promise<RpcListGrokModelsResponse> {
return await this.rpcGateway.listGrokModelsForSession(sessionId)
}
async listGrokReasoningEffortOptionsForSession(sessionId: string): Promise<RpcListGrokReasoningEffortOptionsResponse> {
return await this.rpcGateway.listGrokReasoningEffortOptionsForSession(sessionId)
}
/** Generic Pi RPC — delegates to rpcGateway.callPiRpc. */
async callPiRpc<T = unknown>(sessionId: string, method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T> {
return await this.rpcGateway.callPiRpc<T>(sessionId, method, params, timeoutMs)
}
async listOpencodeReasoningEffortOptionsForSession(sessionId: string): Promise<RpcListOpencodeReasoningEffortOptionsResponse> {
return await this.rpcGateway.listOpencodeReasoningEffortOptionsForSession(sessionId)
}
}