mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(opencode): open a fresh session on clear (#1300)
* test(opencode): specify fresh-session clear * feat(opencode): open a fresh session on clear * fix(opencode): release clear latch on cancel * fix(opencode): retry transient clear handoffs * fix(opencode): confirm clear archive delivery * fix(web): preserve superseded session access * fix(clear): invalidate transferred schedules * fix(runner): restore live spawn dedupe * fix(clear): preserve latched scheduled prompts * fix(runner): quarantine unverified children * fix(clear): retain handoff retry ownership * fix(runner): release recovered spawn dedupe * fix(clear): retain archive retry ownership * fix(clear): settle rejected immediate prompts * fix(clear): block reopening replaced sources * fix(clear): settle prompts when clear is cancelled * fix(clear): make fresh-session handoff durable * fix(clear): finalize only after native cleanup * fix(clear): abort failed native handoffs * fix(clear): gate recovery on cleanup proof * fix(clear): retry metadata persistence failures * fix(clear): preserve handoff ownership through teardown * fix(clear): abort incomplete cleanup reservations * fix(clear): require explicit exit before abort * fix(clear): verify owner exit before recovery * fix(clear): guard recovery handoff races * fix(clear): serialize cleanup callbacks * fix(clear): make callback retries idempotent * fix(clear): bind callbacks to reservations * fix(clear): recover pending spawns * fix(clear): deduplicate held prompts * fix(clear): validate redirect ownership * fix(clear): replay prompts in FIFO order * fix(clear): gate replacement delivery
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -106,6 +106,42 @@ describe('API extra headers integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the CLI REST bridge to request a fresh OpenCode session after source cleanup', async () => {
|
||||
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
|
||||
|
||||
const client = await ApiClient.create()
|
||||
await expect(client.clearOpenCodeSession('source-session')).resolves.toBe('fresh-session')
|
||||
|
||||
expect(axiosPostMock).toHaveBeenCalledWith(
|
||||
'https://hapi.example.com/cli/sessions/source-session/clear-opencode',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer cli-token' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the CLI REST bridge to reserve before source cleanup', async () => {
|
||||
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
|
||||
const client = await ApiClient.create()
|
||||
await expect(client.reserveOpenCodeClearSession('source-session')).resolves.toBe('fresh-session')
|
||||
expect(axiosPostMock).toHaveBeenCalledWith(
|
||||
'https://hapi.example.com/cli/sessions/source-session/clear-opencode/reserve', {},
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer cli-token' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['confirmOpenCodeClearCleanup', 'abortOpenCodeClearSession'] as const)('sends reservation identity with %s', async (method) => {
|
||||
axiosPostMock.mockResolvedValue({ data: { ok: true, sessionId: 'fresh-session' } })
|
||||
const client = await ApiClient.create()
|
||||
await expect(client[method]('source-session', 'fresh-session')).resolves.toBe('fresh-session')
|
||||
expect(axiosPostMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/clear-opencode/'),
|
||||
{ replacementSessionId: 'fresh-session' },
|
||||
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer cli-token' }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('adds extra headers to socket transport options', () => {
|
||||
configuration._setExtraHeaders({
|
||||
Cookie: 'CF_Authorization=token'
|
||||
|
||||
+50
-1
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import type { AgentState, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
|
||||
import type { AgentState, ClearOpencodeSessionCallbackRequest, ClearOpencodeSessionResponse, CreateMachineResponse, CreateSessionResponse, RunnerState, Machine, MachineMetadata, Metadata, Session } from '@/api/types'
|
||||
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
|
||||
import {
|
||||
AgentStateSchema,
|
||||
ClearOpencodeSessionResponseSchema,
|
||||
CreateMachineResponseSchema,
|
||||
CreateSessionResponseSchema,
|
||||
GetSessionResponseSchema,
|
||||
@@ -272,6 +273,54 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async clearOpenCodeSession(sessionId: string): Promise<string> {
|
||||
const response = await axios.post<ClearOpencodeSessionResponse>(
|
||||
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode`,
|
||||
{},
|
||||
{
|
||||
headers: this.authHeaders(),
|
||||
timeout: 60_000
|
||||
}
|
||||
)
|
||||
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
|
||||
if (!parsed.success) {
|
||||
throw apiValidationError('Invalid /cli/sessions/:id/clear-opencode response', response)
|
||||
}
|
||||
return parsed.data.sessionId
|
||||
}
|
||||
|
||||
async reserveOpenCodeClearSession(sessionId: string): Promise<string> {
|
||||
const response = await axios.post<ClearOpencodeSessionResponse>(
|
||||
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/reserve`, {},
|
||||
{ headers: this.authHeaders(), timeout: 60_000 }
|
||||
)
|
||||
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
|
||||
if (!parsed.success) throw apiValidationError('Invalid clear reservation response', response)
|
||||
return parsed.data.sessionId
|
||||
}
|
||||
|
||||
async abortOpenCodeClearSession(sessionId: string, replacementSessionId: string): Promise<string> {
|
||||
const response = await axios.post<ClearOpencodeSessionResponse>(
|
||||
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/abort`,
|
||||
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
|
||||
{ headers: this.authHeaders(), timeout: 60_000 }
|
||||
)
|
||||
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
|
||||
if (!parsed.success) throw apiValidationError('Invalid clear abort response', response)
|
||||
return parsed.data.sessionId
|
||||
}
|
||||
|
||||
async confirmOpenCodeClearCleanup(sessionId: string, replacementSessionId: string): Promise<string> {
|
||||
const response = await axios.post<ClearOpencodeSessionResponse>(
|
||||
`${configuration.apiUrl}/cli/sessions/${encodeURIComponent(sessionId)}/clear-opencode/confirm-cleanup`,
|
||||
{ replacementSessionId } satisfies ClearOpencodeSessionCallbackRequest,
|
||||
{ headers: this.authHeaders(), timeout: 60_000 }
|
||||
)
|
||||
const parsed = ClearOpencodeSessionResponseSchema.safeParse(response.data)
|
||||
if (!parsed.success) throw apiValidationError('Invalid clear cleanup confirmation response', response)
|
||||
return parsed.data.sessionId
|
||||
}
|
||||
|
||||
sessionSyncClient(session: Session, options?: ApiSessionClientOptions): ApiSessionClient {
|
||||
return new ApiSessionClient(this.token, session, options)
|
||||
}
|
||||
|
||||
@@ -372,6 +372,21 @@ describe('ApiSessionClient lazy materialization', () => {
|
||||
expect(socket.emitted.some((entry) => entry.event === 'session-end')).toBe(true)
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('reports an unconfirmed final flush when the socket cannot reconnect before the deadline', async () => {
|
||||
socketHarness.sockets.length = 0
|
||||
const client = new ApiSessionClient('token', createSession({ namespace: 'default' }))
|
||||
const socket = socketHarness.sockets[0]
|
||||
if (!socket) throw new Error('expected socket')
|
||||
socket.connected = false
|
||||
socket.connectImmediately = false
|
||||
client.sendSessionDeath('cleared')
|
||||
|
||||
await expect(client.flush({ timeoutMs: 20 })).resolves.toBe(false)
|
||||
|
||||
expect(socket.connectCalls).toBeGreaterThan(0)
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiSessionClient incoming user messages', () => {
|
||||
|
||||
@@ -1098,7 +1098,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
return await this.drainLock(this.metadataLock, timeoutMs)
|
||||
}
|
||||
|
||||
async flush(options?: { timeoutMs?: number }): Promise<void> {
|
||||
async flush(options?: { timeoutMs?: number }): Promise<boolean> {
|
||||
const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000)
|
||||
const remainingMs = () => Math.max(0, deadlineMs - Date.now())
|
||||
|
||||
@@ -1106,37 +1106,44 @@ export class ApiSessionClient extends EventEmitter {
|
||||
if (materializationTask) {
|
||||
this.materializationDrainRequested = true
|
||||
this.materializationRetryAbortController?.abort()
|
||||
await this.waitForPromise(materializationTask, remainingMs())
|
||||
if (!await this.waitForPromise(materializationTask, remainingMs())) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state !== 'active') {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.socket.connected) {
|
||||
const connected = await this.waitForConnected(remainingMs())
|
||||
if (!connected) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
await this.drainLock(this.metadataLock, remainingMs())
|
||||
await this.drainLock(this.agentStateLock, remainingMs())
|
||||
if (!await this.drainLock(this.metadataLock, remainingMs())) {
|
||||
return false
|
||||
}
|
||||
if (!await this.drainLock(this.agentStateLock, remainingMs())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (remainingMs() === 0) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
const pingTimeoutMs = remainingMs()
|
||||
if (pingTimeoutMs === 0) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.socket.timeout(pingTimeoutMs).emitWithAck('ping')
|
||||
this.awaitingMaterializedConnection = false
|
||||
return true
|
||||
} catch {
|
||||
// best effort
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
RunnerStateSchema
|
||||
} from '@hapi/protocol/schemas'
|
||||
import {
|
||||
ClearOpencodeSessionResponseSchema,
|
||||
ClearOpencodeSessionCallbackRequestSchema,
|
||||
CliMessagesResponseSchema,
|
||||
CreateMachineResponseSchema,
|
||||
CreateSessionResponseSchema,
|
||||
@@ -13,6 +15,8 @@ import {
|
||||
LocalHandoffResponseSchema,
|
||||
LocalResumeTargetResponseSchema,
|
||||
ResumableSessionsResponseSchema,
|
||||
type ClearOpencodeSessionResponse,
|
||||
type ClearOpencodeSessionCallbackRequest,
|
||||
type CliMessagesResponse,
|
||||
type CreateMachineResponse,
|
||||
type CreateSessionResponse,
|
||||
@@ -45,6 +49,8 @@ export type SessionEffort = string | null
|
||||
export { AgentStateSchema, AttachmentMetadataSchema, MachineMetadataSchema, MetadataSchema, RunnerStateSchema }
|
||||
|
||||
export {
|
||||
ClearOpencodeSessionCallbackRequestSchema,
|
||||
ClearOpencodeSessionResponseSchema,
|
||||
CliMessagesResponseSchema,
|
||||
CreateMachineResponseSchema,
|
||||
CreateSessionResponseSchema,
|
||||
@@ -55,6 +61,8 @@ export {
|
||||
}
|
||||
|
||||
export type {
|
||||
ClearOpencodeSessionCallbackRequest,
|
||||
ClearOpencodeSessionResponse,
|
||||
CliMessagesResponse,
|
||||
CreateMachineResponse,
|
||||
CreateSessionResponse,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
|
||||
@@ -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
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user