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

* test(opencode): specify fresh-session clear

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

* fix(opencode): release clear latch on cancel

* fix(opencode): retry transient clear handoffs

* fix(opencode): confirm clear archive delivery

* fix(web): preserve superseded session access

* fix(clear): invalidate transferred schedules

* fix(runner): restore live spawn dedupe

* fix(clear): preserve latched scheduled prompts

* fix(runner): quarantine unverified children

* fix(clear): retain handoff retry ownership

* fix(runner): release recovered spawn dedupe

* fix(clear): retain archive retry ownership

* fix(clear): settle rejected immediate prompts

* fix(clear): block reopening replaced sources

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

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

* fix(clear): finalize only after native cleanup

* fix(clear): abort failed native handoffs

* fix(clear): gate recovery on cleanup proof

* fix(clear): retry metadata persistence failures

* fix(clear): preserve handoff ownership through teardown

* fix(clear): abort incomplete cleanup reservations

* fix(clear): require explicit exit before abort

* fix(clear): verify owner exit before recovery

* fix(clear): guard recovery handoff races

* fix(clear): serialize cleanup callbacks

* fix(clear): make callback retries idempotent

* fix(clear): bind callbacks to reservations

* fix(clear): recover pending spawns

* fix(clear): deduplicate held prompts

* fix(clear): validate redirect ownership

* fix(clear): replay prompts in FIFO order

