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

* test(opencode): specify fresh-session clear

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

* fix(opencode): release clear latch on cancel

* fix(opencode): retry transient clear handoffs

* fix(opencode): confirm clear archive delivery

* fix(web): preserve superseded session access

* fix(clear): invalidate transferred schedules

* fix(runner): restore live spawn dedupe

* fix(clear): preserve latched scheduled prompts

* fix(runner): quarantine unverified children

* fix(clear): retain handoff retry ownership

* fix(runner): release recovered spawn dedupe

* fix(clear): retain archive retry ownership

* fix(clear): settle rejected immediate prompts

* fix(clear): block reopening replaced sources

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

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

* fix(clear): finalize only after native cleanup

* fix(clear): abort failed native handoffs

* fix(clear): gate recovery on cleanup proof

* fix(clear): retry metadata persistence failures

* fix(clear): preserve handoff ownership through teardown

* fix(clear): abort incomplete cleanup reservations

* fix(clear): require explicit exit before abort

* fix(clear): verify owner exit before recovery

* fix(clear): guard recovery handoff races

* fix(clear): serialize cleanup callbacks

* fix(clear): make callback retries idempotent

* fix(clear): bind callbacks to reservations

* fix(clear): recover pending spawns

* fix(clear): deduplicate held prompts

* fix(clear): validate redirect ownership

* fix(clear): replay prompts in FIFO order

