mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-09 01:51:39 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user