fix(pi): resume archived sessions safely (#1308)

* fix(pi): resume archived sessions safely

* fix(pi): harden native resume startup

* fix(pi): harden resume termination evidence

* fix(runner): persist resume process evidence

* fix(runner): track resume process generations

* fix(runner): verify full session tree shutdown

* fix(pi): block pre-mapping resume dedup
This commit is contained in:
KorenKrita
2026-08-02 21:15:52 +08:00
committed by GitHub
parent fb6f697555
commit abf9cb02a5
26 changed files with 1546 additions and 137 deletions
@@ -7,12 +7,14 @@ describe('parseRemoteAgentCommandOptions', () => {
expect(parseRemoteAgentCommandOptions([
'--started-by', 'runner',
'--hapi-starting-mode', 'remote',
'--existing-session-id', 'hapi-session-1',
'--permission-mode', 'yolo',
'--resume', 'session-1',
'--model', 'model-a'
], GEMINI_PERMISSION_MODES)).toEqual({
startedBy: 'runner',
startingMode: 'remote',
existingSessionId: 'hapi-session-1',
permissionMode: 'yolo',
resumeSessionId: 'session-1',
model: 'model-a'
@@ -67,6 +69,7 @@ describe('parseRemoteAgentCommandOptions', () => {
expect(() => parseRemoteAgentCommandOptions(['--resume'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --resume value')
expect(() => parseRemoteAgentCommandOptions(['--model'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model value')
expect(() => parseRemoteAgentCommandOptions(['--model-reasoning-effort'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --model-reasoning-effort value')
expect(() => parseRemoteAgentCommandOptions(['--existing-session-id'], OPENCODE_PERMISSION_MODES)).toThrow('Missing --existing-session-id value')
})
it('accepts OpenCode-native -s / --session as resume aliases', () => {
@@ -190,6 +193,7 @@ describe('parseRemoteAgentCommandOptions — pi flavor', () => {
'--hapi-starting-mode', 'remote',
'--model', 'claude-sonnet-4-5',
'--session-id', 'pi-sess-full',
'--existing-session-id', 'hapi-session-pi-full',
],
ALLOWED
)
@@ -198,6 +202,7 @@ describe('parseRemoteAgentCommandOptions — pi flavor', () => {
startingMode: 'remote',
model: 'claude-sonnet-4-5',
resumeSessionId: 'pi-sess-full',
existingSessionId: 'hapi-session-pi-full',
})
})
})
+7
View File
@@ -8,6 +8,7 @@ export type RemoteAgentCommandOptions<TPermissionMode extends PermissionMode> =
effort?: string
modelReasoningEffort?: string
resumeSessionId?: string
existingSessionId?: string
}
export function parseRemoteAgentCommandOptions<TPermissionMode extends PermissionMode>(
@@ -28,6 +29,12 @@ export function parseRemoteAgentCommandOptions<TPermissionMode extends Permissio
} else {
throw new Error('Invalid --hapi-starting-mode (expected local or remote)')
}
} 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 === '--permission-mode') {
const mode = args[++i]
if (!mode || !(allowedPermissionModes as readonly string[]).includes(mode)) {
+23 -1
View File
@@ -16,7 +16,7 @@ const {
checkIfRunnerRunningAndCleanupStaleStateMock: vi.fn(),
listRunnerSessionsMock: vi.fn(async () => []),
stopRunnerMock: vi.fn(async () => {}),
stopRunnerSessionMock: vi.fn(async () => true),
stopRunnerSessionMock: vi.fn(async (_sessionId: string): Promise<'stopped' | 'already_gone' | 'still_alive'> => 'stopped'),
spawnHappyCLIMock: vi.fn(() => ({ unref: vi.fn() })),
startRunnerMock: vi.fn(async () => {}),
getLatestRunnerLogMock: vi.fn(async () => null),
@@ -127,3 +127,25 @@ describe('runnerCommand start', () => {
}
})
})
describe('runnerCommand stop-session', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
['stopped', 'Session stopped'],
['already_gone', 'Session was already stopped'],
['still_alive', 'Failed to stop session'],
] as const)('renders the %s runner result', async (status, expected) => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
stopRunnerSessionMock.mockResolvedValueOnce(status)
try {
await runnerCommand.run(createContext(['stop-session', 'session-1']))
expect(stopRunnerSessionMock).toHaveBeenCalledWith('session-1')
expect(consoleLogSpy).toHaveBeenCalledWith(expected)
} finally {
consoleLogSpy.mockRestore()
}
})
})
+8 -2
View File
@@ -113,8 +113,14 @@ export const runnerCommand: CommandDefinition = {
}
try {
const success = await stopRunnerSession(sessionId)
console.log(success ? 'Session stopped' : 'Failed to stop session')
const status = await stopRunnerSession(sessionId)
if (status === 'stopped') {
console.log('Session stopped')
} else if (status === 'already_gone') {
console.log('Session was already stopped')
} else {
console.log('Failed to stop session')
}
} catch {
console.log('No runner running')
}