diff --git a/packages/happy-app/sources/-session/SessionView.tsx b/packages/happy-app/sources/-session/SessionView.tsx index 82c1f77de6..b5a3c4c143 100644 --- a/packages/happy-app/sources/-session/SessionView.tsx +++ b/packages/happy-app/sources/-session/SessionView.tsx @@ -858,7 +858,9 @@ export function SessionViewLoaded({ const handleAbort = React.useCallback(() => { // Stop cancels only the active turn. Permission, model, and effort are // session choices and must remain sticky for the next message. - sessionAbort(sessionId); + // Best-effort: an orphaned session has no RPC target to abort (#1739), + // and there is nothing to cancel when the process is already gone. + sessionAbort(sessionId).catch(() => {}); }, [sessionId]); const handleFileViewerPress = React.useCallback(() => { diff --git a/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx b/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx index 87b7a1bf72..05ad8bfdec 100644 --- a/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx +++ b/packages/happy-app/sources/components/ActiveSessionsGroupCompact.tsx @@ -16,7 +16,7 @@ import { useHappyAction } from '@/hooks/useHappyAction'; import { HappyError } from '@/utils/errors'; import { SessionActionsAnchor, SessionActionsPopover } from './SessionActionsPopover'; import { useSessionActionAlert } from '@/hooks/useSessionQuickActions'; -import { sessionKill } from '@/sync/ops'; +import { sessionKillOrArchive } from '@/sync/ops'; import { isWorktreePath, getRepoPath, getWorktreeName } from '@/utils/worktree'; import { useNewSessionDraft } from '@/hooks/useNewSessionDraft'; import { useRouter } from 'expo-router'; @@ -237,9 +237,12 @@ export const CompactSessionRow = React.memo(({ session, selected, showBorder }: const [actionsAnchor, setActionsAnchor] = React.useState(null); const [archivingSession, performArchive] = useHappyAction(async () => { - const result = await sessionKill(session.id); - if (!result.success) { - throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false); + // Kill the CLI process; if it's already dead, force-archive via the + // server so an orphaned session can't get stuck active (#1739). + try { + await sessionKillOrArchive(session.id); + } catch (error) { + throw new HappyError(error instanceof Error ? error.message : t('sessionInfo.failedToArchiveSession'), false); } }); diff --git a/packages/happy-app/sources/components/FlatSessionRow.tsx b/packages/happy-app/sources/components/FlatSessionRow.tsx index 71b4b19ab9..5c12733f18 100644 --- a/packages/happy-app/sources/components/FlatSessionRow.tsx +++ b/packages/happy-app/sources/components/FlatSessionRow.tsx @@ -13,7 +13,7 @@ import { useNavigateToSession } from '@/hooks/useNavigateToSession'; import { useSessionActionAlert } from '@/hooks/useSessionQuickActions'; import { useHappyAction } from '@/hooks/useHappyAction'; import { HappyError } from '@/utils/errors'; -import { sessionKill } from '@/sync/ops'; +import { sessionKillOrArchive } from '@/sync/ops'; import type { FlatSessionRowData } from '@/utils/flatSessionList'; import { formatSessionListTimestamp } from '@/utils/sessionListTimestamp'; import type { Theme } from '@/theme'; @@ -106,9 +106,12 @@ export const FlatSessionRow = React.memo(({ row, selected, showBorder, archived ); const [archiving, performArchive] = useHappyAction(async () => { - const result = await sessionKill(session.id); - if (!result.success) { - throw new HappyError(result.message || t('sessionInfo.failedToArchiveSession'), false); + // Kill the CLI process; if it's already dead, force-archive via the + // server so an orphaned session can't get stuck active (#1739). + try { + await sessionKillOrArchive(session.id); + } catch (error) { + throw new HappyError(error instanceof Error ? error.message : t('sessionInfo.failedToArchiveSession'), false); } }); diff --git a/packages/happy-app/sources/sync/ops.killOrArchive.test.ts b/packages/happy-app/sources/sync/ops.killOrArchive.test.ts new file mode 100644 index 0000000000..b8fdd9ce4c --- /dev/null +++ b/packages/happy-app/sources/sync/ops.killOrArchive.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { sessionRPC, request } = vi.hoisted(() => ({ + sessionRPC: vi.fn(), + request: vi.fn(), +})); + +vi.mock('./apiSocket', () => ({ apiSocket: { sessionRPC, request } })); +vi.mock('./sync', () => ({ sync: {} })); +vi.mock('./storage', () => ({ storage: {} })); + +describe('sessionKillOrArchive', () => { + beforeEach(() => { + sessionRPC.mockReset(); + request.mockReset(); + }); + + it('kills the CLI process and never archives when the kill succeeds', async () => { + sessionRPC.mockResolvedValue({ success: true, message: 'Killing happy-cli process' }); + + const { sessionKillOrArchive } = await import('./ops'); + await expect(sessionKillOrArchive('session-1')).resolves.toBeUndefined(); + + expect(sessionRPC).toHaveBeenCalledWith('session-1', 'killSession', {}); + expect(request).not.toHaveBeenCalled(); + }); + + it('falls back to the server-side archive when the CLI process is unreachable', async () => { + sessionRPC.mockRejectedValue(new Error('RPC method not available')); + request.mockResolvedValue({ ok: true, status: 200 }); + + const { sessionKillOrArchive } = await import('./ops'); + await expect(sessionKillOrArchive('session-1')).resolves.toBeUndefined(); + + expect(request).toHaveBeenCalledWith('/v1/sessions/session-1/archive', { method: 'POST' }); + }); + + it('throws when both the kill RPC and the server archive fail', async () => { + sessionRPC.mockResolvedValue({ success: false, message: 'The computer did not respond' }); + request.mockResolvedValue({ ok: false, status: 503 }); + + const { sessionKillOrArchive } = await import('./ops'); + await expect(sessionKillOrArchive('session-1')).rejects.toThrow('Server error: 503'); + }); + + it('throws the kill error path when the archive request itself rejects', async () => { + sessionRPC.mockResolvedValue({ success: false, message: 'The computer did not respond' }); + request.mockRejectedValue(new Error('Network down')); + + const { sessionKillOrArchive } = await import('./ops'); + await expect(sessionKillOrArchive('session-1')).rejects.toThrow('Network down'); + }); +}); diff --git a/packages/happy-app/sources/sync/ops.ts b/packages/happy-app/sources/sync/ops.ts index f206da6d03..1099f87968 100644 --- a/packages/happy-app/sources/sync/ops.ts +++ b/packages/happy-app/sources/sync/ops.ts @@ -1074,6 +1074,26 @@ export async function sessionArchive(sessionId: string): Promise<{ success: bool } } +/** + * Archive a session, stopping its CLI process first. When the CLI process is + * unreachable (killed, crashed, or never registered an RPC handler) the kill + * RPC fails, so fall back to the server-side force archive — otherwise an + * orphaned session would stay active with no way to archive it (#1739). + * + * Throws only when both the kill RPC and the server archive fail, so callers + * can surface that as a user-facing error. + */ +export async function sessionKillOrArchive(sessionId: string): Promise { + const killResult = await sessionKill(sessionId); + if (killResult.success) { + return; + } + const archiveResult = await sessionArchive(sessionId); + if (!archiveResult.success) { + throw new Error(archiveResult.message || 'Failed to archive session'); + } +} + /** * Permanently delete a session from the server * This will remove the session and all its associated data (messages, usage reports, access keys)