* fix(clear): gate replacement delivery
This commit is contained in:
Junmo Kim
2026-08-03 18:06:39 +08:00
committed by GitHub
parent 1b8cc334ea
commit f44c9ff3e6
41 changed files with 3209 additions and 73 deletions
+7 -1
View File
@@ -25,6 +25,9 @@ interface OpencodeLoopOptions {
onSessionReady?: (session: OpencodeSession) => void;
onReasoningEffortRollback?: (effort: string | null) => void;
onCompactAvailabilityChange?: (available: boolean) => void;
onClearRequested?: () => Promise<void>;
onClearCleanupComplete?: () => Promise<void>;
onClearCleanupFailed?: () => Promise<void>;
// Consumes (delete-and-return) whether the given localId was cancelled
// after already being dequeued — needed because a queued /compact can
// still be running (its REST call can take minutes) by the time a
@@ -80,7 +83,10 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
runRemote: (instance) => opencodeRemoteLauncher(instance, {
onReasoningEffortRollback: opts.onReasoningEffortRollback,
onCompactAvailabilityChange: opts.onCompactAvailabilityChange,
isLocalIdCancelled: opts.isLocalIdCancelled
isLocalIdCancelled: opts.isLocalIdCancelled,
onClearRequested: opts.onClearRequested,
onClearCleanupComplete: opts.onClearCleanupComplete,
onClearCleanupFailed: opts.onClearCleanupFailed
}),
onSessionReady: opts.onSessionReady
});
+131 -6
View File
@@ -10,6 +10,7 @@ const harness = vi.hoisted(() => ({
refreshSessionInfoCalls: [] as Array<{ sessionId: string; cwd: string }>,
bridgeOptions: null as { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } } | null,
events: [] as string[],
cleanupEvents: [] as string[],
setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise<void>),
setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise<void>),
thoughtLevelOption: null as null | { id: string; currentValue?: string; options: Array<{ value: string; name?: string }> },
@@ -29,7 +30,10 @@ const harness = vi.hoisted(() => ({
// registered once initialization finishes), so that race can only be
// reproduced via the terminal UI's onExit/onSwitchToLocal callbacks,
// not rpcHandlers.
newSessionImpl: null as null | (() => Promise<string>)
newSessionImpl: null as null | (() => Promise<string>),
disconnectImpl: null as null | (() => Promise<void>),
permissionCancelError: null as Error | null,
serverStopError: null as Error | null
}));
// Captures the RemoteLauncherDisplayContext (including onExit/
@@ -108,7 +112,12 @@ vi.mock('./utils/opencodeBackend', () => ({
harness.refreshSessionInfoCalls.push({ sessionId, cwd });
}),
onPermissionRequest: vi.fn(),
disconnect: vi.fn(async () => {}),
disconnect: vi.fn(async () => {
harness.cleanupEvents.push('cleanup:disconnect');
if (harness.disconnectImpl) {
await harness.disconnectImpl();
}
}),
getSessionModelsMetadata: vi.fn(() => harness.sessionModelsMetadata),
getThoughtLevelConfigOption: vi.fn(() => harness.thoughtLevelOption ?? undefined),
// Real AcpSdkBackend.suppressUpdatesDuring swaps out the message
@@ -123,7 +132,7 @@ vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({
buildHapiMcpBridge: async (_client: unknown, options?: { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } }) => {
harness.bridgeOptions = options ?? null;
return {
server: { stop: () => {} },
server: { stop: () => { harness.cleanupEvents.push('cleanup:server-stop'); if (harness.serverStopError) throw harness.serverStopError; } },
mcpServers: {}
};
}
@@ -131,7 +140,7 @@ vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({
vi.mock('./utils/permissionHandler', () => ({
OpencodePermissionHandler: class {
async cancelAll(): Promise<void> {}
async cancelAll(): Promise<void> { harness.cleanupEvents.push('cleanup:permission'); if (harness.permissionCancelError) throw harness.permissionCancelError; }
}
}));
@@ -300,6 +309,13 @@ function createCompactMode(model?: string): OpencodeMode {
};
}
function createClearMode(): OpencodeMode {
return {
permissionMode: 'default' as PermissionMode,
operation: 'clear'
};
}
describe('opencodeRemoteLauncher inline model switch', () => {
afterEach(() => {
harness.setModelArgs = [];
@@ -309,6 +325,7 @@ describe('opencodeRemoteLauncher inline model switch', () => {
harness.refreshSessionInfoCalls = [];
harness.bridgeOptions = null;
harness.events = [];
harness.cleanupEvents = [];
harness.setModelImpl = null;
harness.setConfigOptionImpl = null;
harness.thoughtLevelOption = null;
@@ -322,9 +339,109 @@ describe('opencodeRemoteLauncher inline model switch', () => {
harness.sessionModelsMetadata = undefined;
harness.cancelPromptImpl = null;
harness.newSessionImpl = null;
harness.disconnectImpl = null;
harness.permissionCancelError = null;
harness.serverStopError = null;
inkHarness.lastRenderProps = null;
});
it('reaches /clear only after the earlier prompt settles, without starting another OpenCode turn', async () => {
let resolvePrompt: (() => void) | null = null;
harness.promptImpl = () => new Promise<void>((resolve) => {
resolvePrompt = resolve;
});
const onClearRequested = vi.fn();
const onClearCleanupComplete = vi.fn(async () => {});
const { session } = createSessionStub([
{ message: 'before-clear', mode: createMode() },
{ message: '', mode: createClearMode() }
]);
const launcherPromise = opencodeRemoteLauncher(session as never, { onClearRequested, onClearCleanupComplete });
while (!harness.events.includes('prompt:start')) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(onClearRequested).not.toHaveBeenCalled();
resolvePrompt!();
await launcherPromise;
expect(harness.events).toEqual(['prompt:start', 'prompt:end']);
expect(harness.promptCount).toBe(1);
expect(onClearRequested).toHaveBeenCalledTimes(1);
expect(onClearCleanupComplete).toHaveBeenCalledTimes(1);
// The sibling compact test below intentionally inspects its first
// factory result; do not leave this test's backend instance behind.
const backendModule = await import('./utils/opencodeBackend');
(backendModule.createOpencodeBackend as unknown as ReturnType<typeof vi.fn>).mockClear();
});
it('reserves before native cleanup but does not complete the transition when cleanup fails', async () => {
harness.disconnectImpl = async () => {
throw new Error('disconnect failed');
};
const onClearRequested = vi.fn();
const onClearCleanupComplete = vi.fn(async () => {});
const onClearCleanupFailed = vi.fn(async () => {});
const { session } = createSessionStub([
{ message: '', mode: createClearMode() }
]);
await expect(opencodeRemoteLauncher(session as never, { onClearRequested, onClearCleanupComplete, onClearCleanupFailed })).rejects.toThrow('disconnect failed');
expect(onClearRequested).toHaveBeenCalledTimes(1);
expect(onClearCleanupComplete).not.toHaveBeenCalled();
expect(onClearCleanupFailed).toHaveBeenCalledTimes(1);
const backendModule = await import('./utils/opencodeBackend');
(backendModule.createOpencodeBackend as unknown as ReturnType<typeof vi.fn>).mockClear();
});
it.each(['permission', 'server'] as const)('aborts clear when %s cleanup fails', async (stage) => {
if (stage === 'permission') harness.permissionCancelError = new Error('permission cleanup failed');
else harness.serverStopError = new Error('server cleanup failed');
const onClearRequested = vi.fn(async () => {});
const onClearCleanupComplete = vi.fn(async () => {});
const onClearCleanupFailed = vi.fn(async () => {});
const { session } = createSessionStub([{ message: '', mode: createClearMode() }]);
await expect(opencodeRemoteLauncher(session as never, {
onClearRequested, onClearCleanupComplete, onClearCleanupFailed
})).rejects.toThrow('cleanup failed');
expect(onClearCleanupFailed).toHaveBeenCalledTimes(1);
expect(onClearCleanupComplete).not.toHaveBeenCalled();
expect(harness.cleanupEvents).toEqual(expect.arrayContaining([
'cleanup:permission', 'cleanup:disconnect', 'cleanup:server-stop'
]));
});
it('reaches /clear only after an in-flight /compact has completed', async () => {
harness.sessionModelsMetadata = { currentModelId: 'ollama/x', availableModels: [] };
let resolveCompact: (() => void) | null = null;
compactHarness.triggerImpl = () => new Promise((resolve) => {
resolveCompact = () => resolve({ ok: true });
});
const onClearRequested = vi.fn();
const { session } = createSessionStub([
{ message: '', mode: createCompactMode('ollama/x') },
{ message: '', mode: createClearMode() }
]);
const launcherPromise = opencodeRemoteLauncher(session as never, {
onCompactAvailabilityChange: () => {},
onClearRequested
});
while (compactHarness.calls.length === 0) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
expect(onClearRequested).not.toHaveBeenCalled();
resolveCompact!();
await launcherPromise;
expect(compactHarness.calls).toHaveLength(1);
expect(onClearRequested).toHaveBeenCalledTimes(1);
const backendModule = await import('./utils/opencodeBackend');
(backendModule.createOpencodeBackend as unknown as ReturnType<typeof vi.fn>).mockClear();
});
it('processes a queued /compact operation only after an earlier queued prompt has finished', async () => {
let resolvePrompt: (() => void) | null = null;
harness.promptImpl = () => new Promise<void>((resolve) => {
@@ -533,7 +650,11 @@ describe('opencodeRemoteLauncher inline model switch', () => {
setSessionInfoUpdateListener: vi.fn(),
refreshSessionInfo: vi.fn(async () => {}),
onPermissionRequest: vi.fn(),
disconnect: vi.fn(async () => {}),
disconnect: vi.fn(async () => {
if (harness.disconnectImpl) {
await harness.disconnectImpl();
}
}),
getSessionModelsMetadata: vi.fn(() => ({
currentModelId: 'ollama/qwen3.6:35b-a3b-q8_0-mtp',
availableModels: []
@@ -1600,7 +1721,11 @@ describe('opencodeRemoteLauncher inline model switch', () => {
setSessionInfoUpdateListener: vi.fn(),
refreshSessionInfo: vi.fn(async () => {}),
onPermissionRequest: vi.fn(),
disconnect: vi.fn(async () => {}),
disconnect: vi.fn(async () => {
if (harness.disconnectImpl) {
await harness.disconnectImpl();
}
}),
getSessionModelsMetadata: vi.fn((sessionId: string) => {
if (sessionId === 'acp-session-1') {
return { availableModels: fixtureModels, currentModelId: 'ollama/exaone:4.5-33b-q8' };
+55 -9
View File
@@ -32,6 +32,12 @@ type OpencodeRemoteLauncherOptions = {
// call (and summary lookup) settles, so a cancelled request's result
// doesn't surface for an action the user no longer expects a reply from.
isLocalIdCancelled?: (localId: string) => boolean;
// Called only after /clear reaches its FIFO position *and* this
// launcher has disconnected its OpenCode backend. The caller then performs
// the source lifecycle cleanup before requesting the fresh process.
onClearRequested?: () => Promise<void>;
onClearCleanupComplete?: () => Promise<void>;
onClearCleanupFailed?: () => Promise<void>;
};
export type AbortStatusDecision = {
@@ -77,6 +83,10 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
private baseUrl: string | null = null;
private permissionHandler: OpencodePermissionHandler | null = null;
private happyServer: { stop: () => void } | null = null;
// Becomes true when the FIFO loop reaches /clear. Its callback is deferred
// until cleanup() completes so a failed OpenCode disconnect cannot create a
// replacement while the source backend may still be live.
private clearRequested = false;
private abortController = new AbortController();
// Set by the dequeue loop as soon as a batch is identified as a
// `operation:'compact'` one — deliberately *before* that batch's inline
@@ -303,6 +313,18 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
break;
}
// /clear is deliberately a queue operation rather than a direct
// slash side effect: every prompt and /compact ahead of it has
// completed before this point. In particular, do not route this
// through handleAbort(true): that method exists to interrupt an
// in-flight compact, while clear can only run after one finishes.
if (batch.mode.operation === 'clear') {
await this.options.onClearRequested?.();
this.clearRequested = true;
await this.requestExit('exit', async () => {})
break;
}
// Created here — before the model/effort switch below — rather
// than inside runCompactOperation(), so it already exists for
// handleAbort() to act on during that switch. backend.setModel()/
@@ -601,21 +623,45 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
protected async cleanup(): Promise<void> {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
const failures: unknown[] = [];
if (this.permissionHandler) {
await this.permissionHandler.cancelAll('Session ended');
this.permissionHandler = null;
try {
await this.permissionHandler.cancelAll('Session ended');
} catch (error) {
failures.push(error);
} finally {
this.permissionHandler = null;
}
}
if (this.backend) {
await this.backend.disconnect();
this.backend = null;
try {
await this.backend.disconnect();
} catch (error) {
failures.push(error);
} finally {
this.backend = null;
}
}
if (this.happyServer) {
this.happyServer.stop();
this.happyServer = null;
try {
this.happyServer.stop();
} catch (error) {
failures.push(error);
} finally {
this.happyServer = null;
}
}
if (failures.length > 0) {
if (this.clearRequested) await this.options.onClearCleanupFailed?.();
throw failures.length === 1 ? failures[0] : new AggregateError(failures, 'OpenCode cleanup failed');
}
if (this.clearRequested) await this.options.onClearCleanupComplete?.();
// Signal the runner only after the native backend is gone. If an
// awaited teardown above fails, RemoteLauncherBase propagates that
// failure and this callback never runs; runOpencode then archives the
// source as an error rather than spawning a potentially concurrent
// replacement.
}
private rollbackReasoningEffort(batch: { mode: OpencodeMode }, effort: string | null): void {
+307 -1
View File
@@ -1,4 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildCliArgs } from '@/runner/run';
import { parseRemoteAgentCommandOptions } from '@/commands/agentCommandOptions';
import { OPENCODE_PERMISSION_MODES } from '@hapi/protocol/modes';
const mockOpencodeSession = vi.hoisted(() => ({
setModel: vi.fn(),
@@ -21,10 +24,18 @@ const mockOpencodeSession = vi.hoisted(() => ({
const harness = vi.hoisted(() => ({
bootstrapArgs: [] as Array<Record<string, unknown>>,
bootstrapExistingArgs: [] as Array<Record<string, unknown>>,
opencodeLoopArgs: [] as Array<Record<string, unknown>>,
opencodeLoopError: null as Error | null,
triggerClear: false,
triggerCleanupFailure: false,
clearOpenCodeSession: vi.fn(async () => 'fresh-session'),
reserveOpenCodeClearSession: vi.fn(async () => 'fresh-session'),
confirmOpenCodeClearCleanup: vi.fn(async () => 'fresh-session'),
abortOpenCodeClearSession: vi.fn(async () => 'source-session'),
listSlashCommands: vi.fn(async (..._args: unknown[]) => [] as Array<unknown>),
session: {
sessionId: 'source-session',
onUserMessage: vi.fn(),
onCancelQueuedMessage: vi.fn(),
sendAgentMessage: vi.fn(),
@@ -44,7 +55,14 @@ vi.mock('@/agent/sessionFactory', () => ({
bootstrapSession: vi.fn(async (options: Record<string, unknown>) => {
harness.bootstrapArgs.push(options);
return {
api: {},
api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession },
session: harness.session
};
}),
bootstrapExistingSession: vi.fn(async (options: Record<string, unknown>) => {
harness.bootstrapExistingArgs.push(options);
return {
api: { clearOpenCodeSession: harness.clearOpenCodeSession, reserveOpenCodeClearSession: harness.reserveOpenCodeClearSession, confirmOpenCodeClearCleanup: harness.confirmOpenCodeClearCleanup, abortOpenCodeClearSession: harness.abortOpenCodeClearSession },
session: harness.session
};
})
@@ -60,6 +78,17 @@ vi.mock('./loop', () => ({
if (onSessionReady) {
onSessionReady(mockOpencodeSession);
}
if (harness.triggerClear) {
const onClearRequested = options.onClearRequested as (() => void) | undefined;
await onClearRequested?.();
const onClearCleanupComplete = options.onClearCleanupComplete as (() => Promise<void>) | undefined;
await onClearCleanupComplete?.();
}
if (harness.triggerCleanupFailure) {
await (options.onClearRequested as (() => Promise<void>))();
await (options.onClearCleanupFailed as (() => Promise<void>))();
throw new Error('disconnect failed');
}
})
}));
@@ -69,6 +98,8 @@ vi.mock('@/claude/registerKillSessionHandler', () => ({
const lifecycleMock = vi.hoisted(() => ({
registerProcessHandlers: vi.fn(),
cleanup: vi.fn(async () => {}),
cleanupConfirmed: vi.fn(async () => {}),
cleanupAndExit: vi.fn(async () => {}),
markCrash: vi.fn(),
setExitCode: vi.fn(),
@@ -109,8 +140,17 @@ import { runOpencode } from './runOpencode';
describe('runOpencode set-session-config handler', () => {
beforeEach(() => {
harness.bootstrapArgs.length = 0;
harness.bootstrapExistingArgs.length = 0;
harness.opencodeLoopArgs.length = 0;
harness.opencodeLoopError = null;
harness.triggerClear = false;
harness.triggerCleanupFailure = false;
harness.clearOpenCodeSession.mockReset();
harness.clearOpenCodeSession.mockResolvedValue('fresh-session');
harness.reserveOpenCodeClearSession.mockReset();
harness.reserveOpenCodeClearSession.mockResolvedValue('fresh-session');
harness.abortOpenCodeClearSession.mockReset();
harness.abortOpenCodeClearSession.mockResolvedValue('source-session');
mockOpencodeSession.setModel.mockReset();
mockOpencodeSession.setPermissionMode.mockReset();
mockOpencodeSession.setModelReasoningEffort.mockReset();
@@ -127,6 +167,9 @@ describe('runOpencode set-session-config handler', () => {
harness.listSlashCommands.mockReset();
harness.listSlashCommands.mockResolvedValue([]);
lifecycleMock.registerProcessHandlers.mockClear();
lifecycleMock.cleanup.mockClear();
lifecycleMock.cleanupConfirmed.mockReset();
lifecycleMock.cleanupConfirmed.mockResolvedValue(undefined);
lifecycleMock.cleanupAndExit.mockClear();
lifecycleMock.markCrash.mockClear();
lifecycleMock.setExitCode.mockClear();
@@ -143,6 +186,24 @@ describe('runOpencode set-session-config handler', () => {
return configHandler![1] as (payload: unknown) => Promise<unknown>;
}
it('carries the runner preallocated id from CLI args through parse into bootstrapExistingSession', async () => {
const runnerArgs = buildCliArgs('opencode', {
directory: '/tmp/project',
existingSessionId: 'preallocated-hapi-id'
});
const parsed = parseRemoteAgentCommandOptions(runnerArgs.slice(1), OPENCODE_PERMISSION_MODES);
await runOpencode({ ...parsed, workingDirectory: '/tmp/project' });
expect(harness.bootstrapArgs).toEqual([]);
expect(harness.bootstrapExistingArgs).toEqual([{
sessionId: 'preallocated-hapi-id',
flavor: 'opencode',
startedBy: 'runner',
workingDirectory: '/tmp/project'
}]);
});
it('rejects plan mode for local OpenCode startup', async () => {
await expect(runOpencode({ permissionMode: 'plan' })).rejects.toThrow(
'OpenCode plan mode is only supported in remote mode'
@@ -331,6 +392,251 @@ describe('runOpencode set-session-config handler', () => {
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
});
it('queues runner-backed /clear as its own FIFO operation without acknowledging it early', async () => {
await runOpencode({ startedBy: 'runner' });
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as
{ queue: Array<{ message: string; mode: { operation?: string }; localId?: string; isolate?: boolean }> };
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
| undefined;
userMessageHandler!({ content: { text: '/clear' } }, 'local-clear');
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(messageQueue.queue).toEqual([
{
message: '',
mode: expect.objectContaining({ operation: 'clear' }),
modeHash: expect.any(String),
localId: 'local-clear',
isolate: true
}
]);
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalled();
});
it('leaves a prompt uninvoked when it arrives during the clear latch so scheduled rows can transfer', async () => {
let resolveCommands: ((commands: Array<unknown>) => void) | undefined;
harness.listSlashCommands.mockImplementationOnce(() => new Promise((resolve) => {
resolveCommands = resolve;
}));
await runOpencode({ startedBy: 'runner' });
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as
{ queue: Array<{ mode: { operation?: string }; localId?: string }> };
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void);
userMessageHandler({ content: { text: '/clear' } }, 'first-clear');
userMessageHandler({ content: { text: 'must not reach the source session' } }, 'follow-up');
userMessageHandler({ content: { text: 'redelivered scheduled prompt' } }, 'follow-up');
await Promise.resolve();
resolveCommands?.([]);
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(messageQueue.queue).toEqual([expect.objectContaining({
mode: expect.objectContaining({ operation: 'clear' }),
localId: 'first-clear'
})]);
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith(
['follow-up'],
expect.anything()
);
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
});
it('releases the clear transition latch when the queued /clear is cancelled', async () => {
await runOpencode({ startedBy: 'runner' });
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as
{ queue: Array<{ message: string; mode: { operation?: string }; localId?: string }> };
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void);
const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as
((localId: string) => boolean);
userMessageHandler({ content: { text: '/clear' } }, 'queued-clear');
userMessageHandler({ content: { text: 'rejected while clear is queued' } }, 'rejected-before-cancel');
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(cancelHandler('queued-clear')).toBe(true);
expect(harness.session.emitMessagesConsumed).not.toHaveBeenCalledWith(
['rejected-before-cancel'], expect.anything()
);
userMessageHandler({ content: { text: 'continue in the source session' } }, 'after-cancel');
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(messageQueue.queue).toEqual([
expect.objectContaining({ message: 'rejected while clear is queued', localId: 'rejected-before-cancel' }),
expect.objectContaining({ message: 'continue in the source session', localId: 'after-cancel' })
]);
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
});
it('removes an individually cancelled prompt held behind queued clear', async () => {
await runOpencode({ startedBy: 'runner' });
const messageQueue = harness.opencodeLoopArgs[0]?.messageQueue as { queue: Array<{ localId?: string }> };
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
((msg: { content: { text: string } }, localId?: string) => void);
const cancelHandler = harness.session.onCancelQueuedMessage.mock.calls[0]?.[0] as ((localId: string) => boolean);
userMessageHandler({ content: { text: '/clear' } }, 'queued-clear');
userMessageHandler({ content: { text: 'keep me' } }, 'held-keep');
userMessageHandler({ content: { text: 'cancel me' } }, 'held-cancel');
for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0));
expect(cancelHandler('held-cancel')).toBe(true);
expect(cancelHandler('queued-clear')).toBe(true);
expect(messageQueue.queue.map((item) => item.localId)).toEqual(['held-keep']);
});
it('archives the source before asking the hub to spawn the fresh OpenCode process', async () => {
harness.triggerClear = true;
const order: string[] = [];
lifecycleMock.cleanupConfirmed.mockImplementationOnce(async () => { order.push('cleanup'); });
harness.reserveOpenCodeClearSession.mockImplementationOnce(async () => { order.push('reserve'); return 'fresh-session'; });
harness.clearOpenCodeSession.mockImplementationOnce(async () => {
order.push('spawn');
return 'fresh-session';
});
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
try {
await runOpencode({ startedBy: 'runner' });
} finally {
exit.mockRestore();
}
expect(lifecycleMock.setArchiveReason).toHaveBeenCalledWith('Cleared by /clear');
expect(lifecycleMock.setSessionEndReason).toHaveBeenCalledWith('cleared');
expect(harness.clearOpenCodeSession).toHaveBeenCalledWith('source-session');
expect(harness.confirmOpenCodeClearCleanup).toHaveBeenCalledWith('source-session', 'fresh-session');
expect(order).toEqual(['reserve', 'cleanup', 'spawn']);
});
it('keeps archive-confirmation ownership beyond the old finite budget', async () => {
vi.useFakeTimers();
harness.triggerClear = true;
const timeout = Object.assign(new Error('archive acknowledgement timed out'), { code: 'ETIMEDOUT' });
for (let i = 0; i < 13; i++) {
lifecycleMock.cleanupConfirmed.mockRejectedValueOnce(timeout);
}
lifecycleMock.cleanupConfirmed.mockResolvedValueOnce(undefined);
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
try {
const run = runOpencode({ startedBy: 'runner' });
await vi.runAllTimersAsync();
await run;
expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(14);
expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(1);
} finally {
exit.mockRestore();
vi.useRealTimers();
}
});
it('retries a lost durable-reservation response before native teardown', async () => {
vi.useFakeTimers();
harness.triggerClear = true;
const transient = Object.assign(new Error('connection reset after commit'), { code: 'ECONNRESET' });
harness.reserveOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('fresh-session');
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
try {
const run = runOpencode({ startedBy: 'runner' });
await vi.runAllTimersAsync();
await run;
expect(harness.reserveOpenCodeClearSession).toHaveBeenCalledTimes(2);
expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalledTimes(1);
} finally {
exit.mockRestore();
vi.useRealTimers();
}
});
it('surfaces a fresh-session handoff failure instead of exiting cleanly after archival', async () => {
harness.triggerClear = true;
harness.clearOpenCodeSession.mockRejectedValueOnce(new Error('replacement link failed'));
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
try {
await expect(runOpencode({ startedBy: 'runner' })).rejects.toThrow('replacement link failed');
} finally {
exit.mockRestore();
}
expect(lifecycleMock.cleanupConfirmed).toHaveBeenCalled();
expect(exit).not.toHaveBeenCalled();
});
it('retries a transient abort notification before releasing cleanup-failure ownership', async () => {
vi.useFakeTimers();
harness.triggerCleanupFailure = true;
const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' });
harness.abortOpenCodeClearSession.mockRejectedValueOnce(transient).mockResolvedValueOnce('source-session');
try {
const run = runOpencode({ startedBy: 'runner' });
await vi.runAllTimersAsync();
await run;
expect(harness.abortOpenCodeClearSession).toHaveBeenCalledTimes(2);
expect(harness.abortOpenCodeClearSession).toHaveBeenLastCalledWith('source-session', 'fresh-session');
expect(harness.clearOpenCodeSession).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('keeps retry ownership beyond the old finite budget until the archived-source handoff succeeds', async () => {
vi.useFakeTimers();
harness.triggerClear = true;
const transient = Object.assign(new Error('connection reset'), { code: 'ECONNRESET' });
harness.clearOpenCodeSession
.mockRejectedValueOnce(transient)
.mockRejectedValueOnce(transient)
.mockRejectedValueOnce(transient)
.mockRejectedValueOnce(transient)
.mockRejectedValueOnce(transient)
.mockRejectedValueOnce(transient)
.mockResolvedValueOnce('fresh-session');
const exit = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
try {
const run = runOpencode({ startedBy: 'runner' });
await vi.runAllTimersAsync();
await run;
expect(harness.clearOpenCodeSession).toHaveBeenCalledTimes(7);
expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(1, 'source-session');
expect(harness.clearOpenCodeSession).toHaveBeenNthCalledWith(7, 'source-session');
expect(exit).toHaveBeenCalledWith(0);
} finally {
exit.mockRestore();
vi.useRealTimers();
}
});
it('keeps terminal-backed /clear explicit rather than archiving a session with no runner machine', async () => {
await runOpencode({ startedBy: 'terminal' });
const userMessageHandler = harness.session.onUserMessage.mock.calls[0]?.[0] as
((msg: { content: { text: string; attachments?: unknown[] } }, localId?: string) => void)
| undefined;
userMessageHandler!({ content: { text: '/clear' } }, 'local-terminal-clear');
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(harness.session.sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({
message: '/clear is available only for runner-backed OpenCode sessions.'
}));
});
it('queues /compact like a prompt while a remote-mode session is still initializing (ACP backend not ready yet), instead of rejecting it as not-yet-supported', async () => {
// Reproduces a hostile-review finding: compactSupported alone
// conflates "genuinely local mode" with "remote mode, but ACP
+119 -2
View File
@@ -16,6 +16,8 @@ import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
import { listSlashCommands } from '@/modules/common/slashCommands';
import { resolveOpencodeSlashCommand } from './utils/slashCommands';
import { isRetryableConnectionError } from '@/utils/errorUtils';
import { withRetry } from '@/utils/time';
export async function runOpencode(opts: {
startedBy?: 'runner' | 'terminal';
@@ -108,6 +110,14 @@ export async function runOpencode(opts: {
// onLeavingRemote exists to protect, since `mode` stays 'remote'
// throughout it.
let compactSupported = false;
let clearRequested = false;
let clearReplacementSessionId: string | null = null;
// Once a runner-backed /clear is accepted, hold later payloads until the
// transition commits or the queued clear is cancelled. The hub redirects
// their durable rows to the reserved replacement on success.
let clearTransitionLatched = false;
let queuedClearLocalId: string | null = null;
const heldDuringClear: Array<{ message: Parameters<Parameters<typeof session.onUserMessage>[0]>[0]; localId?: string }> = [];
// True from the moment onCompactAvailabilityChange(false) fires (which,
// per onLeavingRemote's contract, only ever happens because remote mode
// is being left — never because remote just started) until this session
@@ -236,6 +246,11 @@ export async function runOpencode(opts: {
};
try {
if (wasCancelled()) return;
if (clearTransitionLatched) {
heldDuringClear.push({ message, localId });
sessionWrapperRef.current?.onThinkingChange(false);
return;
}
let text = message.content.text;
const commands = await listSlashCommands('opencode', workingDirectory).catch(() => []);
if (wasCancelled()) return;
@@ -246,6 +261,30 @@ export async function runOpencode(opts: {
modelReasoningEffort: sessionModelReasoningEffort
});
if (slash.kind === 'clear') {
if (startedBy !== 'runner') {
if (localId) {
session.emitMessagesConsumed([localId], { clearQueuedThinkingGrace: true });
}
session.sendAgentMessage({
type: 'message',
message: '/clear is available only for runner-backed OpenCode sessions.',
id: randomUUID()
});
sessionWrapperRef.current?.onThinkingChange(false);
return;
}
// Latch before enqueueing. userMessageChain serializes later
// messages behind this resolver, including the async
// listSlashCommands race, so they take the rejection path.
clearTransitionLatched = true;
queuedClearLocalId = localId ?? null;
// A clear is isolated but retains its FIFO position:
// older prompts and native /compact work finish first.
messageQueue.pushIsolated('', { ...buildMode(), operation: 'clear' }, localId);
return;
}
if (slash.kind === 'compact') {
// `compactSupported` alone conflates two different
// situations: a genuinely local-mode session (compact
@@ -399,6 +438,19 @@ export async function runOpencode(opts: {
session.onCancelQueuedMessage((localId) => {
const removedFromQueue = messageQueue.cancelByLocalId(localId);
if (removedFromQueue) {
if (queuedClearLocalId === localId) {
queuedClearLocalId = null;
clearTransitionLatched = false;
for (const held of heldDuringClear) {
const formattedText = formatMessageWithAttachments(held.message.content.text, held.message.content.attachments);
messageQueue.push(formattedText, {
permissionMode: currentPermissionMode,
model: sessionModel,
modelReasoningEffort: sessionModelReasoningEffort
}, held.localId);
}
heldDuringClear.length = 0;
}
logger.debug(`[opencode] cancelByLocalId(${localId}): removed from queue`);
return true;
}
@@ -407,6 +459,11 @@ export async function runOpencode(opts: {
logger.debug(`[opencode] cancelByLocalId(${localId}): marked for cancellation before enqueue`);
return true;
}
const heldIndex = heldDuringClear.findIndex((held) => held.localId === localId);
if (heldIndex >= 0) {
heldDuringClear.splice(heldIndex, 1);
return true;
}
// Not in the queue and not in the pre-enqueue preparing window. As
// explained where `cancelledDequeuedLocalIds` is declared above, the
// hub only calls this at all while its own row is still queued, so
@@ -491,6 +548,24 @@ export async function runOpencode(opts: {
compactTeardownInProgress = true;
}
},
onClearRequested: async () => {
clearReplacementSessionId = await withRetry(() => api.reserveOpenCodeClearSession(session.sessionId), {
minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError
});
},
onClearCleanupComplete: async () => {
if (!clearReplacementSessionId) throw new Error('OpenCode clear cleanup completed without a reservation')
await withRetry(() => api.confirmOpenCodeClearCleanup(session.sessionId, clearReplacementSessionId!), {
minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError
});
clearRequested = true;
},
onClearCleanupFailed: async () => {
if (!clearReplacementSessionId) throw new Error('OpenCode clear cleanup failed without a reservation')
await withRetry(() => api.abortOpenCodeClearSession(session.sessionId, clearReplacementSessionId!), {
minDelay: 500, maxDelay: 30_000, shouldRetry: isRetryableConnectionError
});
},
isLocalIdCancelled: (localId) => cancelledDequeuedLocalIds.delete(localId)
});
} catch (error) {
@@ -499,13 +574,55 @@ export async function runOpencode(opts: {
logger.debug('[opencode] Loop error:', error);
} finally {
const localFailure = sessionWrapperRef.current?.localLaunchFailure;
if (localFailure?.exitReason === 'exit') {
if (clearRequested) {
lifecycle.setArchiveReason('Cleared by /clear');
lifecycle.setSessionEndReason('cleared');
} else if (localFailure?.exitReason === 'exit') {
lifecycle.setExitCode(1);
lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`);
lifecycle.setSessionEndReason('error');
} else if (!crashed) {
lifecycle.setSessionEndReason('completed');
}
await lifecycle.cleanupAndExit();
if (!clearRequested) {
await lifecycle.cleanupAndExit();
return;
}
// Keep the source socket open until the hub acknowledges the ordered
// archive/session-end boundary. A transient disconnect must not turn
// the following clear request into a non-retryable active-source 409.
await withRetry(
() => lifecycle.cleanupConfirmed({ timeoutMs: 5_000 }),
{
minDelay: 500,
maxDelay: 30_000,
shouldRetry: isRetryableConnectionError,
onRetry: (error, attempt, nextDelayMs) => {
const message = error instanceof Error ? error.message : String(error);
logger.debug(`[opencode] Session archive confirmation failed (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${message}`);
}
}
);
try {
await withRetry(
() => api.clearOpenCodeSession(session.sessionId),
{
minDelay: 500,
maxDelay: 30_000,
shouldRetry: isRetryableConnectionError,
onRetry: (error, attempt, nextDelayMs) => {
const message = error instanceof Error ? error.message : String(error);
logger.debug(`[opencode] Fresh-session clear handoff failed (attempt ${attempt}), retrying in ${nextDelayMs}ms: ${message}`);
}
}
);
} catch (error) {
// Only non-retryable failures reach here. Retryable transport and
// hub failures retain ownership in the loop above until recovery.
logger.debug('[opencode] Fresh-session clear spawn failed', error);
throw error;
}
process.exit(0);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface OpencodeMode {
// calling `backend.prompt()`, which keeps /compact from "cutting in
// line" ahead of prompts that were already queued when it arrived.
// `undefined` for normal prompts.
operation?: 'compact';
operation?: 'compact' | 'clear';
}
export type OpencodeHookEvent = {
+3 -6
View File
@@ -119,11 +119,8 @@ describe('resolveOpencodeSlashCommand', () => {
}
});
it('returns a not-yet-supported message for /clear', () => {
expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({
kind: 'handled',
message: '/clear is not yet supported in HAPI OpenCode sessions.'
});
it('resolves builtin /clear to the dedicated fresh-session operation', () => {
expect(resolveOpencodeSlashCommand('/clear', state)).toEqual({ kind: 'clear' });
});
it('resolves /compact to a dedicated kind so the launcher can bridge to native compaction asynchronously', () => {
@@ -168,7 +165,7 @@ describe('resolveOpencodeSlashCommand', () => {
expect(help.message).toContain('/plan');
expect(help.message).toContain('/permissions');
expect(help.message).toContain('/compact` — compact (summarize) the OpenCode session context (remote sessions only)');
expect(help.message).toContain('/clear` is not yet supported');
expect(help.message).toContain('/clear` — archive this HAPI session and open a fresh OpenCode session');
}
});
+5 -6
View File
@@ -24,6 +24,9 @@ export type OpencodeSlashResolution =
// synchronous 'handled' shape below. The launcher (runOpencode.ts)
// intercepts this kind and drives that flow itself.
| { kind: 'compact' }
// /clear exits the current runner-backed HAPI process after its FIFO
// predecessors finish, then asks the hub to spawn a fresh OpenCode one.
| { kind: 'clear' }
| {
kind: 'handled';
message: string;
@@ -174,10 +177,7 @@ export function resolveOpencodeSlashCommand(
}
if (command === 'clear') {
return {
kind: 'handled',
message: `/${command} is not yet supported in HAPI OpenCode sessions.`
};
return { kind: 'clear' };
}
if (command === 'init') {
@@ -204,12 +204,11 @@ export function resolveOpencodeSlashCommand(
'- `/default` — return to default permission mode',
'- `/init [extra]` — generate or refresh AGENTS.md for this project',
'- `/compact` — compact (summarize) the OpenCode session context (remote sessions only)',
'- `/clear` — archive this HAPI session and open a fresh OpenCode session',
'',
'Model, reasoning effort, and permission mode have dedicated buttons in the composer. ' +
'You can still type `/model`, `/reasoning`, or `/permissions` if you prefer.',
'',
'`/clear` is not yet supported in HAPI OpenCode sessions.',
'',
'Custom commands from `~/.config/opencode/command` or `.opencode/command` are expanded before sending.'
].join('\n')
};