* fix(clear): gate replacement delivery
This commit is contained in:
Junmo Kim
2026-08-03 18:06:39 +08:00
committed by GitHub
parent 1b8cc334ea
commit f44c9ff3e6
41 changed files with 3209 additions and 73 deletions
+18
View File
@@ -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);
});
});
});
+40
View File
@@ -18,6 +18,7 @@ export type RunnerLifecycle = {
hasExplicitSessionEndReason: () => boolean
markCrash: (error: unknown) => void
cleanup: () => Promise<void>
cleanupConfirmed: (options?: { timeoutMs?: number }) => Promise<void>
cleanupAndExit: (codeOverride?: number) => Promise<void>
registerProcessHandlers: () => void
}
@@ -49,6 +50,8 @@ export function createRunnerLifecycle(options: RunnerLifecycleOptions): RunnerLi
let sessionEndReasonExplicit = false
let cleanupStarted = false
let cleanupPromise: Promise<void> | 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
}
+36
View File
@@ -106,6 +106,42 @@ describe('API extra headers integration', () => {
})
})
it('uses the CLI REST bridge to request a fresh OpenCode session after source cleanup', async () => {
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
const client = await ApiClient.create()
await expect(client.clearOpenCodeSession('source-session')).resolves.toBe('fresh-session')
expect(axiosPostMock).toHaveBeenCalledWith(
'https://hapi.example.com/cli/sessions/source-session/clear-opencode',
{},
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer cli-token' })
})
)
})
it('uses the CLI REST bridge to reserve before source cleanup', async () => {
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
const client = await ApiClient.create()
await expect(client.reserveOpenCodeClearSession('source-session')).resolves.toBe('fresh-session')
expect(axiosPostMock).toHaveBeenCalledWith(
'https://hapi.example.com/cli/sessions/source-session/clear-opencode/reserve', {},
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer cli-token' }) })
)
})
it.each(['confirmOpenCodeClearCleanup', 'abortOpenCodeClearSession'] as const)('sends reservation identity with %s', async (method) => {
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
const client = await ApiClient.create()
await expect(client[method]('source-session', 'fresh-session')).resolves.toBe('fresh-session')
expect(axiosPostMock).toHaveBeenCalledWith(
expect.stringContaining('/clear-opencode/'),
{ replacementSessionId: 'fresh-session' },
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer cli-token' }) })
)
})
it('adds extra headers to socket transport options', () => {
configuration._setExtraHeaders({
Cookie: 'CF_Authorization=token'
+50 -1
View File
@@ -1,8 +1,9 @@
import axios from 'axios'
import type { AgentState, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
import type { AgentState, ClearOpencodeSessionCallbackRequest, ClearOpencodeSessionResponse, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
import {
AgentStateSchema,
ClearOpencodeSessionResponseSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
GetSessionResponseSchema,
@@ -272,6 +273,54 @@ export class ApiClient {
}
}
async clearOpenCodeSession(sessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode`,
{},
{
headers: this.authHeaders(),
timeout: 60_000
}
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) {
throw apiValidationError('Invalid /cli/sessions/:id/clear-opencode response', response)
}
return parsed.data.sessionId
}
async reserveOpenCodeClearSession(sessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/reserve`, {},
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear reservation response', response)
return parsed.data.sessionId
}
async abortOpenCodeClearSession(sessionId: string, replacementSessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/abort`,
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear abort response', response)
return parsed.data.sessionId
}
async confirmOpenCodeClearCleanup(sessionId: string, replacementSessionId: string): Promise<string> {
const response = await axios.post<ClearOpencodeSessionResponse>(
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/confirm-cleanup`,
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
{ headers: this.authHeaders(), timeout: 60_000 }
)
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
if (!parsed.success) throw apiValidationError('Invalid clear cleanup confirmation response', response)
return parsed.data.sessionId
}
sessionSyncClient(session: Session, options?: ApiSessionClientOptions): ApiSessionClient {
return new ApiSessionClient(this.token, session, options)
}
+15
View File
@@ -372,6 +372,21 @@ describe('ApiSessionClient lazy materialization', () => {
expect(socket.emitted.some((entry) => entry.event === 'session-end')).toBe(true)
client.close()
})
it('reports an unconfirmed final flush when the socket cannot reconnect before the deadline', async () => {
socketHarness.sockets.length = 0
const client = new ApiSessionClient('token', createSession({ namespace: 'default' }))
const socket = socketHarness.sockets[0]
if (!socket) throw new Error('expected socket')
socket.connected = false
socket.connectImmediately = false
client.sendSessionDeath('cleared')
await expect(client.flush({ timeoutMs: 20 })).resolves.toBe(false)
expect(socket.connectCalls).toBeGreaterThan(0)
client.close()
})
})
describe('ApiSessionClient incoming user messages', () => {
+16 -9
View File
@@ -1098,7 +1098,7 @@ export class ApiSessionClient extends EventEmitter {
return await this.drainLock(this.metadataLock, timeoutMs)
}
async flush(options?: { timeoutMs?: number }): Promise<void> {
async flush(options?: { timeoutMs?: number }): Promise<boolean> {
const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000)
const remainingMs = () => Math.max(0, deadlineMs - Date.now())
@@ -1106,37 +1106,44 @@ export class ApiSessionClient extends EventEmitter {
if (materializationTask) {
this.materializationDrainRequested = true
this.materializationRetryAbortController?.abort()
await this.waitForPromise(materializationTask, remainingMs())
if (!await this.waitForPromise(materializationTask, remainingMs())) {
return false
}
}
if (this.state !== 'active') {
return
return false
}
if (!this.socket.connected) {
const connected = await this.waitForConnected(remainingMs())
if (!connected) {
return
return false
}
}
await this.drainLock(this.metadataLock, remainingMs())
await this.drainLock(this.agentStateLock, remainingMs())
if (!await this.drainLock(this.metadataLock, remainingMs())) {
return false
}
if (!await this.drainLock(this.agentStateLock, remainingMs())) {
return false
}
if (remainingMs() === 0) {
return
return false
}
const pingTimeoutMs = remainingMs()
if (pingTimeoutMs === 0) {
return
return false
}
try {
await this.socket.timeout(pingTimeoutMs).emitWithAck('ping')
this.awaitingMaterializedConnection = false
return true
} catch {
// best effort
return false
}
}
+8
View File
@@ -6,6 +6,8 @@ import {
RunnerStateSchema
} from '@hapi/protocol/schemas'
import {
ClearOpencodeSessionResponseSchema,
ClearOpencodeSessionCallbackRequestSchema,
CliMessagesResponseSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
@@ -13,6 +15,8 @@ import {
LocalHandoffResponseSchema,
LocalResumeTargetResponseSchema,
ResumableSessionsResponseSchema,
type ClearOpencodeSessionResponse,
type ClearOpencodeSessionCallbackRequest,
type CliMessagesResponse,
type CreateMachineResponse,
type CreateSessionResponse,
@@ -45,6 +49,8 @@ export type SessionEffort = string | null
export { AgentStateSchema, AttachmentMetadataSchema, MachineMetadataSchema, MetadataSchema, RunnerStateSchema }
export {
ClearOpencodeSessionCallbackRequestSchema,
ClearOpencodeSessionResponseSchema,
CliMessagesResponseSchema,
CreateMachineResponseSchema,
CreateSessionResponseSchema,
@@ -55,6 +61,8 @@ export {
}
export type {
ClearOpencodeSessionCallbackRequest,
ClearOpencodeSessionResponse,
CliMessagesResponse,
CreateMachineResponse,
CreateSessionResponse,
@@ -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',
+6
View File
@@ -44,6 +44,12 @@ export function parseRemoteAgentCommandOptions<TPermissionMode extends Permissio
hasExplicitPermissionMode = true
} else if (arg === '--yolo' && !hasExplicitPermissionMode) {
options.permissionMode = 'yolo' as TPermissionMode
} else if (arg === '--existing-session-id') {
const sessionId = args[++i]
if (!sessionId || sessionId.startsWith('-')) {
throw new Error('Missing --existing-session-id value')
}
options.existingSessionId = sessionId
} else if (arg === '--resume') {
const sessionId = args[++i]
if (!sessionId) {
+7 -1
View File
@@ -25,6 +25,9 @@ interface OpencodeLoopOptions {
onSessionReady?: (session: OpencodeSession) => void;
onReasoningEffortRollback?: (effort: string | null) => void;
onCompactAvailabilityChange?: (available: boolean) => void;
onClearRequested?: () => Promise<void>;
onClearCleanupComplete?: () => Promise<void>;
onClearCleanupFailed?: () => Promise<void>;
// 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<void> {
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
});
+131 -6
View File
@@ -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<void>),
setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise<void>),
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<string>)
newSessionImpl: null as null | (() => Promise<string>),
disconnectImpl: null as null | (() => Promise<void>),
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<void> {}
async cancelAll(): Promise<void> { 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<void>((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<void>((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<typeof vi.fn>).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<typeof vi.fn>).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<void>((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<typeof vi.fn>).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<void>((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' };
+55 -9
View File
@@ -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<void>;
onClearCleanupComplete?: () => Promise<void>;
onClearCleanupFailed?: () => Promise<void>;
};
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<void> {
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 {
+307 -1
View File
@@ -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<Record<string, unknown>>,
bootstrapExistingArgs: [] as Array<Record<string, unknown>>,
opencodeLoopArgs: [] as Array<Record<string, unknown>>,
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<unknown>),
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<string, unknown>) => {
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<string, unknown>) => {
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<void>) | undefined;
await onClearCleanupComplete?.();
}
if (harness.triggerCleanupFailure) {
await (options.onClearRequested as (() => Promise<void>))();
await (options.onClearCleanupFailed as (() => Promise<void>))();
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<unknown>;
}
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<unknown>) => 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
+119 -2
View File
@@ -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<Parameters<typeof session.onUserMessage>[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);
}
}
+1 -1
View File
@@ -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 = {
+3 -6
View File
@@ -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');
}
});
+5 -6
View File
@@ -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')
};
+141 -1
View File
@@ -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<typeof createSpawnDeduplicator>
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<typeof createSpawnDeduplicator>
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)
})
})
+153 -7
View File
@@ -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<SpawnSessionResult>) & {
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<SpawnSessionResult>
): SpawnDeduplicator {
const completedOrInFlight = new Map<string, Promise<SpawnSessionResult>>();
const childState = new Map<string, 'alive' | 'stopping'>();
const dedupe = async (options: SpawnSessionOptions): Promise<SpawnSessionResult> => {
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<number, string>,
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<void> {
// 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<number, (session: TrackedSession) => void>();
const pidToErrorAwaiter = new Map<number, (errorMessage: string) => 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<number, string>();
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<SpawnSessionResult> => {
let spawnSession!: SpawnDeduplicator;
const spawnSessionOnce = async (options: SpawnSessionOptions): Promise<SpawnSessionResult> => {
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) {
@@ -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'
})
})
})
+22 -5
View File
@@ -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<string, unknown>) }
const existing = current && typeof current === 'object' && !Array.isArray(current)
? current as Record<string, unknown>
: {}
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)
+97
View File
@@ -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()
+15
View File
@@ -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)
}
+36
View File
@@ -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()
+112
View File
@@ -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,
+31
View File
@@ -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[] = []
+70 -8
View File
@@ -575,7 +575,7 @@ export class MessageService {
sentFrom?: 'telegram-bot' | 'webapp'
scheduledAt?: number | null
}
): Promise<void> {
): Promise<string> {
// 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<string>): void {
const mature = this.store.messages.getMatureScheduledMessages(now)
const maturedSessionIds = new Set<string>()
const deliveryGateBySession = new Map<string, boolean>()
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
+827
View File
@@ -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<string, unknown> = {}) {
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<typeof mock>) {
;(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<void> }).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<void>((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<void> }).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<string, unknown>), ...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<string, unknown>), ...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<void> }).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<void> }).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<void> }).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<void> }).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<typeof mock> } }).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<boolean> })
.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<void>((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<boolean> })
.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<string, unknown>),
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<string, unknown>
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<typeof original>) => {
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<typeof original>) => {
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<typeof original>) => {
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<void> }).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<void> }).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()
}
})
})
+508 -7
View File
@@ -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<string, string>()
/** Serialize scratchlist uploads per session so disk-byte caps cannot race. */
private readonly scratchlistUploadTails = new Map<string, Promise<unknown>>()
/** Coalesce duplicate clear requests so retries cannot spawn two fresh sessions. */
private readonly opencodeClearTails = new Map<string, Promise<ClearOpencodeSessionResult>>()
/** Serialize fork/rewind per session so concurrent native rollbacks cannot stack. */
private readonly historyActionsInFlight = new Set<string>()
@@ -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<void> {
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<ClearOpencodeSessionResult> {
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<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.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<Session['metadata']>['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<NonNullable<Session['metadata']>['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<Session['metadata']>['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<Session['metadata']>['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<Session['metadata']>['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<boolean> {
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<ResumeSessionResult> {
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' }
+73
View File
@@ -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'
+56
View File
@@ -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<CliEnv> {
const app = new Hono<CliEnv>()
@@ -178,6 +186,54 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono<Cl
return c.json({ ok: true })
})
app.post('/sessions/:id/clear-opencode', async (c) => {
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) {
+19 -1
View File
@@ -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({
+11
View File
@@ -63,6 +63,17 @@ export type CreateMachineResponse = z.infer<typeof CreateMachineResponseSchema>
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<typeof ClearOpencodeSessionResponseSchema>
export const ClearOpencodeSessionCallbackRequestSchema = z.object({
replacementSessionId: z.string()
})
export type ClearOpencodeSessionCallbackRequest = z.infer<typeof ClearOpencodeSessionCallbackRequestSchema>
export type AuthResponse = {
token: string
user: {
+9
View File
@@ -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',
+16
View File
@@ -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')
})
})
+16 -1
View File
@@ -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<typeof SessionEndReasonSchema>
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<typeof OpencodeClearOperationSchema>
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(),
+6 -1
View File
@@ -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> = {}): SessionSummary {
return {
@@ -25,6 +25,11 @@ function makeSummary(overrides: Partial<SessionSummary> = {}): 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)
+8
View File
@@ -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<SyncEvent, { type: 'toast' }>
@@ -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()
+24
View File
@@ -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) {
@@ -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)
})
})
@@ -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
}