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
+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