diff --git a/cli/src/agent/runnerLifecycle.test.ts b/cli/src/agent/runnerLifecycle.test.ts index f5b5b2d6..7923a964 100644 --- a/cli/src/agent/runnerLifecycle.test.ts +++ b/cli/src/agent/runnerLifecycle.test.ts @@ -109,6 +109,24 @@ describe('createRunnerLifecycle', () => { expect(session.flush).toHaveBeenCalledWith({ timeoutMs: 1_000 }); }); + + it('keeps the socket open when confirmed cleanup times out and closes only after a retry is acknowledged', async () => { + const session = createMockApiSession(); + session.flush = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const lc = createRunnerLifecycle({ session, logTag: 'test' }); + lc.setArchiveReason('Cleared by /clear'); + lc.setSessionEndReason('cleared'); + + await expect(lc.cleanupConfirmed({ timeoutMs: 5_000 })).rejects.toMatchObject({ code: 'ETIMEDOUT' }); + expect(session.close).not.toHaveBeenCalled(); + + await expect(lc.cleanupConfirmed({ timeoutMs: 5_000 })).resolves.toBeUndefined(); + expect(session.updateMetadata).toHaveBeenCalledTimes(1); + expect(session.sendSessionDeath).toHaveBeenCalledTimes(1); + expect(session.close).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/cli/src/agent/runnerLifecycle.ts b/cli/src/agent/runnerLifecycle.ts index d6ae1419..0311a286 100644 --- a/cli/src/agent/runnerLifecycle.ts +++ b/cli/src/agent/runnerLifecycle.ts @@ -18,6 +18,7 @@ export type RunnerLifecycle = { hasExplicitSessionEndReason: () => boolean markCrash: (error: unknown) => void cleanup: () => Promise + cleanupConfirmed: (options?: { timeoutMs?: number }) => Promise cleanupAndExit: (codeOverride?: number) => Promise registerProcessHandlers: () => void } @@ -49,6 +50,8 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi let sessionEndReasonExplicit = false let cleanupStarted = false let cleanupPromise: Promise | null = null + let confirmedCleanupPrepared = false + let confirmedCleanupComplete = false const logPrefix = `[${options.logTag}]` @@ -93,6 +96,42 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi return cleanupPromise } + const cleanupConfirmed = async (confirmedOptions?: { timeoutMs?: number }) => { + if (confirmedCleanupComplete) { + return + } + cleanupStarted = true + if (!confirmedCleanupPrepared) { + logger.debug(`${logPrefix} Confirmed cleanup start`) + restoreTerminalState() + options.stopKeepAlive?.() + await options.onBeforeClose?.() + options.session.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archivedBy: 'cli', + archiveReason + })) + options.session.sendSessionDeath(sessionEndReason) + confirmedCleanupPrepared = true + } + + const confirmed = await options.session.flush({ timeoutMs: confirmedOptions?.timeoutMs ?? 5_000 }) + if (!confirmed) { + throw Object.assign(new Error(`${logPrefix} Timed out confirming session archive`), { code: 'ETIMEDOUT' }) + } + + await options.session.close() + confirmedCleanupComplete = true + try { + await options.onAfterClose?.() + } catch (error) { + logger.debug(`${logPrefix} Error during post-cleanup:`, error) + } + logger.debug(`${logPrefix} Confirmed cleanup complete`) + } + const cleanupAndExit = async (codeOverride?: number) => { if (codeOverride !== undefined) { exitCode = codeOverride @@ -176,6 +215,7 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi hasExplicitSessionEndReason, markCrash, cleanup, + cleanupConfirmed, cleanupAndExit, registerProcessHandlers } diff --git a/cli/src/api/api.extraHeaders.test.ts b/cli/src/api/api.extraHeaders.test.ts index c72e9a7c..67f6f42a 100644 --- a/cli/src/api/api.extraHeaders.test.ts +++ b/cli/src/api/api.extraHeaders.test.ts @@ -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' diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 275c7196..4a925594 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -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 { + const response = await axios.post( + `${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 { + const response = await axios.post( + `${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 { + const response = await axios.post( + `${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 { + const response = await axios.post( + `${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) } diff --git a/cli/src/api/apiSession.test.ts b/cli/src/api/apiSession.test.ts index deffd7c2..3200ca7e 100644 --- a/cli/src/api/apiSession.test.ts +++ b/cli/src/api/apiSession.test.ts @@ -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', () => { diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 46ca8a44..78af87c2 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -1098,7 +1098,7 @@ export class ApiSessionClient extends EventEmitter { return await this.drainLock(this.metadataLock, timeoutMs) } - async flush(options?: { timeoutMs?: number }): Promise { + async flush(options?: { timeoutMs?: number }): Promise { 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 } } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index adfad4ac..9e19cb9c 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -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, diff --git a/cli/src/commands/agentCommandOptions.test.ts b/cli/src/commands/agentCommandOptions.test.ts index e9981599..c6393bbf 100644 --- a/cli/src/commands/agentCommandOptions.test.ts +++ b/cli/src/commands/agentCommandOptions.test.ts @@ -21,6 +21,15 @@ describe('parseRemoteAgentCommandOptions', () => { }) }) + it('parses --existing-session-id and rejects a missing value', () => { + expect(parseRemoteAgentCommandOptions([ + '--existing-session-id', 'preallocated-hapi-id' + ], OPENCODE_PERMISSION_MODES).existingSessionId).toBe('preallocated-hapi-id') + expect(() => parseRemoteAgentCommandOptions([ + '--existing-session-id' + ], OPENCODE_PERMISSION_MODES)).toThrow('Missing --existing-session-id value') + }) + it('does not let --yolo override an explicit permission mode that appeared first', () => { expect(parseRemoteAgentCommandOptions([ '--permission-mode', 'default', diff --git a/cli/src/commands/agentCommandOptions.ts b/cli/src/commands/agentCommandOptions.ts index 561749d8..b3f922dd 100644 --- a/cli/src/commands/agentCommandOptions.ts +++ b/cli/src/commands/agentCommandOptions.ts @@ -44,6 +44,12 @@ export function parseRemoteAgentCommandOptions void; onReasoningEffortRollback?: (effort: string | null) => void; onCompactAvailabilityChange?: (available: boolean) => void; + onClearRequested?: () => Promise; + onClearCleanupComplete?: () => Promise; + onClearCleanupFailed?: () => Promise; // Consumes (delete-and-return) whether the given localId was cancelled // after already being dequeued — needed because a queued /compact can // still be running (its REST call can take minutes) by the time a @@ -80,7 +83,10 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise { runRemote: (instance) => opencodeRemoteLauncher(instance, { onReasoningEffortRollback: opts.onReasoningEffortRollback, onCompactAvailabilityChange: opts.onCompactAvailabilityChange, - isLocalIdCancelled: opts.isLocalIdCancelled + isLocalIdCancelled: opts.isLocalIdCancelled, + onClearRequested: opts.onClearRequested, + onClearCleanupComplete: opts.onClearCleanupComplete, + onClearCleanupFailed: opts.onClearCleanupFailed }), onSessionReady: opts.onSessionReady }); diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index 03284b18..eeebed37 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -10,6 +10,7 @@ const harness = vi.hoisted(() => ({ refreshSessionInfoCalls: [] as Array<{ sessionId: string; cwd: string }>, bridgeOptions: null as { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } } | null, events: [] as string[], + cleanupEvents: [] as string[], setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise), setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise), thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> }, @@ -29,7 +30,10 @@ const harness = vi.hoisted(() => ({ // registered once initialization finishes), so that race can only be // reproduced via the terminal UI's onExit/onSwitchToLocal callbacks, // not rpcHandlers. - newSessionImpl: null as null | (() => Promise) + newSessionImpl: null as null | (() => Promise), + disconnectImpl: null as null | (() => Promise), + permissionCancelError: null as Error | null, + serverStopError: null as Error | null })); // Captures the RemoteLauncherDisplayContext (including onExit/ @@ -108,7 +112,12 @@ vi.mock('./utils/opencodeBackend', () => ({ harness.refreshSessionInfoCalls.push({ sessionId, cwd }); }), onPermissionRequest: vi.fn(), - disconnect: vi.fn(async () => {}), + disconnect: vi.fn(async () => { + harness.cleanupEvents.push('cleanup:disconnect'); + if (harness.disconnectImpl) { + await harness.disconnectImpl(); + } + }), getSessionModelsMetadata: vi.fn(() => harness.sessionModelsMetadata), getThoughtLevelConfigOption: vi.fn(() => harness.thoughtLevelOption ?? undefined), // Real AcpSdkBackend.suppressUpdatesDuring swaps out the message @@ -123,7 +132,7 @@ vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async (_client: unknown, options?: { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } }) => { harness.bridgeOptions = options ?? null; return { - server: { stop: () => {} }, + server: { stop: () => { harness.cleanupEvents.push('cleanup:server-stop'); if (harness.serverStopError) throw harness.serverStopError; } }, mcpServers: {} }; } @@ -131,7 +140,7 @@ vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ vi.mock('./utils/permissionHandler', () => ({ OpencodePermissionHandler: class { - async cancelAll(): Promise {} + async cancelAll(): Promise { harness.cleanupEvents.push('cleanup:permission'); if (harness.permissionCancelError) throw harness.permissionCancelError; } } })); @@ -300,6 +309,13 @@ function createCompactMode(model?: string): OpencodeMode { }; } +function createClearMode(): OpencodeMode { + return { + permissionMode: 'default' as PermissionMode, + operation: 'clear' + }; +} + describe('opencodeRemoteLauncher inline model switch', () => { afterEach(() => { harness.setModelArgs = []; @@ -309,6 +325,7 @@ describe('opencodeRemoteLauncher inline model switch', () => { harness.refreshSessionInfoCalls = []; harness.bridgeOptions = null; harness.events = []; + harness.cleanupEvents = []; harness.setModelImpl = null; harness.setConfigOptionImpl = null; harness.thoughtLevelOption = null; @@ -322,9 +339,109 @@ describe('opencodeRemoteLauncher inline model switch', () => { harness.sessionModelsMetadata = undefined; harness.cancelPromptImpl = null; harness.newSessionImpl = null; + harness.disconnectImpl = null; + harness.permissionCancelError = null; + harness.serverStopError = null; inkHarness.lastRenderProps = null; }); + it('reaches /clear only after the earlier prompt settles, without starting another OpenCode turn', async () => { + let resolvePrompt: (() => void) | null = null; + harness.promptImpl = () => new Promise((resolve) => { + resolvePrompt = resolve; + }); + const onClearRequested = vi.fn(); + const onClearCleanupComplete = vi.fn(async () => {}); + const { session } = createSessionStub([ + { message: 'before-clear', mode: createMode() }, + { message: '', mode: createClearMode() } + ]); + + const launcherPromise = opencodeRemoteLauncher(session as never, { onClearRequested, onClearCleanupComplete }); + while (!harness.events.includes('prompt:start')) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(onClearRequested).not.toHaveBeenCalled(); + + resolvePrompt!(); + await launcherPromise; + + expect(harness.events).toEqual(['prompt:start', 'prompt:end']); + expect(harness.promptCount).toBe(1); + expect(onClearRequested).toHaveBeenCalledTimes(1); + expect(onClearCleanupComplete).toHaveBeenCalledTimes(1); + // The sibling compact test below intentionally inspects its first + // factory result; do not leave this test's backend instance behind. + const backendModule = await import('./utils/opencodeBackend'); + (backendModule.createOpencodeBackend as unknown as ReturnType).mockClear(); + }); + + it('reserves before native cleanup but does not complete the transition when cleanup fails', async () => { + harness.disconnectImpl = async () => { + throw new Error('disconnect failed'); + }; + const onClearRequested = vi.fn(); + const onClearCleanupComplete = vi.fn(async () => {}); + const onClearCleanupFailed = vi.fn(async () => {}); + const { session } = createSessionStub([ + { message: '', mode: createClearMode() } + ]); + + await expect(opencodeRemoteLauncher(session as never, { onClearRequested, onClearCleanupComplete, onClearCleanupFailed })).rejects.toThrow('disconnect failed'); + expect(onClearRequested).toHaveBeenCalledTimes(1); + expect(onClearCleanupComplete).not.toHaveBeenCalled(); + expect(onClearCleanupFailed).toHaveBeenCalledTimes(1); + const backendModule = await import('./utils/opencodeBackend'); + (backendModule.createOpencodeBackend as unknown as ReturnType).mockClear(); + }); + + it.each(['permission', 'server'] as const)('aborts clear when %s cleanup fails', async (stage) => { + if (stage === 'permission') harness.permissionCancelError = new Error('permission cleanup failed'); + else harness.serverStopError = new Error('server cleanup failed'); + const onClearRequested = vi.fn(async () => {}); + const onClearCleanupComplete = vi.fn(async () => {}); + const onClearCleanupFailed = vi.fn(async () => {}); + const { session } = createSessionStub([{ message: '', mode: createClearMode() }]); + await expect(opencodeRemoteLauncher(session as never, { + onClearRequested, onClearCleanupComplete, onClearCleanupFailed + })).rejects.toThrow('cleanup failed'); + expect(onClearCleanupFailed).toHaveBeenCalledTimes(1); + expect(onClearCleanupComplete).not.toHaveBeenCalled(); + expect(harness.cleanupEvents).toEqual(expect.arrayContaining([ + 'cleanup:permission', 'cleanup:disconnect', 'cleanup:server-stop' + ])); + }); + + it('reaches /clear only after an in-flight /compact has completed', async () => { + harness.sessionModelsMetadata = { currentModelId: 'ollama/x', availableModels: [] }; + let resolveCompact: (() => void) | null = null; + compactHarness.triggerImpl = () => new Promise((resolve) => { + resolveCompact = () => resolve({ ok: true }); + }); + const onClearRequested = vi.fn(); + const { session } = createSessionStub([ + { message: '', mode: createCompactMode('ollama/x') }, + { message: '', mode: createClearMode() } + ]); + + const launcherPromise = opencodeRemoteLauncher(session as never, { + onCompactAvailabilityChange: () => {}, + onClearRequested + }); + while (compactHarness.calls.length === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(onClearRequested).not.toHaveBeenCalled(); + + resolveCompact!(); + await launcherPromise; + + expect(compactHarness.calls).toHaveLength(1); + expect(onClearRequested).toHaveBeenCalledTimes(1); + const backendModule = await import('./utils/opencodeBackend'); + (backendModule.createOpencodeBackend as unknown as ReturnType).mockClear(); + }); + it('processes a queued /compact operation only after an earlier queued prompt has finished', async () => { let resolvePrompt: (() => void) | null = null; harness.promptImpl = () => new Promise((resolve) => { @@ -533,7 +650,11 @@ describe('opencodeRemoteLauncher inline model switch', () => { setSessionInfoUpdateListener: vi.fn(), refreshSessionInfo: vi.fn(async () => {}), onPermissionRequest: vi.fn(), - disconnect: vi.fn(async () => {}), + disconnect: vi.fn(async () => { + if (harness.disconnectImpl) { + await harness.disconnectImpl(); + } + }), getSessionModelsMetadata: vi.fn(() => ({ currentModelId: 'ollama/qwen3.6:35b-a3b-q8_0-mtp', availableModels: [] @@ -1600,7 +1721,11 @@ describe('opencodeRemoteLauncher inline model switch', () => { setSessionInfoUpdateListener: vi.fn(), refreshSessionInfo: vi.fn(async () => {}), onPermissionRequest: vi.fn(), - disconnect: vi.fn(async () => {}), + disconnect: vi.fn(async () => { + if (harness.disconnectImpl) { + await harness.disconnectImpl(); + } + }), getSessionModelsMetadata: vi.fn((sessionId: string) => { if (sessionId === 'acp-session-1') { return { availableModels: fixtureModels, currentModelId: 'ollama/exaone:4.5-33b-q8' }; diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 425ea7c7..5a72c743 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -32,6 +32,12 @@ type OpencodeRemoteLauncherOptions = { // call (and summary lookup) settles, so a cancelled request's result // doesn't surface for an action the user no longer expects a reply from. isLocalIdCancelled?: (localId: string) => boolean; + // Called only after /clear reaches its FIFO position *and* this + // launcher has disconnected its OpenCode backend. The caller then performs + // the source lifecycle cleanup before requesting the fresh process. + onClearRequested?: () => Promise; + onClearCleanupComplete?: () => Promise; + onClearCleanupFailed?: () => Promise; }; export type AbortStatusDecision = { @@ -77,6 +83,10 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { private baseUrl: string | null = null; private permissionHandler: OpencodePermissionHandler | null = null; private happyServer: { stop: () => void } | null = null; + // Becomes true when the FIFO loop reaches /clear. Its callback is deferred + // until cleanup() completes so a failed OpenCode disconnect cannot create a + // replacement while the source backend may still be live. + private clearRequested = false; private abortController = new AbortController(); // Set by the dequeue loop as soon as a batch is identified as a // `operation:'compact'` one — deliberately *before* that batch's inline @@ -303,6 +313,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { break; } + // /clear is deliberately a queue operation rather than a direct + // slash side effect: every prompt and /compact ahead of it has + // completed before this point. In particular, do not route this + // through handleAbort(true): that method exists to interrupt an + // in-flight compact, while clear can only run after one finishes. + if (batch.mode.operation === 'clear') { + await this.options.onClearRequested?.(); + this.clearRequested = true; + await this.requestExit('exit', async () => {}) + break; + } + // Created here — before the model/effort switch below — rather // than inside runCompactOperation(), so it already exists for // handleAbort() to act on during that switch. backend.setModel()/ @@ -601,21 +623,45 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { protected async cleanup(): Promise { this.clearAbortHandlers(this.session.client.rpcHandlerManager); - + const failures: unknown[] = []; if (this.permissionHandler) { - await this.permissionHandler.cancelAll('Session ended'); - this.permissionHandler = null; + try { + await this.permissionHandler.cancelAll('Session ended'); + } catch (error) { + failures.push(error); + } finally { + this.permissionHandler = null; + } } - if (this.backend) { - await this.backend.disconnect(); - this.backend = null; + try { + await this.backend.disconnect(); + } catch (error) { + failures.push(error); + } finally { + this.backend = null; + } } - if (this.happyServer) { - this.happyServer.stop(); - this.happyServer = null; + try { + this.happyServer.stop(); + } catch (error) { + failures.push(error); + } finally { + this.happyServer = null; + } } + if (failures.length > 0) { + if (this.clearRequested) await this.options.onClearCleanupFailed?.(); + throw failures.length === 1 ? failures[0] : new AggregateError(failures, 'OpenCode cleanup failed'); + } + if (this.clearRequested) await this.options.onClearCleanupComplete?.(); + + // Signal the runner only after the native backend is gone. If an + // awaited teardown above fails, RemoteLauncherBase propagates that + // failure and this callback never runs; runOpencode then archives the + // source as an error rather than spawning a potentially concurrent + // replacement. } private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void { diff --git a/cli/src/opencode/runOpencode.test.ts b/cli/src/opencode/runOpencode.test.ts index 0f8bc7c1..cf9826fe 100644 --- a/cli/src/opencode/runOpencode.test.ts +++ b/cli/src/opencode/runOpencode.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildCliArgs } from '@/runner/run'; +import { parseRemoteAgentCommandOptions } from '@/commands/agentCommandOptions'; +import { OPENCODE_PERMISSION_MODES } from '@hapi/protocol/modes'; const mockOpencodeSession = vi.hoisted(() => ({ setModel: vi.fn(), @@ -21,10 +24,18 @@ const mockOpencodeSession = vi.hoisted(() => ({ const harness = vi.hoisted(() => ({ bootstrapArgs: [] as Array>, + bootstrapExistingArgs: [] as Array>, opencodeLoopArgs: [] as Array>, opencodeLoopError: null as Error | null, + triggerClear: false, + triggerCleanupFailure: false, + clearOpenCodeSession: vi.fn(async () => 'fresh-session'), + reserveOpenCodeClearSession: vi.fn(async () => 'fresh-session'), + confirmOpenCodeClearCleanup: vi.fn(async () => 'fresh-session'), + abortOpenCodeClearSession: vi.fn(async () => 'source-session'), listSlashCommands: vi.fn(async (..._args: unknown[]) => [] as Array), session: { + sessionId: 'source-session', onUserMessage: vi.fn(), onCancelQueuedMessage: vi.fn(), sendAgentMessage: vi.fn(), @@ -44,7 +55,14 @@ vi.mock('@/agent/sessionFactory', () => ({ bootstrapSession: vi.fn(async (options: Record) => { harness.bootstrapArgs.push(options); return { - api: {}, + api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession }, + session: harness.session + }; + }), + bootstrapExistingSession: vi.fn(async (options: Record) => { + harness.bootstrapExistingArgs.push(options); + return { + api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession }, session: harness.session }; }) @@ -60,6 +78,17 @@ vi.mock('./loop', () => ({ if (onSessionReady) { onSessionReady(mockOpencodeSession); } + if (harness.triggerClear) { + const onClearRequested = options.onClearRequested as (() => void) | undefined; + await onClearRequested?.(); + const onClearCleanupComplete = options.onClearCleanupComplete as (() => Promise) | undefined; + await onClearCleanupComplete?.(); + } + if (harness.triggerCleanupFailure) { + await (options.onClearRequested as (() => Promise))(); + await (options.onClearCleanupFailed as (() => Promise))(); + throw new Error('disconnect failed'); + } }) })); @@ -69,6 +98,8 @@ vi.mock('@/claude/registerKillSessionHandler', () => ({ const lifecycleMock = vi.hoisted(() => ({ registerProcessHandlers: vi.fn(), + cleanup: vi.fn(async () => {}), + cleanupConfirmed: vi.fn(async () => {}), cleanupAndExit: vi.fn(async () => {}), markCrash: vi.fn(), setExitCode: vi.fn(), @@ -109,8 +140,17 @@ import { runOpencode } from './runOpencode'; describe('runOpencode set-session-config handler', () => { beforeEach(() => { harness.bootstrapArgs.length = 0; + harness.bootstrapExistingArgs.length = 0; harness.opencodeLoopArgs.length = 0; harness.opencodeLoopError = null; + harness.triggerClear = false; + harness.triggerCleanupFailure = false; + harness.clearOpenCodeSession.mockReset(); + harness.clearOpenCodeSession.mockResolvedValue('fresh-session'); + harness.reserveOpenCodeClearSession.mockReset(); + harness.reserveOpenCodeClearSession.mockResolvedValue('fresh-session'); + harness.abortOpenCodeClearSession.mockReset(); + harness.abortOpenCodeClearSession.mockResolvedValue('source-session'); mockOpencodeSession.setModel.mockReset(); mockOpencodeSession.setPermissionMode.mockReset(); mockOpencodeSession.setModelReasoningEffort.mockReset(); @@ -127,6 +167,9 @@ describe('runOpencode set-session-config handler', () => { harness.listSlashCommands.mockReset(); harness.listSlashCommands.mockResolvedValue([]); lifecycleMock.registerProcessHandlers.mockClear(); + lifecycleMock.cleanup.mockClear(); + lifecycleMock.cleanupConfirmed.mockReset(); + lifecycleMock.cleanupConfirmed.mockResolvedValue(undefined); lifecycleMock.cleanupAndExit.mockClear(); lifecycleMock.markCrash.mockClear(); lifecycleMock.setExitCode.mockClear(); @@ -143,6 +186,24 @@ describe('runOpencode set-session-config handler', () => { return configHandler![1] as (payload: unknown) => Promise; } + it('carries the runner preallocated id from CLI args through parse into bootstrapExistingSession', async () => { + const runnerArgs = buildCliArgs('opencode', { + directory: '/tmp/project', + existingSessionId: 'preallocated-hapi-id' + }); + const parsed = parseRemoteAgentCommandOptions(runnerArgs.slice(1), OPENCODE_PERMISSION_MODES); + + await runOpencode({ ...parsed, workingDirectory: '/tmp/project' }); + + expect(harness.bootstrapArgs).toEqual([]); + expect(harness.bootstrapExistingArgs).toEqual([{ + sessionId: 'preallocated-hapi-id', + flavor: 'opencode', + startedBy: 'runner', + workingDirectory: '/tmp/project' + }]); + }); + it('rejects plan mode for local OpenCode startup', async () => { await expect(runOpencode({ permissionMode: 'plan' })).rejects.toThrow( 'OpenCode plan mode is only supported in remote mode' @@ -331,6 +392,251 @@ describe('runOpencode set-session-config handler', () => { expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); }); + it('queues runner-backed /clear as its own FIFO operation without acknowledging it early', async () => { + await runOpencode({ startedBy: 'runner' }); + + const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as + { queue: Array<{ message: string; mode: { operation?: string }; localId?: string; isolate?: boolean }> }; + const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as + ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) + | undefined; + + userMessageHandler!({ content: { text: '/clear' } }, 'local-clear'); + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(messageQueue.queue).toEqual([ + { + message: '', + mode: expect.objectContaining({ operation: 'clear' }), + modeHash: expect.any(String), + localId: 'local-clear', + isolate: true + } + ]); + expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled(); + }); + + it('leaves a prompt uninvoked when it arrives during the clear latch so scheduled rows can transfer', async () => { + let resolveCommands: ((commands: Array) => void) | undefined; + harness.listSlashCommands.mockImplementationOnce(() => new Promise((resolve) => { + resolveCommands = resolve; + })); + await runOpencode({ startedBy: 'runner' }); + + const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as + { queue: Array<{ mode: { operation?: string }; localId?: string }> }; + const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as + ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void); + + userMessageHandler({ content: { text: '/clear' } }, 'first-clear'); + userMessageHandler({ content: { text: 'must not reach the source session' } }, 'follow-up'); + userMessageHandler({ content: { text: 'redelivered scheduled prompt' } }, 'follow-up'); + await Promise.resolve(); + resolveCommands?.([]); + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(messageQueue.queue).toEqual([expect.objectContaining({ + mode: expect.objectContaining({ operation: 'clear' }), + localId: 'first-clear' + })]); + expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith( + ['follow-up'], + expect.anything() + ); + expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); + }); + + it('releases the clear transition latch when the queued /clear is cancelled', async () => { + await runOpencode({ startedBy: 'runner' }); + + const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as + { queue: Array<{ message: string; mode: { operation?: string }; localId?: string }> }; + const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as + ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void); + const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as + ((localId: string) => boolean); + + userMessageHandler({ content: { text: '/clear' } }, 'queued-clear'); + userMessageHandler({ content: { text: 'rejected while clear is queued' } }, 'rejected-before-cancel'); + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(cancelHandler('queued-clear')).toBe(true); + expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith( + ['rejected-before-cancel'], expect.anything() + ); + + userMessageHandler({ content: { text: 'continue in the source session' } }, 'after-cancel'); + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(messageQueue.queue).toEqual([ + expect.objectContaining({ message: 'rejected while clear is queued', localId: 'rejected-before-cancel' }), + expect.objectContaining({ message: 'continue in the source session', localId: 'after-cancel' }) + ]); + expect(harness.session.sendAgentMessage).not.toHaveBeenCalled(); + }); + + it('removes an individually cancelled prompt held behind queued clear', async () => { + await runOpencode({ startedBy: 'runner' }); + const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ localId?: string }> }; + const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as + ((msg: { content: { text: string } }, localId?: string) => void); + const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as ((localId: string) => boolean); + userMessageHandler({ content: { text: '/clear' } }, 'queued-clear'); + userMessageHandler({ content: { text: 'keep me' } }, 'held-keep'); + userMessageHandler({ content: { text: 'cancel me' } }, 'held-cancel'); + for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cancelHandler('held-cancel')).toBe(true); + expect(cancelHandler('queued-clear')).toBe(true); + expect(messageQueue.queue.map((item) => item.localId)).toEqual(['held-keep']); + }); + + it('archives the source before asking the hub to spawn the fresh OpenCode process', async () => { + harness.triggerClear = true; + const order: string[] = []; + lifecycleMock.cleanupConfirmed.mockImplementationOnce(async () => { order.push('cleanup'); }); + harness.reserveOpenCodeClearSession.mockImplementationOnce(async () => { order.push('reserve'); return 'fresh-session'; }); + harness.clearOpenCodeSession.mockImplementationOnce(async () => { + order.push('spawn'); + return 'fresh-session'; + }); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + await runOpencode({ startedBy: 'runner' }); + } finally { + exit.mockRestore(); + } + + expect(lifecycleMock.setArchiveReason).toHaveBeenCalledWith('Cleared by /clear'); + expect(lifecycleMock.setSessionEndReason).toHaveBeenCalledWith('cleared'); + expect(harness.clearOpenCodeSession).toHaveBeenCalledWith('source-session'); + expect(harness.confirmOpenCodeClearCleanup).toHaveBeenCalledWith('source-session', 'fresh-session'); + expect(order).toEqual(['reserve', 'cleanup', 'spawn']); + }); + + it('keeps archive-confirmation ownership beyond the old finite budget', async () => { + vi.useFakeTimers(); + harness.triggerClear = true; + const timeout = Object.assign(new Error('archive acknowledgement timed out'), { code: 'ETIMEDOUT' }); + for (let i = 0; i < 13; i++) { + lifecycleMock.cleanupConfirmed.mockRejectedValueOnce(timeout); + } + lifecycleMock.cleanupConfirmed.mockResolvedValueOnce(undefined); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + const run = runOpencode({ startedBy: 'runner' }); + await vi.runAllTimersAsync(); + await run; + expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(14); + expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(1); + } finally { + exit.mockRestore(); + vi.useRealTimers(); + } + }); + + it('retries a lost durable-reservation response before native teardown', async () => { + vi.useFakeTimers(); + harness.triggerClear = true; + const transient = Object.assign(new Error('connection reset after commit'), { code: 'ECONNRESET' }); + harness.reserveOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('fresh-session'); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + try { + const run = runOpencode({ startedBy: 'runner' }); + await vi.runAllTimersAsync(); + await run; + expect(harness.reserveOpenCodeClearSession).toHaveBeenCalledTimes(2); + expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(1); + } finally { + exit.mockRestore(); + vi.useRealTimers(); + } + }); + + it('surfaces a fresh-session handoff failure instead of exiting cleanly after archival', async () => { + harness.triggerClear = true; + harness.clearOpenCodeSession.mockRejectedValueOnce(new Error('replacement link failed')); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + await expect(runOpencode({ startedBy: 'runner' })).rejects.toThrow('replacement link failed'); + } finally { + exit.mockRestore(); + } + + expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + }); + + it('retries a transient abort notification before releasing cleanup-failure ownership', async () => { + vi.useFakeTimers(); + harness.triggerCleanupFailure = true; + const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + harness.abortOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('source-session'); + try { + const run = runOpencode({ startedBy: 'runner' }); + await vi.runAllTimersAsync(); + await run; + expect(harness.abortOpenCodeClearSession).toHaveBeenCalledTimes(2); + expect(harness.abortOpenCodeClearSession).toHaveBeenLastCalledWith('source-session', 'fresh-session'); + expect(harness.clearOpenCodeSession).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps retry ownership beyond the old finite budget until the archived-source handoff succeeds', async () => { + vi.useFakeTimers(); + harness.triggerClear = true; + const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }); + harness.clearOpenCodeSession + .mockRejectedValueOnce(transient) + .mockRejectedValueOnce(transient) + .mockRejectedValueOnce(transient) + .mockRejectedValueOnce(transient) + .mockRejectedValueOnce(transient) + .mockRejectedValueOnce(transient) + .mockResolvedValueOnce('fresh-session'); + const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + const run = runOpencode({ startedBy: 'runner' }); + await vi.runAllTimersAsync(); + await run; + expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(7); + expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(1, 'source-session'); + expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(7, 'source-session'); + expect(exit).toHaveBeenCalledWith(0); + } finally { + exit.mockRestore(); + vi.useRealTimers(); + } + }); + + it('keeps terminal-backed /clear explicit rather than archiving a session with no runner machine', async () => { + await runOpencode({ startedBy: 'terminal' }); + const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as + ((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void) + | undefined; + + userMessageHandler!({ content: { text: '/clear' } }, 'local-terminal-clear'); + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(harness.session.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({ + message: '/clear is available only for runner-backed OpenCode sessions.' + })); + }); + it('queues /compact like a prompt while a remote-mode session is still initializing (ACP backend not ready yet), instead of rejecting it as not-yet-supported', async () => { // Reproduces a hostile-review finding: compactSupported alone // conflates "genuinely local mode" with "remote mode, but ACP diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index 668fd59f..e0a838f3 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -16,6 +16,8 @@ import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveOpencodeSlashCommand } from './utils/slashCommands'; +import { isRetryableConnectionError } from '@/utils/errorUtils'; +import { withRetry } from '@/utils/time'; export async function runOpencode(opts: { startedBy?: 'runner' | 'terminal'; @@ -108,6 +110,14 @@ export async function runOpencode(opts: { // onLeavingRemote exists to protect, since `mode` stays 'remote' // throughout it. let compactSupported = false; + let clearRequested = false; + let clearReplacementSessionId: string | null = null; + // Once a runner-backed /clear is accepted, hold later payloads until the + // transition commits or the queued clear is cancelled. The hub redirects + // their durable rows to the reserved replacement on success. + let clearTransitionLatched = false; + let queuedClearLocalId: string | null = null; + const heldDuringClear: Array<{ message: Parameters[0]>[0]; localId?: string }> = []; // True from the moment onCompactAvailabilityChange(false) fires (which, // per onLeavingRemote's contract, only ever happens because remote mode // is being left — never because remote just started) until this session @@ -236,6 +246,11 @@ export async function runOpencode(opts: { }; try { if (wasCancelled()) return; + if (clearTransitionLatched) { + heldDuringClear.push({ message, localId }); + sessionWrapperRef.current?.onThinkingChange(false); + return; + } let text = message.content.text; const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []); if (wasCancelled()) return; @@ -246,6 +261,30 @@ export async function runOpencode(opts: { modelReasoningEffort: sessionModelReasoningEffort }); + if (slash.kind === 'clear') { + if (startedBy !== 'runner') { + if (localId) { + session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true }); + } + session.sendAgentMessage({ + type: 'message', + message: '/clear is available only for runner-backed OpenCode sessions.', + id: randomUUID() + }); + sessionWrapperRef.current?.onThinkingChange(false); + return; + } + // Latch before enqueueing. userMessageChain serializes later + // messages behind this resolver, including the async + // listSlashCommands race, so they take the rejection path. + clearTransitionLatched = true; + queuedClearLocalId = localId ?? null; + // A clear is isolated but retains its FIFO position: + // older prompts and native /compact work finish first. + messageQueue.pushIsolated('', { ...buildMode(), operation: 'clear' }, localId); + return; + } + if (slash.kind === 'compact') { // `compactSupported` alone conflates two different // situations: a genuinely local-mode session (compact @@ -399,6 +438,19 @@ export async function runOpencode(opts: { session.onCancelQueuedMessage((localId) => { const removedFromQueue = messageQueue.cancelByLocalId(localId); if (removedFromQueue) { + if (queuedClearLocalId === localId) { + queuedClearLocalId = null; + clearTransitionLatched = false; + for (const held of heldDuringClear) { + const formattedText = formatMessageWithAttachments(held.message.content.text, held.message.content.attachments); + messageQueue.push(formattedText, { + permissionMode: currentPermissionMode, + model: sessionModel, + modelReasoningEffort: sessionModelReasoningEffort + }, held.localId); + } + heldDuringClear.length = 0; + } logger.debug(`[opencode] cancelByLocalId(${localId}): removed from queue`); return true; } @@ -407,6 +459,11 @@ export async function runOpencode(opts: { logger.debug(`[opencode] cancelByLocalId(${localId}): marked for cancellation before enqueue`); return true; } + const heldIndex = heldDuringClear.findIndex((held) => held.localId === localId); + if (heldIndex >= 0) { + heldDuringClear.splice(heldIndex, 1); + return true; + } // Not in the queue and not in the pre-enqueue preparing window. As // explained where `cancelledDequeuedLocalIds` is declared above, the // hub only calls this at all while its own row is still queued, so @@ -491,6 +548,24 @@ export async function runOpencode(opts: { compactTeardownInProgress = true; } }, + onClearRequested: async () => { + clearReplacementSessionId = await withRetry(() => api.reserveOpenCodeClearSession(session.sessionId), { + minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError + }); + }, + onClearCleanupComplete: async () => { + if (!clearReplacementSessionId) throw new Error('OpenCode clear cleanup completed without a reservation') + await withRetry(() => api.confirmOpenCodeClearCleanup(session.sessionId, clearReplacementSessionId!), { + minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError + }); + clearRequested = true; + }, + onClearCleanupFailed: async () => { + if (!clearReplacementSessionId) throw new Error('OpenCode clear cleanup failed without a reservation') + await withRetry(() => api.abortOpenCodeClearSession(session.sessionId, clearReplacementSessionId!), { + minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError + }); + }, isLocalIdCancelled: (localId) => cancelledDequeuedLocalIds.delete(localId) }); } catch (error) { @@ -499,13 +574,55 @@ export async function runOpencode(opts: { logger.debug('[opencode] Loop error:', error); } finally { const localFailure = sessionWrapperRef.current?.localLaunchFailure; - if (localFailure?.exitReason === 'exit') { + if (clearRequested) { + lifecycle.setArchiveReason('Cleared by /clear'); + lifecycle.setSessionEndReason('cleared'); + } else if (localFailure?.exitReason === 'exit') { lifecycle.setExitCode(1); lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`); lifecycle.setSessionEndReason('error'); } else if (!crashed) { lifecycle.setSessionEndReason('completed'); } - await lifecycle.cleanupAndExit(); + if (!clearRequested) { + await lifecycle.cleanupAndExit(); + return; + } + + // Keep the source socket open until the hub acknowledges the ordered + // archive/session-end boundary. A transient disconnect must not turn + // the following clear request into a non-retryable active-source 409. + await withRetry( + () => lifecycle.cleanupConfirmed({ timeoutMs: 5_000 }), + { + minDelay: 500, + maxDelay: 30_000, + shouldRetry: isRetryableConnectionError, + onRetry: (error, attempt, nextDelayMs) => { + const message = error instanceof Error ? error.message : String(error); + logger.debug(`[opencode] Session archive confirmation failed (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${message}`); + } + } + ); + try { + await withRetry( + () => api.clearOpenCodeSession(session.sessionId), + { + minDelay: 500, + maxDelay: 30_000, + shouldRetry: isRetryableConnectionError, + onRetry: (error, attempt, nextDelayMs) => { + const message = error instanceof Error ? error.message : String(error); + logger.debug(`[opencode] Fresh-session clear handoff failed (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${message}`); + } + } + ); + } catch (error) { + // Only non-retryable failures reach here. Retryable transport and + // hub failures retain ownership in the loop above until recovery. + logger.debug('[opencode] Fresh-session clear spawn failed', error); + throw error; + } + process.exit(0); } } diff --git a/cli/src/opencode/types.ts b/cli/src/opencode/types.ts index 0382c5cc..aed1451d 100644 --- a/cli/src/opencode/types.ts +++ b/cli/src/opencode/types.ts @@ -16,7 +16,7 @@ export interface OpencodeMode { // calling `backend.prompt()`, which keeps /compact from "cutting in // line" ahead of prompts that were already queued when it arrived. // `undefined` for normal prompts. - operation?: 'compact'; + operation?: 'compact' | 'clear'; } export type OpencodeHookEvent = { diff --git a/cli/src/opencode/utils/slashCommands.test.ts b/cli/src/opencode/utils/slashCommands.test.ts index 75e049b6..2711b1b9 100644 --- a/cli/src/opencode/utils/slashCommands.test.ts +++ b/cli/src/opencode/utils/slashCommands.test.ts @@ -119,11 +119,8 @@ describe('resolveOpencodeSlashCommand', () => { } }); - it('returns a not-yet-supported message for /clear', () => { - expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({ - kind: 'handled', - message: '/clear is not yet supported in HAPI OpenCode sessions.' - }); + it('resolves builtin /clear to the dedicated fresh-session operation', () => { + expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({ kind: 'clear' }); }); it('resolves /compact to a dedicated kind so the launcher can bridge to native compaction asynchronously', () => { @@ -168,7 +165,7 @@ describe('resolveOpencodeSlashCommand', () => { expect(help.message).toContain('/plan'); expect(help.message).toContain('/permissions'); expect(help.message).toContain('/compact` — compact (summarize) the OpenCode session context (remote sessions only)'); - expect(help.message).toContain('/clear` is not yet supported'); + expect(help.message).toContain('/clear` — archive this HAPI session and open a fresh OpenCode session'); } }); diff --git a/cli/src/opencode/utils/slashCommands.ts b/cli/src/opencode/utils/slashCommands.ts index 33230105..91c24180 100644 --- a/cli/src/opencode/utils/slashCommands.ts +++ b/cli/src/opencode/utils/slashCommands.ts @@ -24,6 +24,9 @@ export type OpencodeSlashResolution = // synchronous 'handled' shape below. The launcher (runOpencode.ts) // intercepts this kind and drives that flow itself. | { kind: 'compact' } + // /clear exits the current runner-backed HAPI process after its FIFO + // predecessors finish, then asks the hub to spawn a fresh OpenCode one. + | { kind: 'clear' } | { kind: 'handled'; message: string; @@ -174,10 +177,7 @@ export function resolveOpencodeSlashCommand( } if (command === 'clear') { - return { - kind: 'handled', - message: `/${command} is not yet supported in HAPI OpenCode sessions.` - }; + return { kind: 'clear' }; } if (command === 'init') { @@ -204,12 +204,11 @@ export function resolveOpencodeSlashCommand( '- `/default` — return to default permission mode', '- `/init [extra]` — generate or refresh AGENTS.md for this project', '- `/compact` — compact (summarize) the OpenCode session context (remote sessions only)', + '- `/clear` — archive this HAPI session and open a fresh OpenCode session', '', 'Model, reasoning effort, and permission mode have dedicated buttons in the composer. ' + 'You can still type `/model`, `/reasoning`, or `/permissions` if you prefer.', '', - '`/clear` is not yet supported in HAPI OpenCode sessions.', - '', 'Custom commands from `~/.config/opencode/command` or `.opencode/command` are expanded before sending.' ].join('\n') }; diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 8496da01..d601bdf0 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { buildCliArgs } from './run' +import { buildCliArgs, classifyRecoveredProcessGeneration, createSpawnDeduplicator, releaseRecoveredSpawnDedupe } from './run' describe('buildCliArgs', () => { it('adds --permission-mode for valid permission mode', () => { @@ -136,6 +136,17 @@ describe('buildCliArgs', () => { + it('passes the preallocated HAPI id to OpenCode without resuming its native session', () => { + const args = buildCliArgs('opencode', { + directory: '/tmp', + existingSessionId: 'fresh-hapi-session', + }) + + expect(args).toContain('--existing-session-id') + expect(args).toContain('fresh-hapi-session') + expect(args).not.toContain('--resume') + }) + it('does not pass existing session id flag to agents that do not reuse HAPI rows', () => { const args = buildCliArgs('claude', { directory: '/tmp', @@ -295,3 +306,132 @@ describe('buildCliArgs', () => { ]) }) }) + + +describe('createSpawnDeduplicator', () => { + it('rehydrates a live child after runner restart without spawning again', async () => { + let calls = 0 + const dedupe = createSpawnDeduplicator(async () => { + calls += 1 + return { type: 'success' as const, sessionId: 'duplicate' } + }) + + dedupe.recoverChild('fresh-hapi-session', { + type: 'error', + errorMessage: 'Session fresh-hapi-session is still starting' + }) + + await expect(dedupe({ directory: '/tmp', existingSessionId: 'fresh-hapi-session' })).resolves.toEqual({ + type: 'error', errorMessage: 'Session fresh-hapi-session is still starting' + }) + expect(calls).toBe(0) + }) + + it('shares an in-flight spawn and its successful result while the child is alive', async () => { + let calls = 0 + let resolveSpawn: ((result: { type: 'success'; sessionId: string }) => void) | undefined + const dedupe = createSpawnDeduplicator(async () => { + calls += 1 + return await new Promise<{ type: 'success'; sessionId: string }>((resolve) => { + resolveSpawn = resolve + }) + }) + + const first = dedupe({ directory: '/tmp', existingSessionId: 'fresh-hapi-session' }) + const concurrentRetry = dedupe({ directory: '/tmp', existingSessionId: 'fresh-hapi-session' }) + expect(calls).toBe(1) + resolveSpawn?.({ type: 'success', sessionId: 'fresh-hapi-session' }) + await expect(first).resolves.toEqual({ type: 'success', sessionId: 'fresh-hapi-session' }) + await expect(concurrentRetry).resolves.toEqual({ type: 'success', sessionId: 'fresh-hapi-session' }) + + await expect(dedupe({ directory: '/tmp', existingSessionId: 'fresh-hapi-session' })).resolves.toEqual({ + type: 'success', sessionId: 'fresh-hapi-session' + }) + expect(calls).toBe(1) + }) + + it('retries immediately when spawning fails before a child PID is registered', async () => { + let calls = 0 + const dedupe = createSpawnDeduplicator(async () => { + calls += 1 + return { type: 'error' as const, errorMessage: 'Failed to spawn HAPI process - no PID returned' } + }) + + const options = { directory: '/tmp', existingSessionId: 'fresh-hapi-session' } + await expect(dedupe(options)).resolves.toEqual({ type: 'error', errorMessage: 'Failed to spawn HAPI process - no PID returned' }) + await expect(dedupe(options)).resolves.toEqual({ type: 'error', errorMessage: 'Failed to spawn HAPI process - no PID returned' }) + + expect(calls).toBe(2) + }) + + it('keeps a timed-out child deduped until the runner observes its exit', async () => { + let calls = 0 + let dedupe!: ReturnType + dedupe = createSpawnDeduplicator(async (options) => { + calls += 1 + dedupe.markChildAlive(options.existingSessionId!) + return { type: 'error' as const, errorMessage: 'Session webhook timeout' } + }) + + const options = { directory: '/tmp', existingSessionId: 'fresh-hapi-session' } + await expect(dedupe(options)).resolves.toEqual({ type: 'error', errorMessage: 'Session webhook timeout' }) + await expect(dedupe(options)).resolves.toEqual({ type: 'error', errorMessage: 'Session webhook timeout' }) + expect(calls).toBe(1) + + dedupe.onChildExited('fresh-hapi-session') + await expect(dedupe(options)).resolves.toEqual({ type: 'error', errorMessage: 'Session webhook timeout' }) + expect(calls).toBe(2) + }) + + it('keeps a stopped child deduped until the runner observes its exit', async () => { + let calls = 0 + let dedupe!: ReturnType + dedupe = createSpawnDeduplicator(async (options) => { + calls += 1 + dedupe.markChildAlive(options.existingSessionId!) + return { type: 'success' as const, sessionId: 'fresh-hapi-session' } + }) + + const options = { directory: '/tmp', existingSessionId: 'fresh-hapi-session' } + await expect(dedupe(options)).resolves.toEqual({ type: 'success', sessionId: 'fresh-hapi-session' }) + + // stopSession() has only requested termination; the child can still be alive. + dedupe.markChildStopping('fresh-hapi-session') + await expect(dedupe(options)).resolves.toEqual({ type: 'success', sessionId: 'fresh-hapi-session' }) + expect(calls).toBe(1) + + dedupe.onChildExited('fresh-hapi-session') + await expect(dedupe(options)).resolves.toEqual({ type: 'success', sessionId: 'fresh-hapi-session' }) + expect(calls).toBe(2) + }) +}) + +describe('classifyRecoveredProcessGeneration', () => { + it('quarantines a live recovered child while its generation marker is unavailable', () => { + expect(classifyRecoveredProcessGeneration(true, null, 'persisted-marker')).toBe('quarantined') + }) + + it('releases quarantine only after exit or a generation mismatch is proven', () => { + expect(classifyRecoveredProcessGeneration(false, null, 'persisted-marker')).toBe('exited') + expect(classifyRecoveredProcessGeneration(true, 'other-marker', 'persisted-marker')).toBe('exited') + expect(classifyRecoveredProcessGeneration(true, 'persisted-marker', 'persisted-marker')).toBe('verified') + }) +}) + +describe('releaseRecoveredSpawnDedupe', () => { + it('allows an immediate same-row spawn after a recovered child reaches a terminal stop branch', async () => { + let calls = 0 + const dedupe = createSpawnDeduplicator(async () => { + calls += 1 + return { type: 'success' as const, sessionId: 'fresh-hapi-session' } + }) + dedupe.recoverChild('fresh-hapi-session', { type: 'success', sessionId: 'fresh-hapi-session' }) + const recovered = new Map([[123, 'fresh-hapi-session']]) + + releaseRecoveredSpawnDedupe(123, recovered, dedupe) + await dedupe({ directory: '/tmp', existingSessionId: 'fresh-hapi-session' }) + + expect(calls).toBe(1) + expect(recovered.has(123)).toBe(false) + }) +}) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index f7c6ffc9..59bf22ef 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -29,6 +29,90 @@ import { resolveWorkspaceRoots } from '@/utils/workspaceRoot'; import { hashRunnerCliApiToken, hashRunnerExtraHeaders } from './runnerIdentity'; import { scheduleCursorModelsPrewarm } from '@/modules/common/cursorModelsPrewarm'; +/** + * Deduplicates a preallocated HAPI-row spawn only while its child is alive. + * A lost acknowledgement can retry safely, but a later resume after that + * child exits must be allowed to start a new child for the same HAPI row. + */ +export type SpawnDeduplicator = ((options: SpawnSessionOptions) => Promise) & { + recoverChild: (existingSessionId: string, result: SpawnSessionResult) => void + markChildAlive: (existingSessionId: string) => void + markChildStopping: (existingSessionId: string) => void + onChildExited: (existingSessionId: string) => void +} + +export function createSpawnDeduplicator( + spawnOnce: (options: SpawnSessionOptions) => Promise +): SpawnDeduplicator { + const completedOrInFlight = new Map>(); + const childState = new Map(); + + const dedupe = async (options: SpawnSessionOptions): Promise => { + const key = options.existingSessionId; + if (!key) { + return await spawnOnce(options); + } + const existing = completedOrInFlight.get(key); + if (existing) { + return await existing; + } + + const task = spawnOnce(options); + completedOrInFlight.set(key, task); + task.then((result) => { + // A failure before a PID exists can retry immediately. Once startRunner + // has registered a child PID, keep its result until exit/stale detection + // confirms that the child is gone. + if (result.type !== 'success' && !childState.has(key) && completedOrInFlight.get(key) === task) { + completedOrInFlight.delete(key); + } + }, () => { + if (!childState.has(key) && completedOrInFlight.get(key) === task) { + completedOrInFlight.delete(key); + } + }); + return await task; + }; + dedupe.recoverChild = (existingSessionId: string, result: SpawnSessionResult) => { + childState.set(existingSessionId, 'alive'); + completedOrInFlight.set(existingSessionId, Promise.resolve(result)); + }; + dedupe.markChildAlive = (existingSessionId: string) => { + childState.set(existingSessionId, 'alive'); + }; + dedupe.markChildStopping = (existingSessionId: string) => { + if (childState.has(existingSessionId)) { + childState.set(existingSessionId, 'stopping'); + } + }; + dedupe.onChildExited = (existingSessionId: string) => { + childState.delete(existingSessionId); + completedOrInFlight.delete(existingSessionId); + }; + return dedupe; +} + +export function classifyRecoveredProcessGeneration( + processAlive: boolean, + currentMarker: string | null, + persistedMarker: string +): 'verified' | 'quarantined' | 'exited' { + if (!processAlive) return 'exited'; + if (currentMarker === null) return 'quarantined'; + return currentMarker === persistedMarker ? 'verified' : 'exited'; +} + +export function releaseRecoveredSpawnDedupe( + pid: number, + existingSessionIdByChildPid: Map, + spawnSession: SpawnDeduplicator +): void { + const existingSessionId = existingSessionIdByChildPid.get(pid); + if (!existingSessionId) return; + spawnSession.onChildExited(existingSessionId); + existingSessionIdByChildPid.delete(pid); +} + export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise { // We don't have cleanup function at the time of server construction // Control flow is: @@ -277,10 +361,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): for (const [pid, record] of [...persistedResumeProcesses]) { const alive = isProcessAlive(pid); const marker = alive ? getProcessStartMarker(pid) : null; - if (alive && marker === record.processStartMarker) { + const generation = classifyRecoveredProcessGeneration(alive, marker, record.processStartMarker); + if (generation === 'verified') { pidToRequestedSessionId.set(pid, record.requestedSessionId); if (record.confirmedSessionId) pidToConfirmedSessionId.set(pid, record.confirmedSessionId); - } else if (!alive || marker !== null) { + } else if (generation === 'exited') { persistedResumeProcesses.delete(pid); rememberVerifiedExit(record.requestedSessionId); if (record.confirmedSessionId) rememberVerifiedExit(record.confirmedSessionId); @@ -307,6 +392,9 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): // Session spawning awaiter system const pidToAwaiter = new Map void>(); const pidToErrorAwaiter = new Map void>(); + // existingSessionId identifies the HAPI row, not a permanent spawn request. + // Keep the dedupe entry only while this runner still owns the child PID. + const existingSessionIdByChildPid = new Map(); type SpawnFailureDetails = { message: string pid?: number @@ -403,7 +491,8 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): }; // Spawn a new session (sessionId reserved for future --resume functionality) - const spawnSession = async (options: SpawnSessionOptions): Promise => { + let spawnSession!: SpawnDeduplicator; + const spawnSessionOnce = async (options: SpawnSessionOptions): Promise => { logger.debugLargeJson('[RUNNER RUN] Spawning session', options); const { directory, sessionId, machineId, approvedNewDirectoryCreation = true } = options; @@ -613,6 +702,10 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } const pid = happyProcess.pid; + if (options.existingSessionId) { + existingSessionIdByChildPid.set(pid, options.existingSessionId); + spawnSession.markChildAlive(options.existingSessionId); + } invalidateVerifiedExit(`PID-${pid}`); logger.debug(`[RUNNER RUN] Spawned process with PID ${pid}`); let observedExitCode: number | null = null; @@ -790,6 +883,18 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } }; + spawnSession = createSpawnDeduplicator(spawnSessionOnce); + for (const [pid, record] of persistedResumeProcesses) { + const verified = pidToRequestedSessionId.get(pid) === record.requestedSessionId; + existingSessionIdByChildPid.set(pid, record.requestedSessionId); + spawnSession.recoverChild( + record.requestedSessionId, + verified && record.confirmedSessionId + ? { type: 'success', sessionId: record.confirmedSessionId } + : { type: 'error', errorMessage: `Session ${record.requestedSessionId} process verification is pending` } + ); + } + // Stop a session by sessionId or PID fallback const stopSession = async (sessionId: string): Promise<'stopped' | 'already_gone' | 'still_alive'> => { logger.debug(`[RUNNER RUN] Attempting to stop session ${sessionId}`); @@ -823,6 +928,12 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } } + // A stop request starts termination but does not prove that a detached + // child is gone. Keep its HAPI-row dedupe key until exit/stale detection. + const existingSessionId = existingSessionIdByChildPid.get(pid); + if (existingSessionId) { + spawnSession.markChildStopping(existingSessionId); + } const deadline = Date.now() + 5_000; while (isProcessAlive(pid) && Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 50)); @@ -867,6 +978,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): pidToConfirmedSessionId.delete(pid); if (requestedSessionId) rememberVerifiedExit(requestedSessionId); if (confirmedSessionId) rememberVerifiedExit(confirmedSessionId); + releaseRecoveredSpawnDedupe(pid, existingSessionIdByChildPid, spawnSession); return 'already_gone'; } if (!(await killProcessTreeByPid(pid))) return 'still_alive'; @@ -876,6 +988,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): pidToRequestedSessionId.delete(pid); pidToConfirmedSessionId.delete(pid); if (persistedResumeProcesses.delete(pid)) persistResumeProcesses(); + releaseRecoveredSpawnDedupe(pid, existingSessionIdByChildPid, spawnSession); return 'stopped'; } if (requestedSessionId) rememberVerifiedExit(requestedSessionId); @@ -884,6 +997,7 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): pidToRequestedSessionId.delete(pid); pidToConfirmedSessionId.delete(pid); if (persistedResumeProcesses.delete(pid)) persistResumeProcesses(); + releaseRecoveredSpawnDedupe(pid, existingSessionIdByChildPid, spawnSession); return 'already_gone'; } @@ -904,6 +1018,11 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): if (confirmedSessionId) rememberVerifiedExit(confirmedSessionId); rememberVerifiedExit(`PID-${pid}`); logger.debug(`[RUNNER RUN] Removing exited process PID ${pid} from tracking`); + const existingSessionId = existingSessionIdByChildPid.get(pid); + if (existingSessionId) { + spawnSession.onChildExited(existingSessionId); + existingSessionIdByChildPid.delete(pid); + } pidToTrackedSession.delete(pid); pidToAwaiter.delete(pid); pidToErrorAwaiter.delete(pid); @@ -1099,10 +1218,36 @@ export async function startRunner(options: { workspaceRoots?: string[] } = {}): } // Prune stale sessions - for (const [pid, _] of pidToTrackedSession.entries()) { + const pidsToCheck = new Set([ + ...pidToTrackedSession.keys(), + ...existingSessionIdByChildPid.keys() + ]); + for (const pid of pidsToCheck) { if (!isProcessAlive(pid)) { logger.debug(`[RUNNER RUN] Removing stale session with PID ${pid} (process no longer exists)`); - pidToTrackedSession.delete(pid); + onChildExited(pid); + continue; + } + const persisted = persistedResumeProcesses.get(pid); + if (persisted) { + const generation = classifyRecoveredProcessGeneration( + true, + getProcessStartMarker(pid), + persisted.processStartMarker + ); + if (generation === 'exited') { + logger.debug(`[RUNNER RUN] Removing stale session with reused PID ${pid}`); + onChildExited(pid); + } else if (generation === 'verified' && pidToRequestedSessionId.get(pid) !== persisted.requestedSessionId) { + pidToRequestedSessionId.set(pid, persisted.requestedSessionId); + if (persisted.confirmedSessionId) pidToConfirmedSessionId.set(pid, persisted.confirmedSessionId); + spawnSession.recoverChild( + persisted.requestedSessionId, + persisted.confirmedSessionId + ? { type: 'success', sessionId: persisted.confirmedSessionId } + : { type: 'error', errorMessage: `Session ${persisted.requestedSessionId} is still starting` } + ); + } } } @@ -1341,9 +1486,10 @@ export function buildCliArgs( args.push('--fork-session'); } args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); - // Codex, Cursor ACP, Pi native resume, and Claude message-level forks - // reuse the original HAPI row via --existing-session-id. + // Codex, Cursor ACP, OpenCode, Pi native resume, and Claude message-level + // forks reuse the original HAPI row via --existing-session-id. if (agent === 'codex' || agent === 'cursor' || agent === 'pi' + || agent === 'opencode' || (agentCommand === 'claude' && options.forkSession)) { const existingSessionId = options.existingSessionId ?? options.sessionId; if (existingSessionId) { diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 428299ce..39c0ff71 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -38,6 +38,25 @@ function redundantGoalStatusContent(message: string): unknown { } describe('cli session handlers', () => { + it('preserves immediate queued rows for cleared handoff transfer', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('clear-end', {}, null, 'default') + store.messages.addMessage(session.id, { text: 'held' }, 'held-local') + const socket = new FakeSocket() + let swept = false + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => {}, + onSweepImmediateQueued: () => { swept = true } + }) + socket.trigger('session-end', { sid: session.id, time: Date.now(), reason: 'cleared' }) + expect(swept).toBe(false) + expect(store.messages.getAllMessages(session.id)).toEqual([ + expect.objectContaining({ localId: 'held-local', invokedAt: null }) + ]) + }) + it('drops redundant goal status events before persistence and broadcast', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('goal-status-session', {}, null, 'default') @@ -120,4 +139,55 @@ describe('cli session handlers', () => { expect(broadcastBody.metadata.value.path).toBe('/tmp/project') expect(broadcastBody.metadata.value.lifecycleState).toBe('archived') }) + + it.each(['supersededBySessionId', 'opencodeClearOperation'] as const)( + 'ignores a forged hub-owned %s addition from CLI metadata', + (field) => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('forged-clear-link', { path: '/tmp/project' }, null, 'default') + const socket = new FakeSocket() + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { throw new Error('unexpected access error') } + }) + socket.trigger('update-metadata', { + sid: session.id, + expectedVersion: session.metadataVersion, + metadata: { + path: '/tmp/project', + [field]: field === 'supersededBySessionId' + ? 'foreign-session' + : { replacementSessionId: 'foreign-session', state: 'reserved', updatedAt: Date.now() } + } + }, () => {}) + expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).not.toHaveProperty(field) + } + ) + + it('preserves existing hub-owned clear metadata across CLI metadata updates', () => { + const store = new Store(':memory:') + const operation = { replacementSessionId: 'owned-target', state: 'completed', updatedAt: Date.now() } + const session = store.sessions.getOrCreateSession('preserve-clear-link', { + supersededBySessionId: 'owned-target', opencodeClearOperation: operation + }, null, 'default') + const socket = new FakeSocket() + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { throw new Error('unexpected access error') } + }) + socket.trigger('update-metadata', { + sid: session.id, + expectedVersion: session.metadataVersion, + metadata: { + lifecycleState: 'archived', + supersededBySessionId: 'forged-target', + opencodeClearOperation: { replacementSessionId: 'forged-target', state: 'reserved', updatedAt: 0 } + } + }, () => {}) + expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).toMatchObject({ + supersededBySessionId: 'owned-target', opencodeClearOperation: operation, lifecycleState: 'archived' + }) + }) }) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 16769888..39f34356 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -62,6 +62,21 @@ const updateStateSchema = z.object({ agentState: z.unknown().nullable() }) +const HUB_OWNED_METADATA_KEYS = ['supersededBySessionId', 'opencodeClearOperation'] as const + +function preserveHubOwnedMetadata(incoming: unknown, current: unknown): unknown { + if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return incoming + const next = { ...(incoming as Record) } + const existing = current && typeof current === 'object' && !Array.isArray(current) + ? current as Record + : {} + for (const key of HUB_OWNED_METADATA_KEYS) { + if (Object.prototype.hasOwnProperty.call(existing, key)) next[key] = existing[key] + else delete next[key] + } + return next +} + export type SessionHandlersDeps = { store: Store resolveSessionAccess: ResolveSessionAccess @@ -189,7 +204,7 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session const result = store.sessions.updateSessionMetadata( sid, - metadata, + preserveHubOwnedMetadata(metadata, sessionAccess.value.metadata), expectedVersion, sessionAccess.value.namespace ) @@ -366,10 +381,12 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session // rows after the CLI exits — there is no longer an ack path, so they would // stay queued forever. The 5-second tick in syncEngine.expireInactive // emits scheduled rows when they mature, regardless of session end. - try { - onSweepImmediateQueued?.(data.sid, Date.now()) - } catch (err) { - console.error('session-end sweep failed', err) + if (data.reason !== 'cleared') { + try { + onSweepImmediateQueued?.(data.sid, Date.now()) + } catch (err) { + console.error('session-end sweep failed', err) + } } onSessionEnd?.(data) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 0f713e17..4327cd67 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -4,6 +4,8 @@ import { dirname } from 'node:path' import { MachineStore } from './machineStore' import { MessageStore } from './messageStore' +import { addMessage } from './messages' +import type { StoredMessage } from './types' import { PushStore } from './pushStore' import { FcmStore } from './fcmStore' import { ScratchlistStore } from './scratchlistStore' @@ -143,6 +145,101 @@ export class Store { })() } + /** Resolve a durable OpenCode clear reservation and insert in one SQLite transaction. */ + addMessageForCurrentSession( + sessionId: string, + content: unknown, + localId?: string, + scheduledAt?: number | null + ): { sessionId: string; message: StoredMessage } { + return this.db.transaction(() => { + const row = this.db.prepare('SELECT namespace, metadata FROM sessions WHERE id = ?').get(sessionId) as { namespace: string; metadata: string | null } | undefined + if (!row) throw new Error('Message source session not found') + let targetSessionId = sessionId + if (row?.metadata) { + const metadata = JSON.parse(row.metadata) as { opencodeClearOperation?: { replacementSessionId?: string; state?: string }, supersededBySessionId?: string } + targetSessionId = metadata.supersededBySessionId + ?? (metadata.opencodeClearOperation?.state !== 'aborted' + ? metadata.opencodeClearOperation?.replacementSessionId + : undefined) + ?? sessionId + } + if (targetSessionId !== sessionId) { + const target = this.db.prepare('SELECT 1 FROM sessions WHERE id = ? AND namespace = ?') + .get(targetSessionId, row.namespace) + if (!target) throw new Error('OpenCode clear redirect target is unavailable in the source namespace') + } + return { sessionId: targetSessionId, message: addMessage(this.db, targetSessionId, content, localId, scheduledAt) } + })() + } + + /** Durable delivery gate for a preallocated replacement owned by an unfinished clear. */ + isOpenCodeClearDeliveryGated(sessionId: string): boolean { + const target = this.db.prepare('SELECT namespace FROM sessions WHERE id = ?') + .get(sessionId) as { namespace: string } | undefined + if (!target) return false + const rows = this.db.prepare('SELECT metadata FROM sessions WHERE namespace = ? AND metadata IS NOT NULL') + .all(target.namespace) as Array<{ metadata: string }> + return rows.some((row) => { + try { + const operation = (JSON.parse(row.metadata) as { + opencodeClearOperation?: { replacementSessionId?: string; state?: string } + }).opencodeClearOperation + return operation?.replacementSessionId === sessionId + && operation.state !== 'completed' + && operation.state !== 'aborted' + } catch { + return false + } + }) + } + + abortOpenCodeClearOperation( + sessionId: string, + replacementSessionId: string, + metadata: unknown, + expectedVersion: number, + namespace: string, + expected?: { replacementSessionId: string; state: string; requireInactive?: boolean } + ) { + return this.db.transaction(() => { + const current = this.sessions.getSessionByNamespace(sessionId, namespace) + const operation = current?.metadata && typeof current.metadata === 'object' + ? (current.metadata as { opencodeClearOperation?: { replacementSessionId?: string; state?: string } }).opencodeClearOperation + : undefined + if (expected && (!current + || (expected.requireInactive === true && current.active) + || operation?.replacementSessionId !== expected.replacementSessionId + || operation.state !== expected.state)) { + return { result: 'version-mismatch' as const } + } + const result = this.sessions.updateSessionMetadata(sessionId, metadata, expectedVersion, namespace, { touchUpdatedAt: false }) + if (result.result === 'success') this.messages.moveUninvokedMessages(replacementSessionId, sessionId) + return result + })() + } + + transitionOpenCodeClearOperation( + sessionId: string, + metadata: unknown, + expectedVersion: number, + namespace: string, + expected: { replacementSessionId: string; state: string } + ) { + return this.db.transaction(() => { + const current = this.sessions.getSessionByNamespace(sessionId, namespace) + const operation = current?.metadata && typeof current.metadata === 'object' + ? (current.metadata as { opencodeClearOperation?: { replacementSessionId?: string; state?: string } }).opencodeClearOperation + : undefined + if (!current + || operation?.replacementSessionId !== expected.replacementSessionId + || operation.state !== expected.state) { + return { result: 'version-mismatch' as const } + } + return this.sessions.updateSessionMetadata(sessionId, metadata, expectedVersion, namespace, { touchUpdatedAt: false }) + })() + } + close(): void { if (this.closed) return this.db.close() diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 9c5b3d74..c44d9543 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -23,7 +23,10 @@ import { minFutureScheduledAtBySessionIds, countMessages, markMessagesInvoked, + markUninvokedImmediateMessages, mergeSessionMessages, + moveUninvokedScheduledMessages, + moveUninvokedMessages, copyMessageToSession as copyStoredMessageToSession, copyMessagesToSession as copyStoredMessagesToSession, getAllMessages, @@ -154,6 +157,18 @@ export class MessageStore { return markMessagesInvoked(this.db, sessionId, localIds, invokedAt) } + markUninvokedImmediateMessages(sessionId: string, invokedAt: number): string[] { + return markUninvokedImmediateMessages(this.db, sessionId, invokedAt) + } + + moveUninvokedScheduledMessages(fromSessionId: string, toSessionId: string): number { + return moveUninvokedScheduledMessages(this.db, fromSessionId, toSessionId) + } + + moveUninvokedMessages(fromSessionId: string, toSessionId: string): number { + return moveUninvokedMessages(this.db, fromSessionId, toSessionId) + } + mergeSessionMessages(fromSessionId: string, toSessionId: string): { moved: number; oldMaxSeq: number; newMaxSeq: number } { return mergeSessionMessages(this.db, fromSessionId, toSessionId) } diff --git a/hub/src/store/messages.test.ts b/hub/src/store/messages.test.ts index 7e47c9ef..441e36cf 100644 --- a/hub/src/store/messages.test.ts +++ b/hub/src/store/messages.test.ts @@ -435,6 +435,42 @@ describe('countFutureScheduledLocalMessages', () => { }) }) +describe('moveUninvokedScheduledMessages', () => { + it('atomically moves only pending scheduled rows to the replacement session', () => { + const store = makeStore() + const source = makeSession(store, 'scheduled-source') + const replacement = makeSession(store, 'scheduled-replacement') + const now = Date.now() + const scheduled = store.messages.addMessage(source.id, { text: 'later' }, 'scheduled-local', now + 60_000) + store.messages.addMessage(source.id, { text: 'ordinary queued' }, 'ordinary-local') + + expect(store.messages.moveUninvokedScheduledMessages(source.id, replacement.id)).toBe(1) + expect(store.messages.getAllMessages(source.id).map((message) => message.id)).not.toContain(scheduled.id) + expect(store.messages.getAllMessages(replacement.id)).toEqual([ + expect.objectContaining({ id: scheduled.id, localId: 'scheduled-local', scheduledAt: now + 60_000, invokedAt: null }) + ]) + expect(store.messages.getUninvokedLocalMessages(source.id)).toEqual([ + expect.objectContaining({ localId: 'ordinary-local', scheduledAt: null }) + ]) + }) +}) + +describe('markUninvokedImmediateMessages', () => { + it('settles immediate queued rows while preserving scheduled rows for clear transfer', () => { + const store = makeStore() + const source = makeSession(store, 'clear-source-immediate') + const invokedAt = Date.now() + store.messages.addMessage(source.id, { text: 'immediate' }, 'immediate-local') + store.messages.addMessage(source.id, { text: 'scheduled' }, 'scheduled-local', invokedAt + 60_000) + + expect(store.messages.markUninvokedImmediateMessages(source.id, invokedAt)).toEqual(['immediate-local']) + expect(store.messages.getAllMessages(source.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ localId: 'immediate-local', invokedAt }), + expect.objectContaining({ localId: 'scheduled-local', invokedAt: null }) + ])) + }) +}) + describe('content codec integration', () => { it('stores large agent content compressed and returns it truncated on read', () => { const store = makeStore() diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 71c98a4b..b20401e2 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -711,6 +711,118 @@ export function markMessagesInvoked( ).run(invokedAt, sessionId, ...localIds).changes } +/** Settle immediate queued rows on an archived clear source without touching + * scheduled rows, which must remain uninvoked for transfer to the replacement. */ +export function markUninvokedImmediateMessages( + db: Database, + sessionId: string, + invokedAt: number +): string[] { + const rows = db.prepare(` + SELECT local_id FROM messages + WHERE session_id = ? + AND local_id IS NOT NULL + AND scheduled_at IS NULL + AND invoked_at IS NULL + ORDER BY seq ASC + `).all(sessionId) as Array<{ local_id: string }> + if (rows.length === 0) return [] + + db.prepare(` + UPDATE messages + SET invoked_at = ? + WHERE session_id = ? + AND local_id IS NOT NULL + AND scheduled_at IS NULL + AND invoked_at IS NULL + `).run(invokedAt, sessionId) + return rows.map((row) => row.local_id) +} + +/** + * Reassign only uninvoked scheduled rows when an archived OpenCode session + * is replaced by /clear. The transaction preserves ids/localIds so the normal + * scheduled-message ack path continues on the replacement session. + */ +export function moveUninvokedScheduledMessages( + db: Database, + fromSessionId: string, + toSessionId: string +): number { + if (fromSessionId === toSessionId) return 0 + + const rows = db.prepare(` + SELECT id FROM messages + WHERE session_id = ? + AND scheduled_at IS NOT NULL + AND invoked_at IS NULL + ORDER BY seq ASC + `).all(fromSessionId) as Array<{ id: string }> + if (rows.length === 0) return 0 + + try { + db.exec('BEGIN') + let nextSeq = getMaxSeq(db, toSessionId) + const update = db.prepare('UPDATE messages SET session_id = ?, seq = ? WHERE id = ?') + for (const row of rows) { + nextSeq += 1 + update.run(toSessionId, nextSeq, row.id) + } + bumpMessageEpoch(db, fromSessionId) + bumpMessageEpoch(db, toSessionId) + db.exec('COMMIT') + return rows.length + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +/** + * Move every still-held prompt to a reserved replacement, preserving FIFO. + * If both sessions already contain the same non-null localId, the replacement + * row is authoritative (it represents the retry/new owner) and only the + * uninvoked source duplicate is discarded. + */ +export function moveUninvokedMessages(db: Database, fromSessionId: string, toSessionId: string): number { + if (fromSessionId === toSessionId) return 0 + return db.transaction(() => { + const discarded = db.prepare(` + DELETE FROM messages + WHERE session_id = ? + AND invoked_at IS NULL + AND local_id IS NOT NULL + AND EXISTS ( + SELECT 1 FROM messages AS target + WHERE target.session_id = ? + AND target.local_id = messages.local_id + ) + `).run(fromSessionId, toSessionId).changes + const rows = db.prepare(` + SELECT id, session_id FROM messages + WHERE session_id IN (?, ?) AND invoked_at IS NULL + -- created_at is millisecond-granularity; rowid is the durable + -- cross-session insertion order for ties within this database. + ORDER BY created_at ASC, + rowid ASC, + seq ASC, + id ASC + `).all(fromSessionId, toSessionId) as Array<{ id: string; session_id: string }> + const moved = rows.filter((row) => row.session_id === fromSessionId).length + if (discarded === 0 && moved === 0) return 0 + const invokedMax = db.prepare(` + SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages + WHERE session_id = ? AND invoked_at IS NOT NULL + `).get(toSessionId) as { maxSeq: number } + let nextSeq = invokedMax.maxSeq + const update = db.prepare('UPDATE messages SET session_id = ?, seq = ? WHERE id = ?') + for (const row of rows) update.run(toSessionId, ++nextSeq, row.id) + bumpMessageEpoch(db, fromSessionId) + bumpMessageEpoch(db, toSessionId) + return discarded + moved + })() +} + export function mergeSessionMessages( db: Database, fromSessionId: string, diff --git a/hub/src/sync/aliveEvents.test.ts b/hub/src/sync/aliveEvents.test.ts index abc02076..8cdaaae8 100644 --- a/hub/src/sync/aliveEvents.test.ts +++ b/hub/src/sync/aliveEvents.test.ts @@ -16,6 +16,37 @@ function createPublisher(events: SyncEvent[]): EventPublisher { } describe('alive incremental events', () => { + it('replays durable immediate prompts on every attach until consumed', () => { + const store = new Store(':memory:') + const emitted: Array<{ body?: { t?: string; message?: { localId?: string | null } } }> = [] + const io = { + of: () => ({ + to: () => ({ emit: (_event: string, payload: unknown) => emitted.push(payload as typeof emitted[number]) }) + }) + } + const engine = new SyncEngine(store, io as never, new RpcRegistry(), { broadcast() {} } as never) + try { + const session = engine.getOrCreateSession('attach-replay', { path: '/tmp/project', host: 'localhost' }, null, 'default') + store.messages.addMessage(session.id, { text: 'queued before attach' }, 'queued-before-attach') + const invoked = store.messages.addMessage(session.id, { text: 'already consumed' }, 'already-consumed') + store.messages.markMessagesInvoked(session.id, ['already-consumed'], invoked.createdAt + 1) + store.messages.addMessage(session.id, { text: 'future scheduled' }, 'future-scheduled', Date.now() + 60_000) + expect(emitted).toEqual([]) + + engine.handleSessionAlive({ sid: session.id, time: Date.now() }) + engine.handleSessionAlive({ sid: session.id, time: Date.now() + 1 }) + expect(emitted.map((update) => update.body?.message?.localId)).toEqual([ + 'queued-before-attach', 'queued-before-attach' + ]) + + store.messages.markMessagesInvoked(session.id, ['queued-before-attach'], Date.now()) + engine.handleSessionAlive({ sid: session.id, time: Date.now() + 2 }) + expect(emitted.map((update) => update.body?.message?.localId)).toEqual([ + 'queued-before-attach', 'queued-before-attach' + ]) + } finally { engine.stop() } + }) + it('includes active=true in session alive updates', () => { const store = new Store(':memory:') const events: SyncEvent[] = [] diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 911021c2..5fdd17e2 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -575,7 +575,7 @@ export class MessageService { sentFrom?: 'telegram-bot' | 'webapp' scheduledAt?: number | null } - ): Promise { + ): Promise { // Defence-in-depth invariant for non-REST callers (Telegram bot, MCP, // internal callers). Attachment paths live under the CLI session's // upload directory which `cleanupUploadDir` purges on session end; a @@ -602,13 +602,15 @@ export class MessageService { } } - const msg = this.store.messages.addMessage( + const inserted = this.store.addMessageForCurrentSession( sessionId, content, payload.localId ?? undefined, payload.scheduledAt ?? null ) - this.onSessionActivity?.(sessionId, msg.createdAt) + const actualSessionId = inserted.sessionId + const msg = inserted.message + this.onSessionActivity?.(actualSessionId, msg.createdAt) // Only emit to CLI if the message is not scheduled for the future. // Mature or non-scheduled messages go through immediately; future scheduled @@ -617,14 +619,14 @@ export class MessageService { // the pre-insert `now` capture could misclassify a borderline scheduledAt // as future when it has already become past by the time we check. const isFutureScheduled = msg.scheduledAt !== null && msg.scheduledAt > Date.now() - if (!isFutureScheduled) { + if (!isFutureScheduled && !this.store.isOpenCodeClearDeliveryGated(actualSessionId)) { const update = { id: msg.id, seq: msg.seq, createdAt: msg.createdAt, body: { t: 'new-message' as const, - sid: sessionId, + sid: actualSessionId, message: { id: msg.id, seq: msg.seq, @@ -634,13 +636,13 @@ export class MessageService { } } } - this.io.of('/cli').to(`session:${sessionId}`).emit('update', update) + this.io.of('/cli').to(`session:${actualSessionId}`).emit('update', update) } // Always emit message-received to Web SSE so the floating bar renders. this.publisher.emit({ type: 'message-received', - sessionId, + sessionId: actualSessionId, message: { id: msg.id, seq: msg.seq, @@ -651,6 +653,7 @@ export class MessageService { scheduledAt: msg.scheduledAt } }) + return actualSessionId } /** @@ -684,6 +687,59 @@ export class MessageService { return { localIds, invokedAt } } + /** Replay durable immediate prompts whenever their CLI session attaches. */ + replayImmediateQueuedMessages(sessionId: string): number { + if (this.store.isOpenCodeClearDeliveryGated(sessionId)) return 0 + const queued = this.store.messages.getImmediateQueuedLocalMessages(sessionId) + for (const msg of queued) { + const update = { + id: msg.id, + seq: msg.seq, + createdAt: msg.createdAt, + body: { + t: 'new-message' as const, + sid: sessionId, + message: { + id: msg.id, + seq: msg.seq, + createdAt: msg.createdAt, + localId: msg.localId, + content: msg.content + } + } + } + this.io.of('/cli').to(`session:${sessionId}`).emit('update', update) + } + return queued.length + } + + /** Release a completed clear handoff in finalized seq order. */ + releaseDeliverableQueuedMessages(sessionId: string, now: number = Date.now()): number { + if (this.store.isOpenCodeClearDeliveryGated(sessionId)) return 0 + const queued = this.store.messages.getUninvokedLocalMessages(sessionId) + .filter((msg) => msg.scheduledAt === null || msg.scheduledAt <= now) + for (const msg of queued) { + const update = { + id: msg.id, + seq: msg.seq, + createdAt: msg.createdAt, + body: { + t: 'new-message' as const, + sid: sessionId, + message: { + id: msg.id, + seq: msg.seq, + createdAt: msg.createdAt, + localId: msg.localId, + content: msg.content + } + } + } + this.io.of('/cli').to(`session:${sessionId}`).emit('update', update) + } + return queued.length + } + /** Called by the hub 5-second tick (syncEngine.expireInactive). * * Finds all scheduled messages whose scheduled_at <= now and emits them to @@ -702,8 +758,14 @@ export class MessageService { releaseMatureScheduledMessages(now: number, skipSessionIds?: ReadonlySet): void { const mature = this.store.messages.getMatureScheduledMessages(now) const maturedSessionIds = new Set() + const deliveryGateBySession = new Map() for (const msg of mature) { - if (skipSessionIds?.has(msg.sessionId)) { + let deliveryGated = deliveryGateBySession.get(msg.sessionId) + if (deliveryGated === undefined) { + deliveryGated = this.store.isOpenCodeClearDeliveryGated(msg.sessionId) + deliveryGateBySession.set(msg.sessionId, deliveryGated) + } + if (skipSessionIds?.has(msg.sessionId) || deliveryGated) { continue } const localId = msg.localId diff --git a/hub/src/sync/opencodeClear.test.ts b/hub/src/sync/opencodeClear.test.ts new file mode 100644 index 00000000..7f1c75ee --- /dev/null +++ b/hub/src/sync/opencodeClear.test.ts @@ -0,0 +1,827 @@ +import { describe, expect, it, mock } from 'bun:test' +import { RpcRegistry } from '../socket/rpcRegistry' +import { Store } from '../store' +import { SyncEngine, type SyncEvent } from './syncEngine' + +function createEngine(onCliEmit?: (payload: unknown) => void) { + const store = new Store(':memory:') + const engine = new SyncEngine(store, { + of: () => ({ to: () => ({ emit: (_event: string, payload: unknown) => onCliEmit?.(payload) }) }) + } as never, new RpcRegistry(), { broadcast() {} } as never) + engine.getOrCreateMachine( + 'machine-1', + { host: 'host', platform: 'linux', happyCliVersion: 'test' }, + null, + 'default' + ) + return { store, engine } +} + +function createClearSource(engine: SyncEngine, metadata: Record = {}) { + return engine.getOrCreateSession('clear-source', { + path: '/tmp/project', + host: 'host', + machineId: 'machine-1', + flavor: 'opencode', + lifecycleState: 'archived', + archiveReason: 'Cleared by /clear', + preferredPermissionMode: 'yolo', + opencodeSessionId: 'native-source-must-not-resume', + ...metadata + }, null, 'default', 'opencode/model', 'effort-x', 'high') +} + + +function currentReplacementId(engine: SyncEngine, sessionId: string): string { + const id = engine.getSessionByNamespace(sessionId, 'default')?.metadata?.opencodeClearOperation?.replacementSessionId + if (!id) throw new Error('clear reservation missing') + return id +} + +function setSpawn(engine: SyncEngine, spawnSession: ReturnType) { + ;(engine as unknown as { rpcGateway: { spawnSession: typeof spawnSession } }).rpcGateway.spawnSession = spawnSession +} + +describe('SyncEngine.clearOpenCodeSession', () => { + it.each(['resume', 'reopen'] as const)('allows %s after a failed native cleanup aborts clear', async (action) => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession(`abort-${action}`, { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + expect(engine.abortOpenCodeClearSession(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'success' }) + const abortedMetadata = engine.getSessionByNamespace(source.id, 'default')!.metadata! + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'error' }) + const ended = store.sessions.getSessionByNamespace(source.id, 'default')! + store.sessions.updateSessionMetadata(source.id, abortedMetadata, ended.metadataVersion, 'default') + ;(engine as unknown as { sessionCache: { refreshSession(id: string): unknown } }).sessionCache.refreshSession(source.id) + setSpawn(engine, mock(async () => ({ type: 'success' as const, sessionId: source.id }))) + const result = action === 'resume' + ? await engine.resumeSession(source.id, 'default') + : await engine.reopenSession(source.id, 'default') + expect(result).not.toMatchObject({ type: 'error', code: 'resume_unavailable' }) + } finally { engine.stop() } + }) + it('durably reserves a replacement while the source is active and reuses it after archival', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('active-clear-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + expect(reserved.type).toBe('success') + if (reserved.type !== 'success') throw new Error('reservation failed') + expect(typeof reserved.sessionId).toBe('string') + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation).toMatchObject({ + replacementSessionId: reserved.sessionId, state: 'reserved' + }) + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'success' }) + const metadataBeforeEnd = engine.getSessionByNamespace(source.id, 'default')!.metadata! + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'cleared' }) + const storedAfterEnd = store.sessions.getSessionByNamespace(source.id, 'default')! + store.sessions.updateSessionMetadata(source.id, { ...metadataBeforeEnd, lifecycleState: 'archived', archiveReason: 'Cleared by /clear' }, storedAfterEnd.metadataVersion, 'default') + ;(engine as unknown as { sessionCache: { refreshSession(id: string): unknown } }).sessionCache.refreshSession(source.id) + const spawnSession = mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string })) + setSpawn(engine, spawnSession) + await expect(engine.clearOpenCodeSession(source.id, 'default')).resolves.toEqual({ type: 'success', sessionId: reserved.sessionId }) + } finally { engine.stop() } + }) + + it('atomically redirects messages arriving after reservation to the replacement', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('active-clear-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + await engine.sendMessage(source.id, { text: 'late immediate', localId: 'late-immediate' }) + await engine.sendMessage(source.id, { text: 'late scheduled', localId: 'late-scheduled', scheduledAt: Date.now() + 60_000 }) + expect(store.messages.getAllMessages(source.id)).toEqual([]) + expect(store.messages.getAllMessages(reserved.sessionId).map((m) => m.localId)).toEqual(['late-immediate', 'late-scheduled']) + } finally { engine.stop() } + }) + + it('preserves FIFO from a source prompt before reservation to a redirected target prompt', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('reservation-boundary-fifo', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + store.messages.addMessage(source.id, { text: 'A before reservation' }, 'fifo-a') + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + await engine.sendMessage(source.id, { text: 'B after reservation', localId: 'fifo-b' }) + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', reserved.sessionId)).toMatchObject({ type: 'success' }) + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'cleared' }) + setSpawn(engine, mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string }))) + + await (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + + expect(store.messages.getAllMessages(reserved.sessionId).map((message) => message.localId)).toEqual(['fifo-a', 'fifo-b']) + expect(store.messages.getAllMessages(source.id)).toEqual([]) + } finally { engine.stop() } + }) + + it('gates replacement delivery during spawn and releases finalized FIFO after linking', async () => { + const emitted: Array<{ body?: { message?: { localId?: string | null } } }> = [] + const { store, engine } = createEngine((payload) => emitted.push(payload as typeof emitted[number])) + try { + const source = engine.getOrCreateSession('spawn-delivery-gate', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + emitted.length = 0 + store.messages.addMessage(source.id, { text: 'A before reservation' }, 'gated-a') + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + await engine.sendMessage(source.id, { text: 'B after reservation', localId: 'gated-b' }) + store.messages.addMessage(reserved.sessionId, { text: 'mature but gated' }, 'gated-scheduled', Date.now() - 1) + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', reserved.sessionId)).toMatchObject({ type: 'success' }) + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'cleared' }) + let releaseSpawn!: () => void + let spawnStarted = false + const spawnWait = new Promise((resolve) => { releaseSpawn = resolve }) + setSpawn(engine, mock(async (...args: unknown[]) => { + spawnStarted = true + await spawnWait + return { type: 'success' as const, sessionId: args[12] as string } + })) + + const reconcile = (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + while (!spawnStarted) await Promise.resolve() + expect(store.isOpenCodeClearDeliveryGated(reserved.sessionId)).toBe(true) + engine.handleSessionAlive({ sid: reserved.sessionId, time: Date.now() }) + engine.handleSessionAlive({ sid: reserved.sessionId, time: Date.now() + 1 }) + ;(engine as unknown as { messageService: { releaseMatureScheduledMessages(now: number): void } }) + .messageService.releaseMatureScheduledMessages(Date.now()) + expect(emitted).toEqual([]) + + releaseSpawn() + await reconcile + expect(store.isOpenCodeClearDeliveryGated(reserved.sessionId)).toBe(false) + expect(emitted.map((update) => update.body?.message?.localId)).toEqual([ + 'gated-a', 'gated-b', 'gated-scheduled' + ]) + } finally { engine.stop() } + }) + + it.each([ + ['supersededBySessionId', 'foreign'], + ['opencodeClearOperation', 'foreign'], + ['supersededBySessionId', 'missing'], + ['opencodeClearOperation', 'missing'] + ] as const)('fails closed for a forged %s redirect to a %s target', async (field, targetKind) => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession(`forged-${field}-${targetKind}`, { + path: '/tmp/project', host: 'host', flavor: 'opencode' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const targetId = `target-${field}-${targetKind}` + if (targetKind === 'foreign') { + engine.getOrCreateSession(`foreign-${field}`, { path: '/tmp/foreign', host: 'host' }, null, 'other', undefined, undefined, undefined, targetId) + } + const stored = store.sessions.getSessionByNamespace(source.id, 'default')! + const redirect = field === 'supersededBySessionId' + ? { supersededBySessionId: targetId } + : { opencodeClearOperation: { replacementSessionId: targetId, state: 'reserved', updatedAt: Date.now() } } + store.sessions.updateSessionMetadata(source.id, { + ...(stored.metadata as Record), ...redirect + }, stored.metadataVersion, 'default') + const events: SyncEvent[] = [] + engine.subscribe((event) => events.push(event)) + + await expect(engine.sendMessage(source.id, { text: 'must not cross namespace', localId: 'forged-local' })).rejects.toThrow( + 'redirect target is unavailable' + ) + + expect(store.messages.getAllMessages(source.id)).toEqual([]) + if (targetKind === 'foreign') expect(store.messages.getAllMessages(targetId)).toEqual([]) + expect(events).not.toContainEqual(expect.objectContaining({ type: 'message-received' })) + } finally { engine.stop() } + }) + + it.each(['supersededBySessionId', 'opencodeClearOperation'] as const)( + 'allows a same-namespace %s redirect', + async (field) => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession(`same-namespace-${field}`, { path: '/tmp/project', host: 'host' }, null, 'default') + const target = engine.getOrCreateSession(`same-target-${field}`, { path: '/tmp/project', host: 'host' }, null, 'default') + const stored = store.sessions.getSessionByNamespace(source.id, 'default')! + const redirect = field === 'supersededBySessionId' + ? { supersededBySessionId: target.id } + : { opencodeClearOperation: { replacementSessionId: target.id, state: 'reserved', updatedAt: Date.now() } } + store.sessions.updateSessionMetadata(source.id, { + ...(stored.metadata as Record), ...redirect + }, stored.metadataVersion, 'default') + await engine.sendMessage(source.id, { text: 'same namespace', localId: `same-${field}` }) + expect(store.messages.getAllMessages(source.id)).toEqual([]) + expect(store.messages.getAllMessages(target.id)).toEqual([ + expect.objectContaining({ localId: `same-${field}`, invokedAt: null }) + ]) + } finally { engine.stop() } + } + ) + + it('recovers cleanup-confirmed clear when the CLI dies before writing archive metadata', async () => { + const { engine } = createEngine() + try { + const source = engine.getOrCreateSession('crashed-clear-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'success' }) + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'error' }) + const spawnSession = mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string })) + setSpawn(engine, spawnSession) + await (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata).toMatchObject({ + lifecycleState: 'archived', archiveReason: 'Cleared by /clear', supersededBySessionId: reserved.sessionId + }) + expect(spawnSession).toHaveBeenCalledTimes(1) + } finally { engine.stop() } + }) + + it('recovers a persisted pending spawn with the exact replacement identity after restart', async () => { + const { engine } = createEngine() + try { + const replacementSessionId = 'pending-before-spawn' + const source = createClearSource(engine, { + opencodeClearOperation: { + replacementSessionId, + state: 'pending', + updatedAt: Date.now() + } + }) + const spawnSession = mock(async (...args: unknown[]) => ({ + type: 'success' as const, + sessionId: args[12] as string + })) + setSpawn(engine, spawnSession) + + await (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + + expect(spawnSession).toHaveBeenCalledTimes(1) + expect(spawnSession.mock.calls[0]?.[12]).toBe(replacementSessionId) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata).toMatchObject({ + supersededBySessionId: replacementSessionId, + opencodeClearOperation: { replacementSessionId, state: 'completed' } + }) + } finally { engine.stop() } + }) + + it('safely aborts an inactive unconfirmed reservation and restores held messages', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('unconfirmed-clear-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + await engine.sendMessage(source.id, { text: 'held during lost response', localId: 'lost-response-held' }) + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'error' }) + const spawnSession = mock(async () => ({ type: 'success' as const, sessionId: 'must-not-spawn' })) + setSpawn(engine, spawnSession) + await (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + expect(spawnSession).not.toHaveBeenCalled() + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('aborted') + expect(store.messages.getAllMessages(source.id)).toEqual([ + expect.objectContaining({ localId: 'lost-response-held', invokedAt: null }) + ]) + expect((engine as unknown as { isOpenCodeClearSource(session: unknown): boolean }).isOpenCodeClearSource( + engine.getSessionByNamespace(source.id, 'default')! + )).toBe(false) + } finally { engine.stop() } + }) + + it('does not treat heartbeat expiry as process-death proof for a live reservation', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('heartbeat-expired-reservation', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() - 120_000 }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + await engine.sendMessage(source.id, { text: 'still owned', localId: 'still-owned' }) + const cached = engine.getSessionByNamespace(source.id, 'default') as unknown as { activeAt: number } + cached.activeAt = Date.now() - 120_000 + const spawnSession = mock(async () => ({ type: 'success' as const, sessionId: 'must-not-spawn' })) + setSpawn(engine, spawnSession) + ;(engine as unknown as { expireInactive(): void }).expireInactive() + await (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + expect(engine.getSessionByNamespace(source.id, 'default')?.active).toBe(false) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('reserved') + expect(store.messages.getAllMessages(source.id)).toEqual([]) + expect(spawnSession).not.toHaveBeenCalled() + const gateway = (engine as unknown as { rpcGateway: { stopRunnerSession: ReturnType } }).rpcGateway + gateway.stopRunnerSession = mock(async () => 'still_alive' as const) + await expect(engine.resumeSession(source.id, 'default')).resolves.toMatchObject({ type: 'error', code: 'resume_unavailable' }) + gateway.stopRunnerSession = mock(async () => 'already_gone' as const) + expect(await (engine as unknown as { recoverInactiveReservedClear(session: unknown, namespace: string): Promise }) + .recoverInactiveReservedClear(engine.getSessionByNamespace(source.id, 'default')!, 'default')).toBe(true) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('aborted') + expect((engine as unknown as { isOpenCodeClearSource(session: unknown): boolean }).isOpenCodeClearSource( + engine.getSessionByNamespace(source.id, 'default')! + )).toBe(false) + expect(store.messages.getAllMessages(source.id)).toEqual([ + expect.objectContaining({ localId: 'still-owned', invokedAt: null }) + ]) + } finally { engine.stop() } + }) + + it.each(['confirm', 'reactivate'] as const)('does not abort when %s wins during StopSession await', async (winner) => { + const { engine } = createEngine() + try { + const source = engine.getOrCreateSession(`stop-race-${winner}`, { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + const cached = engine.getSessionByNamespace(source.id, 'default') as unknown as { activeAt: number } + cached.activeAt = Date.now() - 120_000 + ;(engine as unknown as { expireInactive(): void }).expireInactive() + let release!: () => void + const stop = new Promise((resolve) => { release = resolve }) + ;(engine as unknown as { rpcGateway: { stopRunnerSession: unknown } }).rpcGateway.stopRunnerSession = mock(async () => { + await stop + return 'already_gone' as const + }) + const recovery = (engine as unknown as { recoverInactiveReservedClear(session: unknown, namespace: string): Promise }) + .recoverInactiveReservedClear(engine.getSessionByNamespace(source.id, 'default')!, 'default') + await Promise.resolve() + if (winner === 'confirm') { + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'success' }) + } else { + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + } + release() + expect(await recovery).toBe(false) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state) + .toBe(winner === 'confirm' ? 'cleanup-confirmed' : 'reserved') + } finally { engine.stop() } + }) + + it('rejects a delayed cleanup confirmation after explicit exit owns the abort', () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('confirm-after-exit', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + const original = store.abortOpenCodeClearOperation.bind(store) + store.abortOpenCodeClearOperation = (() => ({ result: 'error' as const })) as typeof original + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'error' }) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('abort-needed') + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'error' }) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('abort-needed') + } finally { engine.stop() } + }) + + it('rejects a delayed cleanup-failure abort after cleanup confirmation', () => { + const { engine } = createEngine() + try { + const source = engine.getOrCreateSession('abort-after-confirm', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'success' }) + expect(engine.abortOpenCodeClearSession(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'error' }) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('cleanup-confirmed') + } finally { engine.stop() } + }) + + it('does not confirm a stale reservation identity', () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('stale-confirm-identity', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + const stored = store.sessions.getSessionByNamespace(source.id, 'default')! + store.sessions.updateSessionMetadata(source.id, { + ...(stored.metadata as Record), + opencodeClearOperation: { replacementSessionId: 'new-owner', state: 'reserved', updatedAt: Date.now() } + }, stored.metadataVersion, 'default') + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'error' }) + const persisted = store.sessions.getSessionByNamespace(source.id, 'default')?.metadata as Record + expect(persisted.opencodeClearOperation).toMatchObject({ + replacementSessionId: 'new-owner', state: 'reserved' + }) + } finally { engine.stop() } + }) + + it('treats a lost cleanup-confirm success response as an idempotent retry', () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('lost-confirm-response', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + const original = store.transitionOpenCodeClearOperation.bind(store) + let loseResponse = true + store.transitionOpenCodeClearOperation = ((...args: Parameters) => { + const result = original(...args) + if (loseResponse && result.result === 'success') { + loseResponse = false + return { result: 'version-mismatch' as const } + } + return result + }) as typeof store.transitionOpenCodeClearOperation + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toEqual({ + type: 'success', sessionId: reserved.sessionId + }) + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', currentReplacementId(engine, source.id))).toEqual({ + type: 'success', sessionId: reserved.sessionId + }) + } finally { engine.stop() } + }) + + it('treats a lost abort success response as an idempotent retry', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('lost-abort-response', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + await engine.sendMessage(source.id, { text: 'restore once', localId: 'restore-once' }) + const original = store.abortOpenCodeClearOperation.bind(store) + let loseResponse = true + store.abortOpenCodeClearOperation = ((...args: Parameters) => { + const result = original(...args) + if (loseResponse && result.result === 'success') { + loseResponse = false + return { result: 'version-mismatch' as const } + } + return result + }) as typeof store.abortOpenCodeClearOperation + expect(engine.abortOpenCodeClearSession(source.id, 'default', currentReplacementId(engine, source.id))).toEqual({ type: 'success', sessionId: source.id }) + expect(engine.abortOpenCodeClearSession(source.id, 'default', currentReplacementId(engine, source.id))).toEqual({ type: 'success', sessionId: source.id }) + expect(store.messages.getAllMessages(source.id)).toEqual([ + expect.objectContaining({ localId: 'restore-once', invokedAt: null }) + ]) + } finally { engine.stop() } + }) + + it.each(['confirm', 'abort'] as const)('does not let delayed reservation A %s mutate reservation B', (callback) => { + const { engine } = createEngine() + try { + const source = engine.getOrCreateSession(`stale-a-${callback}`, { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const first = engine.reserveOpenCodeClearSession(source.id, 'default') + if (first.type !== 'success') throw new Error('first reservation failed') + expect(engine.abortOpenCodeClearSession(source.id, 'default', first.sessionId)).toMatchObject({ type: 'success' }) + const second = engine.reserveOpenCodeClearSession(source.id, 'default') + if (second.type !== 'success') throw new Error('second reservation failed') + const result = callback === 'confirm' + ? engine.confirmOpenCodeClearCleanup(source.id, 'default', first.sessionId) + : engine.abortOpenCodeClearSession(source.id, 'default', first.sessionId) + expect(result).toMatchObject({ type: 'error' }) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation).toMatchObject({ + replacementSessionId: second.sessionId, + state: 'reserved' + }) + } finally { engine.stop() } + }) + + it('aborts a reservation after native cleanup failure and restores held rows to the source', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('abort-clear-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + await engine.sendMessage(source.id, { text: 'held', localId: 'held' }) + expect(store.messages.getAllMessages(reserved.sessionId)).toHaveLength(1) + expect(store.isOpenCodeClearDeliveryGated(reserved.sessionId)).toBe(true) + expect(engine.abortOpenCodeClearSession(source.id, 'default', currentReplacementId(engine, source.id))).toEqual({ type: 'success', sessionId: source.id }) + expect(store.isOpenCodeClearDeliveryGated(reserved.sessionId)).toBe(false) + expect(store.messages.getAllMessages(source.id).map((m) => m.localId)).toEqual(['held']) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('aborted') + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'error' }) + expect((engine as unknown as { isOpenCodeClearSource(session: unknown): boolean }).isOpenCodeClearSource( + engine.getSessionByNamespace(source.id, 'default')! + )).toBe(false) + } finally { engine.stop() } + }) + + it('durably retries an explicit-exit abort after a metadata write failure', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('abort-retry-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner', + lifecycleState: 'archived', archiveReason: 'Archived before clear abort' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + expect(engine.reserveOpenCodeClearSession(source.id, 'default')).toMatchObject({ type: 'success' }) + await engine.sendMessage(source.id, { text: 'restore atomically', localId: 'atomic-held' }) + const original = store.abortOpenCodeClearOperation.bind(store) + let fail = true + store.abortOpenCodeClearOperation = ((...args: Parameters) => { + if (fail) return { result: 'not-found' as const } + return original(...args) + }) as typeof store.abortOpenCodeClearOperation + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'error' }) + expect(store.messages.getAllMessages(source.id)).toEqual([]) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('abort-needed') + fail = false + await (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.state).toBe('aborted') + expect(store.messages.getAllMessages(source.id)).toEqual([ + expect.objectContaining({ localId: 'atomic-held', invokedAt: null }) + ]) + } finally { engine.stop() } + }) + + it('re-reserves an aborted operation with a fresh durable identity', () => { + const { engine } = createEngine() + try { + const source = engine.getOrCreateSession('retry-clear-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const first = engine.reserveOpenCodeClearSession(source.id, 'default') + if (first.type !== 'success') throw new Error('reservation failed') + expect(engine.abortOpenCodeClearSession(source.id, 'default', currentReplacementId(engine, source.id))).toMatchObject({ type: 'success' }) + const second = engine.reserveOpenCodeClearSession(source.id, 'default') + expect(second).toMatchObject({ type: 'success', sessionId: expect.any(String) }) + if (second.type !== 'success') throw new Error('re-reservation failed') + expect(second.sessionId).not.toBe(first.sessionId) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation).toMatchObject({ + replacementSessionId: second.sessionId, state: 'reserved' + }) + } finally { engine.stop() } + }) + it.each(['resume', 'reopen'] as const)('blocks %s of an archived clear source before spawning', async (action) => { + const { engine } = createEngine() + try { + const source = createClearSource(engine) + const spawnSession = mock(async () => ({ type: 'success' as const, sessionId: 'must-not-spawn' })) + setSpawn(engine, spawnSession) + + const result = action === 'resume' + ? await engine.resumeSession(source.id, 'default') + : await engine.reopenSession(source.id, 'default') + + expect(result).toMatchObject({ type: 'error', code: 'resume_unavailable' }) + expect(spawnSession).not.toHaveBeenCalled() + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata).toMatchObject({ + lifecycleState: 'archived', + archiveReason: 'Cleared by /clear' + }) + } finally { + engine.stop() + } + }) + + it('persists a preallocated replacement before spawning, preserving launch settings but never native source identity', async () => { + const { engine } = createEngine() + try { + const source = createClearSource(engine) + let operationAtSpawn: { replacementSessionId: string } | undefined + const spawnSession = mock(async (...args: unknown[]) => { + operationAtSpawn = engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation + return { + type: 'success' as const, + sessionId: args[12] as string + } + }) + setSpawn(engine, spawnSession) + + await expect(engine.clearOpenCodeSession(source.id, 'default')).resolves.toMatchObject({ + type: 'success', + sessionId: expect.any(String) + }) + const replacementSessionId = spawnSession.mock.calls[0]?.[12] as string + expect(replacementSessionId).toEqual(expect.any(String)) + expect(operationAtSpawn?.replacementSessionId).toBe(replacementSessionId) + expect(replacementSessionId).not.toBe(source.id) + expect(spawnSession).toHaveBeenCalledWith( + 'machine-1', + '/tmp/project', + 'opencode', + 'opencode/model', + 'high', + false, + undefined, + undefined, + undefined, + 'effort-x', + 'yolo', + undefined, + replacementSessionId, + undefined + ) + expect(engine.getSessionByNamespace(replacementSessionId, 'default')?.metadata).toMatchObject({ + flavor: 'opencode', + path: '/tmp/project' + }) + expect(engine.getSessionByNamespace(replacementSessionId, 'default')?.metadata?.opencodeSessionId).toBeUndefined() + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata).toMatchObject({ + supersededBySessionId: replacementSessionId + }) + } finally { + engine.stop() + } + }) + + it('reserves an independent replacement row for each cleared source', async () => { + const { engine } = createEngine() + try { + const first = createClearSource(engine) + const second = engine.getOrCreateSession('another-clear-source', { + path: '/tmp/another-project', host: 'host', machineId: 'machine-1', flavor: 'opencode', + lifecycleState: 'archived', archiveReason: 'Cleared by /clear' + }, null, 'default') + const spawnSession = mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string })) + setSpawn(engine, spawnSession) + + const firstResult = await engine.clearOpenCodeSession(first.id, 'default') + const secondResult = await engine.clearOpenCodeSession(second.id, 'default') + expect(firstResult).toMatchObject({ type: 'success' }) + expect(secondResult).toMatchObject({ type: 'success' }) + if (firstResult.type !== 'success' || secondResult.type !== 'success') throw new Error('expected successful clears') + expect(firstResult.sessionId).not.toBe(secondResult.sessionId) + } finally { + engine.stop() + } + }) + + it('retries a failed spawn against the same durable replacement id', async () => { + const { engine } = createEngine() + try { + const source = createClearSource(engine) + const firstSpawn = mock(async () => ({ type: 'error' as const, message: 'runner unavailable' })) + setSpawn(engine, firstSpawn) + await expect(engine.clearOpenCodeSession(source.id, 'default')).resolves.toMatchObject({ + type: 'error', code: 'spawn_failed' + }) + + const pendingId = engine.getSessionByNamespace(source.id, 'default')?.metadata?.opencodeClearOperation?.replacementSessionId + expect(pendingId).toEqual(expect.any(String)) + if (!pendingId) throw new Error('expected durable replacement id') + const secondSpawn = mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string })) + setSpawn(engine, secondSpawn) + await expect(engine.clearOpenCodeSession(source.id, 'default')).resolves.toEqual({ + type: 'success', sessionId: pendingId + }) + expect(secondSpawn.mock.calls[0]?.[12]).toBe(pendingId) + } finally { + engine.stop() + } + }) + + it('returns the durable replacement to a reconnecting clear source without spawning again', async () => { + const { engine } = createEngine() + try { + const source = createClearSource(engine, { supersededBySessionId: 'already-fresh' }) + const spawnSession = mock(async () => ({ type: 'success' as const, sessionId: 'must-not-spawn' })) + setSpawn(engine, spawnSession) + + await expect(engine.clearOpenCodeSession(source.id, 'default')).resolves.toEqual({ + type: 'success', sessionId: 'already-fresh' + }) + expect(spawnSession).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) + + it('refuses source metadata that points to a machine outside its namespace', async () => { + const { engine } = createEngine() + try { + const source = engine.getOrCreateSession('cross-namespace-clear', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', + lifecycleState: 'archived', archiveReason: 'Cleared by /clear' + }, null, 'other') + const spawnSession = mock(async () => ({ type: 'success' as const, sessionId: 'must-not-spawn' })) + setSpawn(engine, spawnSession) + + await expect(engine.clearOpenCodeSession(source.id, 'other')).resolves.toMatchObject({ + type: 'error', code: 'clear_unavailable' + }) + expect(spawnSession).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) + + it('moves pending scheduled prompts to the replacement before it links the archived source', async () => { + const { store, engine } = createEngine() + try { + const source = createClearSource(engine) + const events: Array<{ type: string, sessionId?: string }> = [] + engine.subscribe((event) => events.push(event)) + const scheduled = store.messages.addMessage(source.id, { text: 'send later' }, 'scheduled-clear', Date.now() + 60_000) + const spawnSession = mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string })) + setSpawn(engine, spawnSession) + + const result = await engine.clearOpenCodeSession(source.id, 'default') + expect(result).toMatchObject({ type: 'success' }) + if (result.type !== 'success') throw new Error('expected successful clear') + expect(store.messages.getAllMessages(source.id)).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ id: scheduled.id }) + ])) + expect(store.messages.getAllMessages(result.sessionId)).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: scheduled.id, localId: 'scheduled-clear', invokedAt: null }) + ])) + expect(events).toContainEqual(expect.objectContaining({ type: 'messages-invalidated', sessionId: source.id })) + expect(events).toContainEqual(expect.objectContaining({ type: 'messages-invalidated', sessionId: result.sessionId })) + } finally { + engine.stop() + } + }) + + it('moves every held prompt to the replacement without falsely consuming it', async () => { + const { store, engine } = createEngine() + try { + const source = createClearSource(engine) + store.messages.addMessage(source.id, { text: 'rejected immediate' }, 'immediate-after-clear') + store.messages.addMessage(source.id, { text: 'scheduled transfer' }, 'scheduled-after-clear', Date.now() + 60_000) + const events: Array<{ type: string, sessionId?: string, localIds?: string[] }> = [] + engine.subscribe((event) => events.push(event)) + setSpawn(engine, mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string }))) + + const result = await engine.clearOpenCodeSession(source.id, 'default') + if (result.type !== 'success') throw new Error('expected successful clear') + + expect(store.messages.getAllMessages(source.id)).toEqual([]) + expect(store.messages.getAllMessages(result.sessionId)).toEqual(expect.arrayContaining([ + expect.objectContaining({ localId: 'immediate-after-clear', invokedAt: null }), + expect.objectContaining({ localId: 'scheduled-after-clear', invokedAt: null }) + ])) + expect(events).not.toContainEqual(expect.objectContaining({ type: 'messages-consumed' })) + } finally { + engine.stop() + } + }) + + it('keeps the replacement copy when source and target share a queued localId', async () => { + const { store, engine } = createEngine() + try { + const source = engine.getOrCreateSession('duplicate-held-source', { + path: '/tmp/project', host: 'host', machineId: 'machine-1', flavor: 'opencode', startedBy: 'runner' + }, null, 'default') + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const reserved = engine.reserveOpenCodeClearSession(source.id, 'default') + if (reserved.type !== 'success') throw new Error('reservation failed') + store.messages.addMessage(reserved.sessionId, { text: 'authoritative retry' }, 'duplicate-local-id') + store.messages.addMessage(source.id, { text: 'stale source copy' }, 'duplicate-local-id') + store.messages.addMessage(source.id, { text: 'unique immediate' }, 'unique-immediate') + store.messages.addMessage(source.id, { text: 'unique scheduled' }, 'unique-scheduled', Date.now() + 60_000) + expect(engine.confirmOpenCodeClearCleanup(source.id, 'default', reserved.sessionId)).toMatchObject({ type: 'success' }) + engine.handleSessionEnd({ sid: source.id, time: Date.now(), reason: 'cleared' }) + setSpawn(engine, mock(async (...args: unknown[]) => ({ type: 'success' as const, sessionId: args[12] as string }))) + + await (engine as unknown as { reconcileOpenCodeClears(): Promise }).reconcileOpenCodeClears() + + expect(store.messages.getAllMessages(source.id)).toEqual([]) + expect(store.messages.getAllMessages(reserved.sessionId).map((message) => ({ + localId: message.localId, + text: (message.content as { text: string }).text, + invokedAt: message.invokedAt + }))).toEqual([ + { localId: 'duplicate-local-id', text: 'authoritative retry', invokedAt: null }, + { localId: 'unique-immediate', text: 'unique immediate', invokedAt: null }, + { localId: 'unique-scheduled', text: 'unique scheduled', invokedAt: null } + ]) + expect(engine.getSessionByNamespace(source.id, 'default')?.metadata?.supersededBySessionId).toBe(reserved.sessionId) + } finally { engine.stop() } + }) + + it('refuses before spawning while the source is still active', async () => { + const { engine } = createEngine() + try { + const source = createClearSource(engine) + engine.handleSessionAlive({ sid: source.id, time: Date.now() }) + const spawnSession = mock(async () => ({ type: 'success' as const, sessionId: 'must-not-spawn' })) + setSpawn(engine, spawnSession) + + await expect(engine.clearOpenCodeSession(source.id, 'default')).resolves.toMatchObject({ + type: 'error', code: 'clear_unavailable' + }) + expect(spawnSession).not.toHaveBeenCalled() + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 88769216..e41a0176 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,7 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import { isKnownFlavor, type LocalResumeTarget, type ResumableSession } from '@hapi/protocol' +import { isKnownFlavor, type LocalResumeTarget, type ResumableSession, type SessionEndReason } 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' @@ -91,6 +91,14 @@ export type LocalHandoffResult = | { type: 'success' } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'already_local' | 'handoff_failed' } +export type ClearOpencodeSessionResult = + | { type: 'success'; sessionId: string } + | { + type: 'error' + message: string + code: 'session_not_found' | 'access_denied' | 'clear_unavailable' | 'spawn_failed' | 'replacement_link_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' } @@ -161,6 +169,8 @@ export class SyncEngine { private readonly piUnexpectedTempOriginalIds = new Map() /** Serialize scratchlist uploads per session so disk-byte caps cannot race. */ private readonly scratchlistUploadTails = new Map>() + /** Coalesce duplicate clear requests so retries cannot spawn two fresh sessions. */ + private readonly opencodeClearTails = new Map>() /** Serialize fork/rewind per session so concurrent native rollbacks cannot stack. */ private readonly historyActionsInFlight = new Set() @@ -409,6 +419,7 @@ export class SyncEngine { collaborationMode?: CodexCollaborationMode }): void { this.sessionCache.handleSessionAlive(payload) + this.messageService.replayImmediateQueuedMessages(payload.sid) this.triggerDedupIfNeeded(payload.sid) } @@ -430,8 +441,14 @@ export class SyncEngine { this.sessionCache.clearQueuedThinkingGrace(sessionId) } - handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void { + handleSessionEnd(payload: { sid: string; time: number; reason?: SessionEndReason }): void { const before = this.sessionCache.getSession(payload.sid) + if (before?.metadata?.opencodeClearOperation?.state === 'reserved' && payload.reason !== 'cleared') { + const operation = before.metadata.opencodeClearOperation + if (this.transitionClearOperation(payload.sid, before.namespace, operation, 'abort-needed')) { + this.abortOpenCodeClearSession(payload.sid, before.namespace, operation.replacementSessionId, 'abort-needed') + } + } const ownsPiAttempt = before?.metadata?.piResumeAttempt !== undefined const isPiAttemptChild = this.sessionCache.getSessions().some( (session) => session.metadata?.piResumeAttempt?.childSessionId === payload.sid @@ -764,6 +781,31 @@ async uploadScratchlistAttachment( // 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(), this.historyActionsInFlight) + void this.reconcileOpenCodeClears() + } + + private async reconcileOpenCodeClears(): Promise { + for (let session of this.sessionCache.getSessions()) { + const operation = session.metadata?.opencodeClearOperation + if (session.active || !operation) continue + if (operation.state === 'reserved') continue + if (operation.state === 'abort-needed') { + this.abortOpenCodeClearSession(session.id, session.namespace, operation.replacementSessionId, 'abort-needed') + continue + } + if (!['cleanup-confirmed', 'finalizing', 'pending', 'failed'].includes(operation.state)) continue + if (session.metadata?.lifecycleState !== 'archived' || session.metadata.archiveReason !== 'Cleared by /clear') { + const result = this.store.sessions.updateSessionMetadata(session.id, { + ...session.metadata, + lifecycleState: 'archived', + lifecycleStateSince: Date.now(), + archiveReason: 'Cleared by /clear' + }, session.metadataVersion, session.namespace, { touchUpdatedAt: false }) + if (result.result !== 'success') continue + session = this.sessionCache.refreshSession(session.id) ?? session + } + await this.clearOpenCodeSession(session.id, session.namespace).catch(() => {}) + } } private reloadAll(): void { @@ -817,9 +859,9 @@ async uploadScratchlistAttachment( if (this.historyActionsInFlight.has(sessionId)) { throw new Error('Conversation history action already in progress') } - await this.messageService.sendMessage(sessionId, payload) - this.sessionCache.markMessageQueued(sessionId) - this.sessionCache.recordSessionActivity(sessionId, Date.now()) + const actualSessionId = await this.messageService.sendMessage(sessionId, payload) + this.sessionCache.markMessageQueued(actualSessionId) + this.sessionCache.recordSessionActivity(actualSessionId, Date.now()) } async cancelQueuedMessage( @@ -1627,6 +1669,421 @@ async uploadScratchlistAttachment( ) } + /** + * Spawn a fresh OpenCode HAPI session from a source that its own CLI has + * already archived with the `cleared` lifecycle. Deliberately accepts only + * that post-cleanup state: a target must never become active while the + * source still owns an in-flight OpenCode turn or native compaction. + */ + async clearOpenCodeSession(sessionId: string, namespace: string): Promise { + const clearTailKey = `${namespace}:${sessionId}` + const existing = this.opencodeClearTails.get(clearTailKey) + if (existing) { + return await existing + } + + const task = this.clearOpenCodeSessionOnce(sessionId, namespace) + this.opencodeClearTails.set(clearTailKey, task) + try { + return await task + } finally { + if (this.opencodeClearTails.get(clearTailKey) === task) { + this.opencodeClearTails.delete(clearTailKey) + } + } + } + + reserveOpenCodeClearSession(sessionId: string, namespace: string): ClearOpencodeSessionResult { + 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 source = access.session + const metadata = source.metadata + if (!source.active || metadata?.flavor !== 'opencode' || metadata.startedBy !== 'runner' || !metadata.machineId || !metadata.path) { + return { type: 'error', message: 'Session is not an active runner-backed OpenCode session', code: 'clear_unavailable' } + } + const existing = metadata.opencodeClearOperation + const operation = !existing || existing.state === 'aborted' + ? { replacementSessionId: randomUUID(), state: 'reserved' as const, updatedAt: Date.now() } + : existing + if (operation !== existing && !this.persistClearOperation(sessionId, namespace, operation)) { + return { type: 'error', message: 'Could not persist the OpenCode clear reservation', code: 'replacement_link_failed' } + } + const replacementMetadata = { ...metadata } + delete replacementMetadata.opencodeSessionId + delete replacementMetadata.supersededBySessionId + delete replacementMetadata.opencodeClearOperation + delete replacementMetadata.lifecycleState + delete replacementMetadata.lifecycleStateSince + delete replacementMetadata.archivedBy + delete replacementMetadata.archiveReason + replacementMetadata.startedFromRunner = true + replacementMetadata.startedBy = 'runner' + this.getOrCreateSession(`opencode-clear-replacement:${operation.replacementSessionId}`, replacementMetadata, null, namespace, + source.model ?? undefined, source.effort ?? undefined, source.modelReasoningEffort ?? undefined, operation.replacementSessionId) + return { type: 'success', sessionId: operation.replacementSessionId } + } + + abortOpenCodeClearSession( + sessionId: string, + namespace: string, + replacementSessionId: string, + expectedState: 'reserved' | 'abort-needed' = 'reserved', + requireInactive: boolean = false + ): ClearOpencodeSessionResult { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok) return { type: 'error', message: 'Session not found', code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' } + const operation = access.session.metadata?.opencodeClearOperation + if (!operation) return { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } + if (operation.state === 'aborted') { + return replacementSessionId === operation.replacementSessionId + ? { type: 'success', sessionId } + : { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } + } + const required = { replacementSessionId, state: expectedState, requireInactive } + for (let attempt = 0; attempt < 3; attempt += 1) { + const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? this.sessionCache.refreshSession(sessionId) + if (!latest?.metadata) break + const current = latest.metadata.opencodeClearOperation + if (!current) break + if (current.replacementSessionId === required.replacementSessionId && current.state === 'aborted') { + return { type: 'success', sessionId } + } + if ((required.requireInactive && latest.active) + || current.replacementSessionId !== required.replacementSessionId + || current.state !== required.state) break + const result = this.store.abortOpenCodeClearOperation(sessionId, current.replacementSessionId, { + ...latest.metadata, + opencodeClearOperation: { ...current, state: 'aborted', updatedAt: Date.now(), error: undefined } + }, latest.metadataVersion, namespace, required) + if (result.result === 'success') { + this.sessionCache.refreshSession(sessionId) + return { type: 'success', sessionId } + } + if (result.result !== 'version-mismatch') break + this.sessionCache.refreshSession(sessionId) + } + return { type: 'error', message: 'Could not abort clear reservation', code: 'replacement_link_failed' } + } + + confirmOpenCodeClearCleanup(sessionId: string, namespace: string, replacementSessionId: string): ClearOpencodeSessionResult { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok) return { type: 'error', message: 'Session not found', code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' } + const operation = access.session.metadata?.opencodeClearOperation + if (!operation) return { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } + if (operation.state === 'cleanup-confirmed' && operation.replacementSessionId === replacementSessionId) { + return { type: 'success', sessionId: operation.replacementSessionId } + } + if (operation.state !== 'reserved') return { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } + if (operation.replacementSessionId !== replacementSessionId) return { type: 'error', message: 'Clear reservation not found', code: 'clear_unavailable' } + if (!this.transitionClearOperation(sessionId, namespace, operation, 'cleanup-confirmed')) { + return { type: 'error', message: 'Could not confirm native cleanup', code: 'replacement_link_failed' } + } + return { type: 'success', sessionId: operation.replacementSessionId } + } + + private async clearOpenCodeSessionOnce(sessionId: string, namespace: string): Promise { + 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 source = access.session + const metadata = source.metadata + if (source.active + || metadata?.flavor !== 'opencode' + || metadata.lifecycleState !== 'archived' + || metadata.archiveReason !== 'Cleared by /clear') { + return { + type: 'error', + message: 'Session must be an archived OpenCode clear source', + code: 'clear_unavailable' + } + } + + // A completed first request is the durable idempotency record used by + // reconnecting/retrying CLI processes after their source socket closed. + if (metadata.supersededBySessionId) { + return { type: 'success', sessionId: metadata.supersededBySessionId } + } + + if (!metadata.machineId || !metadata.path) { + return { + type: 'error', + message: 'OpenCode clear source is missing machine or directory metadata', + code: 'clear_unavailable' + } + } + // The source metadata is client-controlled, so validate the recorded + // machine through the namespace-scoped cache before any persistent or + // runner-facing action. + if (!this.getMachineByNamespace(metadata.machineId, namespace)) { + return { + type: 'error', + message: 'OpenCode clear source machine is unavailable in this namespace', + code: 'clear_unavailable' + } + } + + // Persist the replacement identity *before* asking a runner to create + // a process. A retry after a lost RPC response therefore uses this same + // HAPI id rather than accidentally spawning a second fresh session. + let operation = metadata.opencodeClearOperation + if (!operation) { + operation = { + replacementSessionId: randomUUID(), + state: 'pending' as const, + updatedAt: Date.now() + } + if (!this.persistClearOperation(sessionId, namespace, operation)) { + return { + type: 'error', + message: 'Could not persist the OpenCode clear replacement operation', + code: 'replacement_link_failed' + } + } + } else if (operation.state === 'failed') { + operation = { ...operation, state: 'pending', updatedAt: Date.now(), error: undefined } + if (!this.persistClearOperation(sessionId, namespace, operation)) { + return { + type: 'error', + message: 'Could not resume the OpenCode clear replacement operation', + code: 'replacement_link_failed' + } + } + } + + if (operation.state === 'reserved') { + return { type: 'error', message: 'Native OpenCode cleanup is not confirmed', code: 'clear_unavailable' } + } + if (operation.state === 'cleanup-confirmed') { + operation = { ...operation, state: 'finalizing', updatedAt: Date.now() } + if (!this.persistClearOperation(sessionId, namespace, operation)) { + return { type: 'error', message: 'Could not finalize the OpenCode clear reservation', code: 'replacement_link_failed' } + } + } + + const replacementMetadata = { ...metadata } + delete replacementMetadata.opencodeSessionId + delete replacementMetadata.supersededBySessionId + delete replacementMetadata.opencodeClearOperation + delete replacementMetadata.lifecycleState + delete replacementMetadata.lifecycleStateSince + delete replacementMetadata.archivedBy + delete replacementMetadata.archiveReason + replacementMetadata.startedFromRunner = true + replacementMetadata.startedBy = 'runner' + + // bootstrapExistingSession requires an existing row. The stable id lets + // a runner coalesce retries only while its spawned child remains alive; + // replacement.active is the durable cross-runner reconciliation signal. + const replacement = this.getOrCreateSession( + `opencode-clear-replacement:${operation.replacementSessionId}`, + replacementMetadata, + null, + namespace, + source.model ?? undefined, + source.effort ?? undefined, + source.modelReasoningEffort ?? undefined, + operation.replacementSessionId + ) + + // A previous request can have spawned the target but lost the source + // link acknowledgement. Do not ask the runner again in that case. + if (replacement.active) { + return this.finishOpenCodeClear(sessionId, namespace, operation.replacementSessionId, operation) + } + + // Do not supply a native OpenCode resume id. existingSessionId is only + // the preallocated HAPI row; OpenCode starts a brand-new native thread. + const spawned = await this.spawnSession( + metadata.machineId, + metadata.path, + 'opencode', + source.model ?? undefined, + source.modelReasoningEffort ?? undefined, + false, + undefined, + undefined, + undefined, + source.effort ?? undefined, + source.permissionMode ?? metadata.preferredPermissionMode, + source.serviceTier ?? undefined, + operation.replacementSessionId, + source.collaborationMode + ) + if (spawned.type === 'error') { + this.persistClearOperationState(sessionId, namespace, operation, spawned.message) + return { type: 'error', message: spawned.message, code: 'spawn_failed' } + } + if (spawned.sessionId !== operation.replacementSessionId) { + const message = 'Runner returned an unexpected OpenCode clear replacement id' + this.persistClearOperationState(sessionId, namespace, operation, message) + return { type: 'error', message, code: 'spawn_failed' } + } + + return this.finishOpenCodeClear(sessionId, namespace, operation.replacementSessionId, operation) + } + + private finishOpenCodeClear( + sessionId: string, + namespace: string, + replacementSessionId: string, + operation: NonNullable['opencodeClearOperation'] + ): ClearOpencodeSessionResult { + if (!operation) { + return { + type: 'error', + message: 'OpenCode clear operation was not persisted', + code: 'replacement_link_failed' + } + } + try { + const moved = this.store.messages.moveUninvokedMessages(sessionId, replacementSessionId) + if (moved > 0) { + this.eventPublisher.emit({ type: 'messages-invalidated', sessionId }) + this.eventPublisher.emit({ type: 'messages-invalidated', sessionId: replacementSessionId }) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Could not move scheduled prompts to the fresh OpenCode session' + this.persistClearOperationState(sessionId, namespace, operation, message) + return { type: 'error', message, code: 'replacement_link_failed' } + } + if (!this.persistClearReplacement(sessionId, namespace, replacementSessionId, operation)) { + const message = 'Fresh OpenCode session started but the archived source could not be linked' + this.persistClearOperationState(sessionId, namespace, operation, message) + return { + type: 'error', + message, + code: 'replacement_link_failed' + } + } + this.messageService.releaseDeliverableQueuedMessages(replacementSessionId) + return { type: 'success', sessionId: replacementSessionId } + } + + private transitionClearOperation( + sessionId: string, + namespace: string, + expected: NonNullable['opencodeClearOperation']>, + state: 'abort-needed' | 'cleanup-confirmed' + ): boolean { + for (let attempt = 0; attempt < 3; attempt += 1) { + const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!latest?.metadata) return false + const current = latest.metadata.opencodeClearOperation + if (current?.replacementSessionId === expected.replacementSessionId && current.state === state) return true + if (current?.replacementSessionId !== expected.replacementSessionId || current.state !== expected.state) return false + const result = this.store.transitionOpenCodeClearOperation(sessionId, { + ...latest.metadata, + opencodeClearOperation: { ...expected, state, updatedAt: Date.now(), error: undefined } + }, latest.metadataVersion, namespace, { + replacementSessionId: expected.replacementSessionId, + state: expected.state + }) + if (result.result === 'success') { + this.sessionCache.refreshSession(sessionId) + return true + } + if (result.result !== 'version-mismatch') return false + this.sessionCache.refreshSession(sessionId) + } + return false + } + + private persistClearOperation( + sessionId: string, + namespace: string, + operation: NonNullable['opencodeClearOperation'] + ): boolean { + if (!operation) return false + for (let attempt = 0; attempt < 3; attempt += 1) { + const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!latest?.metadata) return false + if (latest.metadata.supersededBySessionId) { + return latest.metadata.supersededBySessionId === operation.replacementSessionId + } + const existing = latest.metadata.opencodeClearOperation + if (existing && existing.replacementSessionId !== operation.replacementSessionId && existing.state !== 'aborted') return false + const result = this.store.sessions.updateSessionMetadata( + sessionId, + { ...latest.metadata, opencodeClearOperation: operation }, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.sessionCache.refreshSession(sessionId) + return true + } + if (result.result !== 'version-mismatch') return false + this.sessionCache.refreshSession(sessionId) + } + return false + } + + private persistClearOperationState( + sessionId: string, + namespace: string, + operation: NonNullable['opencodeClearOperation'], + error: string + ): void { + if (!operation) return + this.persistClearOperation(sessionId, namespace, { + ...operation, + state: 'failed', + updatedAt: Date.now(), + error: error.slice(0, 500) + }) + } + + private persistClearReplacement( + sessionId: string, + namespace: string, + replacementSessionId: string, + operation: NonNullable['opencodeClearOperation'] + ): boolean { + if (!operation) return false + for (let attempt = 0; attempt < 3; attempt += 1) { + const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) + ?? this.sessionCache.refreshSession(sessionId) + if (!latest?.metadata) return false + if (latest.metadata.supersededBySessionId) { + return latest.metadata.supersededBySessionId === replacementSessionId + } + const result = this.store.sessions.updateSessionMetadata( + sessionId, + { + ...latest.metadata, + supersededBySessionId: replacementSessionId, + opencodeClearOperation: { + ...operation, + state: 'completed', + updatedAt: Date.now(), + error: undefined + } + }, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.sessionCache.refreshSession(sessionId) + return true + } + if (result.result !== 'version-mismatch') return false + this.sessionCache.refreshSession(sessionId) + } + return false + } + private resolveFlavor(session: Session): AgentFlavor { const flavor = session.metadata?.flavor return isKnownFlavor(flavor) ? flavor : 'claude' @@ -1999,6 +2456,29 @@ async uploadScratchlistAttachment( return this.store.messages.getFirstMessages(sessionId, 1).length === 0 } + private isOpenCodeClearSource(session: Session): boolean { + const metadata = session.metadata + return metadata?.flavor === 'opencode' + && (metadata.archiveReason === 'Cleared by /clear' + || (metadata.opencodeClearOperation !== undefined && metadata.opencodeClearOperation.state !== 'aborted') + || metadata.supersededBySessionId !== undefined) + } + + private async recoverInactiveReservedClear(session: Session, namespace: string): Promise { + const operation = session.metadata?.opencodeClearOperation + const machineId = session.metadata?.machineId + if (session.active || operation?.state !== 'reserved' || !machineId) return false + try { + const status = await this.rpcGateway.stopRunnerSession(machineId, session.id) + if (status === 'still_alive') return false + return this.abortOpenCodeClearSession( + session.id, namespace, operation.replacementSessionId, 'reserved', true + ).type === 'success' + } catch { + return false + } + } + async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { @@ -2009,7 +2489,17 @@ async uploadScratchlistAttachment( } } - const initialSession = access.session + let initialSession = access.session + if (await this.recoverInactiveReservedClear(initialSession, namespace)) { + initialSession = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? initialSession + } + if (this.isOpenCodeClearSource(initialSession)) { + return { + type: 'error', + message: 'This OpenCode session was replaced by /clear', + code: 'resume_unavailable' + } + } if (initialSession.active) { return { type: 'success', sessionId: access.sessionId } } @@ -2275,9 +2765,20 @@ async uploadScratchlistAttachment( } } - const session = access.session + let session = access.session + if (await this.recoverInactiveReservedClear(session, namespace)) { + session = this.sessionCache.getSessionByNamespace(sessionId, namespace) ?? session + } const metadata = session.metadata + if (this.isOpenCodeClearSource(session)) { + return { + type: 'error', + message: 'This OpenCode session was replaced by /clear', + code: 'resume_unavailable' + } + } + if (metadata?.flavor === 'pi' && this.isPiResumeBlocked(access.sessionId)) { if (session.active) { return { type: 'error', message: 'Pi resume is already in progress', code: 'resume_failed' } diff --git a/hub/src/web/routes/cli.test.ts b/hub/src/web/routes/cli.test.ts index 637f95bb..f9033643 100644 --- a/hub/src/web/routes/cli.test.ts +++ b/hub/src/web/routes/cli.test.ts @@ -116,6 +116,79 @@ describe('cli resume routes', () => { }) }) +describe('cli OpenCode clear route', () => { + it.each(['confirm-cleanup', 'abort'] as const)('maps transient %s persistence failure to retryable 500', async (action) => { + const failure = mock(() => ({ + type: 'error' as const, + code: 'replacement_link_failed' as const, + message: 'metadata write failed' + })) + const app = createApp(action === 'confirm-cleanup' + ? { confirmOpenCodeClearCleanup: failure } as never + : { abortOpenCodeClearSession: failure } as never) + const response = await app.request(`/cli/sessions/source-session/clear-opencode/${action}`, { + method: 'POST', headers: authHeaders(), body: JSON.stringify({ replacementSessionId: 'reserved-session' }) + }) + expect(response.status).toBe(500) + expect(await response.json()).toMatchObject({ code: 'replacement_link_failed' }) + expect(failure).toHaveBeenCalledWith('source-session', 'default', 'reserved-session') + }) + + it.each(['confirm-cleanup', 'abort'] as const)('requires reservation identity for %s', async (action) => { + const app = createApp({} as never) + const response = await app.request(`/cli/sessions/source-session/clear-opencode/${action}`, { + method: 'POST', headers: authHeaders(), body: '{}' + }) + expect(response.status).toBe(400) + }) + + it('durably reserves through the namespace-scoped engine route', async () => { + const reserveOpenCodeClearSession = mock(() => ({ type: 'success' as const, sessionId: 'reserved-session' })) + const app = createApp({ reserveOpenCodeClearSession } as never) + const response = await app.request('/cli/sessions/source-session/clear-opencode/reserve', { + method: 'POST', headers: authHeaders() + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, sessionId: 'reserved-session' }) + expect(reserveOpenCodeClearSession).toHaveBeenCalledWith('source-session', 'default') + }) + + it('orchestrates a fresh session only through the namespace-scoped engine route', async () => { + const clearOpenCodeSession = mock(async () => ({ type: 'success' as const, sessionId: 'fresh-opencode-session' })) + const app = createApp({ clearOpenCodeSession } as never) + + const response = await app.request('/cli/sessions/source-session/clear-opencode', { + method: 'POST', + headers: authHeaders() + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, sessionId: 'fresh-opencode-session' }) + expect(clearOpenCodeSession).toHaveBeenCalledWith('source-session', 'default') + }) + + it('does not turn an active or wrong-flavor source into a new session', async () => { + const app = createApp({ + clearOpenCodeSession: async () => ({ + type: 'error' as const, + code: 'clear_unavailable' as const, + message: 'Session must be an archived OpenCode clear source' + }) + } as never) + + const response = await app.request('/cli/sessions/source-session/clear-opencode', { + method: 'POST', + headers: authHeaders() + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Session must be an archived OpenCode clear source', + code: 'clear_unavailable' + }) + }) +}) + describe('cli lazy session creation', () => { const sessionId = '11111111-1111-4111-8111-111111111111' diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index fc0abe43..c31696b9 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { CreateOrLoadMachineRequestSchema, CreateOrLoadSessionRequestSchema, + ClearOpencodeSessionCallbackRequestSchema, CursorMigrateToAcpRequestSchema, PROTOCOL_VERSION } from '@hapi/protocol' @@ -56,6 +57,13 @@ function resolveMachineForNamespace( return { ok: false, status: 404, error: 'Machine not found' } } +function clearErrorStatus(code: string): 403 | 404 | 409 | 500 { + return code === 'access_denied' ? 403 + : code === 'session_not_found' ? 404 + : code === 'clear_unavailable' ? 409 + : 500 +} + export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { const app = new Hono() @@ -178,6 +186,54 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const engine = getSyncEngine() + if (!engine) { + return c.json({ error: 'Not ready' }, 503) + } + + const result = await engine.clearOpenCodeSession(c.req.param('id'), c.get('namespace')) + if (result.type === 'error') { + const status = result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : result.code === 'clear_unavailable' ? 409 + : 500 + return c.json({ error: result.message, code: result.code }, status) + } + return c.json({ ok: true, sessionId: result.sessionId }) + }) + + app.post('/sessions/:id/clear-opencode/reserve', (c) => { + const engine = getSyncEngine() + if (!engine) return c.json({ error: 'Not ready' }, 503) + const result = engine.reserveOpenCodeClearSession(c.req.param('id'), c.get('namespace')) + if (result.type === 'error') { + const status = result.code === 'access_denied' ? 403 : result.code === 'session_not_found' ? 404 : result.code === 'clear_unavailable' ? 409 : 500 + return c.json({ error: result.message, code: result.code }, status) + } + return c.json({ ok: true, sessionId: result.sessionId }) + }) + + app.post('/sessions/:id/clear-opencode/abort', async (c) => { + const engine = getSyncEngine() + if (!engine) return c.json({ error: 'Not ready' }, 503) + const parsed = ClearOpencodeSessionCallbackRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) return c.json({ error: 'Invalid clear callback request' }, 400) + const result = engine.abortOpenCodeClearSession(c.req.param('id'), c.get('namespace'), parsed.data.replacementSessionId) + if (result.type === 'error') return c.json({ error: result.message, code: result.code }, clearErrorStatus(result.code)) + return c.json({ ok: true, sessionId: result.sessionId }) + }) + + app.post('/sessions/:id/clear-opencode/confirm-cleanup', async (c) => { + const engine = getSyncEngine() + if (!engine) return c.json({ error: 'Not ready' }, 503) + const parsed = ClearOpencodeSessionCallbackRequestSchema.safeParse(await c.req.json().catch(() => null)) + if (!parsed.success) return c.json({ error: 'Invalid clear callback request' }, 400) + const result = engine.confirmOpenCodeClearCleanup(c.req.param('id'), c.get('namespace'), parsed.data.replacementSessionId) + if (result.type === 'error') return c.json({ error: result.message, code: result.code }, clearErrorStatus(result.code)) + return c.json({ ok: true, sessionId: result.sessionId }) + }) + app.get('/sessions/:id', (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/shared/src/apiTypes.test.ts b/shared/src/apiTypes.test.ts index 88c1dd68..77cb42ac 100644 --- a/shared/src/apiTypes.test.ts +++ b/shared/src/apiTypes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ListCodexSessionsRpcResponseSchema, MessagesQuerySchema } from './apiTypes' +import { ClearOpencodeSessionCallbackRequestSchema, ClearOpencodeSessionResponseSchema, ListCodexSessionsRpcResponseSchema, MessagesQuerySchema } from './apiTypes' describe('ListCodexSessionsRpcResponseSchema', () => { it('preserves Codex session messages when parsing runner RPC responses', () => { @@ -30,6 +30,24 @@ describe('ListCodexSessionsRpcResponseSchema', () => { }) }) +describe('ClearOpencodeSessionResponseSchema', () => { + it('requires the new HAPI session identity', () => { + expect(ClearOpencodeSessionResponseSchema.parse({ ok: true, sessionId: 'fresh-session' })).toEqual({ + ok: true, + sessionId: 'fresh-session' + }) + }) +}) + +describe('ClearOpencodeSessionCallbackRequestSchema', () => { + it('requires the reservation identity', () => { + expect(ClearOpencodeSessionCallbackRequestSchema.parse({ replacementSessionId: 'fresh-session' })).toEqual({ + replacementSessionId: 'fresh-session' + }) + expect(ClearOpencodeSessionCallbackRequestSchema.safeParse({}).success).toBe(false) + }) +}) + describe('MessagesQuerySchema', () => { it('parses a forward cursor with a bounded snapshot and epoch', () => { expect(MessagesQuerySchema.parse({ diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index a80d863f..e9dfe601 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -63,6 +63,17 @@ export type CreateMachineResponse = z.infer export const GetSessionResponseSchema = CreateSessionResponseSchema export type GetSessionResponse = CreateSessionResponse +export const ClearOpencodeSessionResponseSchema = z.object({ + ok: z.literal(true), + sessionId: z.string() +}) +export type ClearOpencodeSessionResponse = z.infer + +export const ClearOpencodeSessionCallbackRequestSchema = z.object({ + replacementSessionId: z.string() +}) +export type ClearOpencodeSessionCallbackRequest = z.infer + export type AuthResponse = { token: string user: { diff --git a/shared/src/resume.test.ts b/shared/src/resume.test.ts index 8835e9fb..d653fd26 100644 --- a/shared/src/resume.test.ts +++ b/shared/src/resume.test.ts @@ -45,6 +45,7 @@ describe('resume schemas', () => { it('accepts handoff as a session end reason', () => { expect(SessionEndReasonSchema.parse('handoff')).toBe('handoff') + expect(SessionEndReasonSchema.parse('cleared')).toBe('cleared') }) it('accepts handoff in session-ended sync events', () => { @@ -57,6 +58,14 @@ describe('resume schemas', () => { expect(parsed.success).toBe(true) }) + it('accepts cleared in session-ended sync events', () => { + expect(SyncEventSchema.parse({ + type: 'session-ended', + sessionId: 'session-1', + reason: 'cleared' + })).toMatchObject({ reason: 'cleared' }) + }) + it('requires invokedAt in messages-consumed sync events', () => { expect(SyncEventSchema.safeParse({ type: 'messages-consumed', diff --git a/shared/src/schemas.clear.test.ts b/shared/src/schemas.clear.test.ts new file mode 100644 index 00000000..e84e5673 --- /dev/null +++ b/shared/src/schemas.clear.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { MetadataSchema, SessionEndReasonSchema } from './schemas' + +describe('fresh-session clear schema contract', () => { + it('preserves the archived session replacement link', () => { + expect(MetadataSchema.parse({ + path: '/tmp/project', + host: 'host', + supersededBySessionId: 'new-session-id' + })).toMatchObject({ supersededBySessionId: 'new-session-id' }) + }) + + it('accepts cleared as an additive session-end reason', () => { + expect(SessionEndReasonSchema.parse('cleared')).toBe('cleared') + }) +}) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 1483c4cf..80c6e87f 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -3,7 +3,7 @@ import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) -export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error', 'handoff']) +export const SessionEndReasonSchema = z.enum(['completed', 'terminated', 'error', 'handoff', 'cleared']) export type SessionEndReason = z.infer const MetadataSummarySchema = z.object({ @@ -17,6 +17,16 @@ const ConversationHistoryCapabilitiesSchema = z.object({ rewindToMessage: z.boolean().optional() }) +// Written to an archived OpenCode source before the runner is asked to spawn. +// The stable replacement id makes retrying a lost RPC acknowledgement safe. +export const OpencodeClearOperationSchema = z.object({ + replacementSessionId: z.string(), + state: z.enum(['reserved', 'abort-needed', 'cleanup-confirmed', 'finalizing', 'pending', 'failed', 'completed', 'aborted']), + updatedAt: z.number(), + error: z.string().optional() +}) +export type OpencodeClearOperation = z.infer + const SessionCapabilitiesSchema = z.object({ terminal: z.boolean().optional(), conversationHistory: ConversationHistoryCapabilitiesSchema.optional() @@ -91,6 +101,11 @@ export const MetadataSchema = z.object({ lifecycleStateSince: z.number().optional(), archivedBy: z.string().optional(), archiveReason: z.string().optional(), + // Set only after a completed fresh-session clear. The source row remains + // archived; web clients use this durable link to follow the replacement. + supersededBySessionId: z.string().optional(), + // Durable in-progress state for runner-backed OpenCode /clear. + opencodeClearOperation: OpencodeClearOperationSchema.optional(), preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index f2cf4fcb..0354dfee 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { SessionSummary } from '@/types/api' import type { Session } from '@/types/api' -import { isGlobalScopedMessageStreamEvent, isRenderIrrelevantPatch, isRenderIrrelevantSessionPatch } from './useSSE' +import { isGlobalScopedMessageStreamEvent, isRenderIrrelevantPatch, isRenderIrrelevantSessionPatch, shouldInvalidateSessionListForEvent } from './useSSE' function makeSummary(overrides: Partial = {}): SessionSummary { return { @@ -25,6 +25,11 @@ function makeSummary(overrides: Partial = {}): SessionSummary { } describe('useSSE scope handling', () => { + it('invalidates the global session list when message ownership changes', () => { + expect(shouldInvalidateSessionListForEvent('global', 'messages-invalidated')).toBe(true) + expect(shouldInvalidateSessionListForEvent('full', 'messages-invalidated')).toBe(false) + }) + it('treats message stream events as global-scoped skips', () => { expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true) expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true) diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 82e662ae..b2087060 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -34,6 +34,10 @@ export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: Syn return scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(eventType) } +export function shouldInvalidateSessionListForEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean { + return scope === 'global' && eventType === 'messages-invalidated' +} + type VisibilityState = 'visible' | 'hidden' type ToastEvent = Extract @@ -528,6 +532,10 @@ export function useSSE(options: { return } + if (shouldInvalidateSessionListForEvent(scope, event.type)) { + queueSessionListInvalidation() + } + if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) { if (event.type === 'message-received' && event.message.scheduledAt != null) { queueSessionListInvalidation() diff --git a/web/src/router.tsx b/web/src/router.tsx index 84cc1d47..9526e4d8 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -46,6 +46,7 @@ import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession } from '@/lib/codexImportedSessions' +import { getSupersedingSessionId, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession' import { migrateSuppressedSendError } from '@/lib/suppressed-send-error' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' @@ -764,6 +765,29 @@ function SessionDetailRoute() { useSessionBrowserTitle(session) const basePath = `/sessions/${sessionId}` const isChat = pathname === basePath || pathname === `${basePath}/` + const supersedingSessionId = getSupersedingSessionId(sessionId, session?.metadata) + const observedSessionRef = useRef<{ + sessionId: string + supersedingSessionId: string | null + } | null>(null) + + useEffect(() => { + if (!session) { + return + } + const shouldFollow = shouldFollowSupersedingSession( + observedSessionRef.current, + sessionId, + session.metadata + ) + observedSessionRef.current = { sessionId, supersedingSessionId } + if (!shouldFollow || !supersedingSessionId) return + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: supersedingSessionId }, + replace: true + }) + }, [navigate, session, sessionId, supersedingSessionId]) useEffect(() => { if (!sessionNotFound) { diff --git a/web/src/routes/sessions/followSupersedingSession.test.ts b/web/src/routes/sessions/followSupersedingSession.test.ts new file mode 100644 index 00000000..3d4845ac --- /dev/null +++ b/web/src/routes/sessions/followSupersedingSession.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { getSupersedingSessionId, shouldFollowSupersedingSession } from './followSupersedingSession' + +describe('getSupersedingSessionId', () => { + it('follows a different persisted replacement identity', () => { + expect(getSupersedingSessionId('source', { supersededBySessionId: 'fresh' })).toBe('fresh') + }) + + it('does not self-navigate for missing, blank, or identical values', () => { + expect(getSupersedingSessionId('source', undefined)).toBeNull() + expect(getSupersedingSessionId('source', { supersededBySessionId: ' ' })).toBeNull() + expect(getSupersedingSessionId('source', { supersededBySessionId: 'source' })).toBeNull() + }) +}) + +describe('shouldFollowSupersedingSession', () => { + it('follows a replacement only when the open view witnessed the source session before supersession', () => { + expect(shouldFollowSupersedingSession({ + sessionId: 'source', + supersedingSessionId: null + }, 'source', { + supersededBySessionId: 'fresh' + })).toBe(true) + }) + + it('keeps an archived conversation accessible when opened after it was already superseded', () => { + expect(shouldFollowSupersedingSession(null, 'source', { + supersededBySessionId: 'fresh' + })).toBe(false) + expect(shouldFollowSupersedingSession({ + sessionId: 'other-session', + supersedingSessionId: null + }, 'source', { + supersededBySessionId: 'fresh' + })).toBe(false) + expect(shouldFollowSupersedingSession({ + sessionId: 'source', + supersedingSessionId: 'fresh' + }, 'source', { + supersededBySessionId: 'fresh' + })).toBe(false) + }) +}) diff --git a/web/src/routes/sessions/followSupersedingSession.ts b/web/src/routes/sessions/followSupersedingSession.ts new file mode 100644 index 00000000..ee0365d5 --- /dev/null +++ b/web/src/routes/sessions/followSupersedingSession.ts @@ -0,0 +1,20 @@ +export function getSupersedingSessionId( + currentSessionId: string, + metadata: { supersededBySessionId?: string } | null | undefined +): string | null { + const replacement = metadata?.supersededBySessionId?.trim() + if (!replacement || replacement === currentSessionId) { + return null + } + return replacement +} + +export function shouldFollowSupersedingSession( + previous: { sessionId: string; supersedingSessionId: string | null } | null, + currentSessionId: string, + metadata: { supersededBySessionId?: string } | null | undefined +): boolean { + return previous?.sessionId === currentSessionId + && previous.supersedingSessionId === null + && getSupersedingSessionId(currentSessionId, metadata) !== null +}