Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/happy-app/sources/-session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -237,9 +237,12 @@ export const CompactSessionRow = React.memo(({ session, selected, showBorder }:
const [actionsAnchor, setActionsAnchor] = React.useState<SessionActionsAnchor | null>(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);
}
});

Expand Down
11 changes: 7 additions & 4 deletions packages/happy-app/sources/components/FlatSessionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
});

Expand Down
53 changes: 53 additions & 0 deletions packages/happy-app/sources/sync/ops.killOrArchive.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
20 changes: 20 additions & 0 deletions packages/happy-app/sources/sync/ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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)
Expand Down