From 9c04478bf0f9f9439a2e21af957f8ebbb6b8c49e Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Wed, 5 Aug 2026 18:39:07 +0000 Subject: [PATCH] refactor(session): adopt streamer lifecycle for ended vs hold Replace idle+!ptyAttached / completedAt inference with the streamer's additive lifecycle field. sessionPhase and sessionOpensAsHistory now prefer lifecycle so holds (resumable) are not conflated with genuine ends, while older servers keep the legacy idle+detached fallback. Co-authored-by: Ronen Mars --- .../LiveActivity.reconcile.test.tsx | 5 +- .../SessionScreen.endedRedirect.test.tsx | 69 +++++++++++++---- .../unit/lib/sessionPresentation.test.ts | 77 +++++++++++++++++-- __tests__/unit/services/live-activity.test.ts | 19 ++++- app/conversation/[id].tsx | 8 +- app/session/[id].tsx | 29 ++++--- e2e/fixtures/session-detail.json | 1 + e2e/fixtures/session-first-run.json | 1 + e2e/fixtures/session-missing-path.json | 1 + e2e/fixtures/session-waiting-input.json | 1 + e2e/fixtures/sessions.json | 4 + lib/sessionPresentation.ts | 70 +++++++++++++++-- services/live-activity.ts | 25 +++--- types/api.ts | 28 +++++++ 14 files changed, 281 insertions(+), 57 deletions(-) diff --git a/__tests__/integration/components/LiveActivity.reconcile.test.tsx b/__tests__/integration/components/LiveActivity.reconcile.test.tsx index 69870046..ab59ad33 100644 --- a/__tests__/integration/components/LiveActivity.reconcile.test.tsx +++ b/__tests__/integration/components/LiveActivity.reconcile.test.tsx @@ -66,7 +66,10 @@ describe('live activity reconciler', () => { it('ends the activity when the session turns terminal', async () => { await openTurn('srv-1', session()) const handle = factory.start.mock.results[0].value - await reconcile('srv-1', session({ completedAt: '2026-07-25T11:00:00.000Z' })) + await reconcile( + 'srv-1', + session({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }), + ) expect(handle.end).toHaveBeenCalledWith('immediate') }) diff --git a/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx b/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx index 3f845a46..b5bf9b73 100644 --- a/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx +++ b/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx @@ -1,12 +1,13 @@ /** * SessionScreen — ended-session redirect. * - * When a session has ended (ptyAttached:false, status:'idle') the screen should - * open its conversation history if the session HAS a conversation. The trigger - * is boundConversationId ?? conversationId, NOT promptCount — promptCount counts - * only prompts sent through the app, so an adopted / externally-started session - * with real history reads promptCount 0 and must still redirect rather than - * strand on the read-only "Running in another terminal" placeholder. + * When a session has no live process (streamer `lifecycle` completed/failed/ + * resumable, or the legacy idle+detached pair on older servers) the screen + * should open its conversation history if the session HAS a conversation. + * The trigger is boundConversationId ?? conversationId, NOT promptCount — + * promptCount counts only prompts sent through the app, so an adopted / + * externally-started session with real history reads promptCount 0 and must + * still redirect rather than strand on the read-only placeholder. */ import React from 'react' import { render } from '@testing-library/react-native' @@ -113,27 +114,57 @@ describe('SessionScreen — ended-session redirect', () => { mockParams = { id: SESSION_UUID, server: 'srv1' } }) - it('redirects to the conversation when the ended session has a conversationId, even with promptCount 0', async () => { - mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 }) + it('redirects when lifecycle is completed and a conversationId is present', async () => { + mockSessionData = endedSession({ + conversationId: SESSION_UUID, + promptCount: 0, + lifecycle: 'completed', + }) + await render(, { wrapper: createWrapper() }) + expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`) + }) + + it('redirects a held (resumable) session to conversation history for resume', async () => { + mockSessionData = endedSession({ + conversationId: SESSION_UUID, + lifecycle: 'resumable', + completedAt: '2026-08-01T00:00:00Z', + }) await render(, { wrapper: createWrapper() }) expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`) }) it('redirects via boundConversationId (codex) even without conversationId', async () => { - mockSessionData = endedSession({ boundConversationId: SESSION_UUID, conversationId: null, promptCount: 0 }) + mockSessionData = endedSession({ + boundConversationId: SESSION_UUID, + conversationId: null, + promptCount: 0, + lifecycle: 'completed', + }) await render(, { wrapper: createWrapper() }) expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`) }) it('does NOT redirect when the ended session has no conversation at all', async () => { - mockSessionData = endedSession({ conversationId: null, boundConversationId: null, promptCount: 0 }) + mockSessionData = endedSession({ + conversationId: null, + boundConversationId: null, + promptCount: 0, + lifecycle: 'completed', + }) await render(, { wrapper: createWrapper() }) expect(mockReplace).not.toHaveBeenCalled() }) - // A just-resumed session reads idle + detached until its PTY attaches, which - // looks identical to an ended one. Redirecting then bounced the user straight - // back to the conversation they had just tapped Resume on. + it('falls back to idle+detached redirect when lifecycle is absent', async () => { + mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 }) + await render(, { wrapper: createWrapper() }) + expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`) + }) + + // A just-resumed session on an older server (no lifecycle) can read + // idle+detached until its PTY attaches. Redirecting then bounced the user + // straight back to the conversation they had just tapped Resume on. it('does NOT redirect a starting session that has not attached its PTY yet', async () => { mockParams = { id: SESSION_UUID, server: 'srv1', starting: '1' } mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 }) @@ -141,12 +172,24 @@ describe('SessionScreen — ended-session redirect', () => { expect(mockReplace).not.toHaveBeenCalled() }) + it('does NOT redirect an attached live session', async () => { + mockSessionData = endedSession({ + conversationId: SESSION_UUID, + ptyAttached: true, + status: 'waiting_input', + lifecycle: 'attached', + }) + await render(, { wrapper: createWrapper() }) + expect(mockReplace).not.toHaveBeenCalled() + }) + it('drops the starting screen once the PTY is attached', async () => { mockParams = { id: SESSION_UUID, server: 'srv1', starting: '1' } mockSessionData = endedSession({ conversationId: SESSION_UUID, ptyAttached: true, status: 'waiting_input', + lifecycle: 'attached', }) const { queryByText } = await render(, { wrapper: createWrapper() }) expect(queryByText('Starting session…')).toBeNull() diff --git a/__tests__/unit/lib/sessionPresentation.test.ts b/__tests__/unit/lib/sessionPresentation.test.ts index 8f680d9b..dcfbcf83 100644 --- a/__tests__/unit/lib/sessionPresentation.test.ts +++ b/__tests__/unit/lib/sessionPresentation.test.ts @@ -1,6 +1,7 @@ import { deriveConversationPresentation, deriveSessionPresentation, + sessionOpensAsHistory, sessionPhase, } from '@/lib/sessionPresentation' @@ -120,17 +121,47 @@ describe('deriveSessionPresentation', () => { capabilities: { canResume: true, canSendInput: false, isObserveOnly: true }, }) }) + + it('prefers lifecycle completed/failed over status idle', () => { + expect( + deriveSessionPresentation( + base({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }), + ), + ).toMatchObject({ kind: 'completed', live: false }) + expect( + deriveSessionPresentation( + base({ + status: 'idle', + ptyAttached: false, + lifecycle: 'failed', + failureReason: 'boom', + }), + ).labelKey, + ).toBe('status.failed') + }) + + it('treats lifecycle resumable as historical (resume, observe-only)', () => { + expect( + deriveSessionPresentation( + base({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }), + ), + ).toMatchObject({ + kind: 'historical', + live: false, + capabilities: { canResume: true, isObserveOnly: true }, + }) + }) }) describe('sessionPhase', () => { - it('reads an idle, detached session with no completedAt as starting, not ended', () => { + it('reads an idle, detached session with no lifecycle as starting, not ended', () => { expect(sessionPhase({ status: 'idle', ptyAttached: false })).toBe('starting') }) - it('reads an idle, detached session with completedAt as ended', () => { + it('does not treat completedAt alone as ended (holds stamp it too)', () => { expect( sessionPhase({ status: 'idle', ptyAttached: false, completedAt: '2026-08-01T00:00:00Z' }), - ).toBe('ended') + ).toBe('starting') }) it('reads an idle, attached session as live', () => { @@ -145,15 +176,47 @@ describe('sessionPhase', () => { expect(sessionPhase({ status: 'running', ptyAttached: true })).toBe('live') }) - it('gives completedAt precedence over ptyAttached still reading true', () => { - // The server can report a stale ptyAttached: true on the same payload - // that finally sets completedAt; completedAt is the authoritative signal. + it('maps streamer lifecycle onto the coarse phase', () => { + expect(sessionPhase({ status: 'running', ptyAttached: true, lifecycle: 'attached' })).toBe( + 'live', + ) + expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' })).toBe( + 'resumable', + ) + expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'completed' })).toBe( + 'ended', + ) + expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'failed' })).toBe( + 'ended', + ) + }) + + it('gives lifecycle precedence over a stale ptyAttached: true', () => { expect( - sessionPhase({ status: 'idle', ptyAttached: true, completedAt: '2026-08-01T00:00:00Z' }), + sessionPhase({ status: 'idle', ptyAttached: true, lifecycle: 'completed' }), ).toBe('ended') }) }) +describe('sessionOpensAsHistory', () => { + it('opens history for ended and resumable lifecycles', () => { + expect( + sessionOpensAsHistory({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }), + ).toBe(true) + expect( + sessionOpensAsHistory({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }), + ).toBe(true) + expect( + sessionOpensAsHistory({ status: 'running', ptyAttached: true, lifecycle: 'attached' }), + ).toBe(false) + }) + + it('falls back to idle+detached when lifecycle is absent', () => { + expect(sessionOpensAsHistory({ status: 'idle', ptyAttached: false })).toBe(true) + expect(sessionOpensAsHistory({ status: 'running', ptyAttached: false })).toBe(false) + }) +}) + describe('deriveConversationPresentation', () => { it('returns unavailable when resume is blocked', () => { expect( diff --git a/__tests__/unit/services/live-activity.test.ts b/__tests__/unit/services/live-activity.test.ts index 95991864..4c569b1e 100644 --- a/__tests__/unit/services/live-activity.test.ts +++ b/__tests__/unit/services/live-activity.test.ts @@ -40,16 +40,26 @@ function makeState(overrides: Partial = {}): LiveSessionState describe('isTerminal', () => { it('treats each end-of-life signal independently', () => { expect(isTerminal(makeSession({ processLiveness: 'gone' }))).toBe(true) - expect(isTerminal(makeSession({ completedAt: '2026-07-25T11:00:00.000Z' }))).toBe(true) + expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }))).toBe( + true, + ) + expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }))).toBe( + true, + ) expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false }))).toBe(true) }) + it('does not treat completedAt alone as terminal (holds stamp it too)', () => { + expect(isTerminal(makeSession({ completedAt: '2026-07-25T11:00:00.000Z' }))).toBe(false) + }) + it('does not treat a live session as terminal', () => { expect(isTerminal(makeSession())).toBe(false) expect(isTerminal(makeSession({ status: 'waiting_input' }))).toBe(false) // idle but still attached is a lull, not an ending expect(isTerminal(makeSession({ status: 'idle', ptyAttached: true }))).toBe(false) expect(isTerminal(makeSession({ processLiveness: 'unknown' }))).toBe(false) + expect(isTerminal(makeSession({ lifecycle: 'attached' }))).toBe(false) }) }) @@ -63,7 +73,12 @@ describe('toLiveState', () => { it('returns null for every terminal signal', () => { expect(toLiveState(makeSession({ processLiveness: 'gone' }), 'srv-1')).toBeNull() - expect(toLiveState(makeSession({ completedAt: STARTED_AT }), 'srv-1')).toBeNull() + expect( + toLiveState( + makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }), + 'srv-1', + ), + ).toBeNull() expect(toLiveState(makeSession({ status: 'idle', ptyAttached: false }), 'srv-1')).toBeNull() }) diff --git a/app/conversation/[id].tsx b/app/conversation/[id].tsx index 57780b53..c54ae203 100644 --- a/app/conversation/[id].tsx +++ b/app/conversation/[id].tsx @@ -308,8 +308,12 @@ export default function ConversationDetailScreen() { // Only redirect to the live session view when the session is actually // attached. An idle, detached session bounces straight back here, so - // redirecting on mere existence is an infinite loop. - const isSessionLive = liveSession?.ptyAttached === true + // redirecting on mere existence is an infinite loop. Prefer streamer + // `lifecycle` when present; fall back to ptyAttached on older servers. + const isSessionLive = + liveSession?.lifecycle != null + ? liveSession.lifecycle === 'attached' + : liveSession?.ptyAttached === true useEffect(() => { if (isConvNotFound && isSessionLive) { router.replace(`/session/${id}?server=${serverId}`) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 4002c6ca..32386a1e 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -16,7 +16,7 @@ import { useLocalSearchParams, useRouter, useNavigation } from 'expo-router' import * as Clipboard from 'expo-clipboard' import { CopySimple, InfoIcon, PencilSimple, Star, StopCircle, GitDiff, Warning } from 'phosphor-react-native' import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge' -import { deriveSessionPresentation } from '@/lib/sessionPresentation' +import { deriveSessionPresentation, sessionOpensAsHistory } from '@/lib/sessionPresentation' import { useSessionDetail } from '@/hooks/useSession' import { useSessionActions } from '@/hooks/useSessionActions' import { useTerminalStream } from '@/hooks/useTerminalStream' @@ -592,25 +592,30 @@ export default function SessionDetailScreen() { } useEffect(() => { - // Redirect an ended session to its conversation history. Gate on "has a - // conversation" (boundConversationId ?? conversationId), NOT promptCount: - // promptCount counts only prompts sent through the app, so an adopted / - // externally-started session that has real history reads promptCount 0 and - // would otherwise strand on the read-only placeholder. + // Redirect when there is no live process to attach to and the session has + // conversation history. Prefer streamer `lifecycle` (via sessionOpensAsHistory) + // so a hold (`resumable`) is not conflated with a genuine end — both open + // history (resume lives there), but `completedAt` / idle+detached alone + // cannot tell them apart. // - // Skip while starting: a session that is spawning also reads - // idle + detached, and redirecting then bounces the user back to the very - // conversation they just resumed from. + // Gate on "has a conversation" (boundConversationId ?? conversationId), NOT + // promptCount: promptCount counts only prompts sent through the app, so an + // adopted / externally-started session that has real history reads + // promptCount 0 and would otherwise strand on the read-only placeholder. + // + // Skip while starting: older servers omit `lifecycle`, and a spawning + // session can still read idle+detached on those builds. if (isPending) return const hasConversation = !!(session?.boundConversationId ?? session?.conversationId) - if (session?.ptyAttached === false && - session?.status === 'idle' && + if ( + session != null && + sessionOpensAsHistory(session) && hasConversation && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id ?? '') ) { router.replace(`/conversation/${id}?server=${serverId}`) } - }, [isPending, session?.ptyAttached, session?.status, session?.boundConversationId, session?.conversationId, id, serverId, router]) + }, [isPending, session, id, serverId, router]) // Codex bind race: before boundConversationId arrives, history may 404 on the // placeholder id. When the streamer first publishes the rollout UUID, switch diff --git a/e2e/fixtures/session-detail.json b/e2e/fixtures/session-detail.json index de4a8bba..376a7dc7 100644 --- a/e2e/fixtures/session-detail.json +++ b/e2e/fixtures/session-detail.json @@ -2,6 +2,7 @@ "id": "session-abc123", "status": "running", "ptyAttached": true, + "lifecycle": "attached", "projectPath": "/home/user/my-project", "projectName": "my-project", "branch": "main", diff --git a/e2e/fixtures/session-first-run.json b/e2e/fixtures/session-first-run.json index 0ce46b81..7194608f 100644 --- a/e2e/fixtures/session-first-run.json +++ b/e2e/fixtures/session-first-run.json @@ -2,6 +2,7 @@ "id": "session-first-run", "status": "running", "ptyAttached": true, + "lifecycle": "attached", "projectPath": "/home/user/my-project", "projectName": "my-project", "branch": "main", diff --git a/e2e/fixtures/session-missing-path.json b/e2e/fixtures/session-missing-path.json index b4504c2f..00bddab9 100644 --- a/e2e/fixtures/session-missing-path.json +++ b/e2e/fixtures/session-missing-path.json @@ -2,6 +2,7 @@ "id": "session-missing-path", "status": "idle", "ptyAttached": false, + "lifecycle": "failed", "projectPath": "/Users/ronenmars/Desktop/dev/personal/new/ai-tools", "projectName": "ai-tools", "branch": "main", diff --git a/e2e/fixtures/session-waiting-input.json b/e2e/fixtures/session-waiting-input.json index 97be5c81..eefafb80 100644 --- a/e2e/fixtures/session-waiting-input.json +++ b/e2e/fixtures/session-waiting-input.json @@ -2,6 +2,7 @@ "id": "session-waiting", "status": "waiting_input", "ptyAttached": true, + "lifecycle": "attached", "projectPath": "/home/user/my-project", "projectName": "my-project", "branch": "main", diff --git a/e2e/fixtures/sessions.json b/e2e/fixtures/sessions.json index 168d0881..09fe73e5 100644 --- a/e2e/fixtures/sessions.json +++ b/e2e/fixtures/sessions.json @@ -5,6 +5,7 @@ "serverLabel": "Local Dev", "status": "running", "ptyAttached": true, + "lifecycle": "attached", "projectPath": "/home/user/my-project", "projectName": "my-project", "branch": "main", @@ -19,6 +20,7 @@ "serverLabel": "Local Dev", "status": "waiting_input", "ptyAttached": true, + "lifecycle": "attached", "projectPath": "/home/user/my-project", "projectName": "my-project", "branch": "feature/auth", @@ -33,6 +35,7 @@ "serverLabel": "Local Dev", "status": "idle", "ptyAttached": false, + "lifecycle": "completed", "projectPath": "/home/user/my-project", "projectName": "my-project", "branch": "fix/login", @@ -47,6 +50,7 @@ "serverLabel": "Remote Dev", "status": "running", "ptyAttached": true, + "lifecycle": "attached", "projectPath": "/home/user/other-project", "projectName": "other-project", "branch": "main", diff --git a/lib/sessionPresentation.ts b/lib/sessionPresentation.ts index 66b8d7d9..d7d0346d 100644 --- a/lib/sessionPresentation.ts +++ b/lib/sessionPresentation.ts @@ -1,4 +1,4 @@ -import type { UnavailableReason } from '@/types/api' +import type { SessionLifecycle, UnavailableReason } from '@/types/api' /** * Canonical session/conversation presentation kinds for hub rows, badges, and @@ -67,6 +67,8 @@ export type SessionPresentationInput = { resumedFromConversationId?: string | null completedAt?: string failureReason?: string + /** Process-lifetime axis from the streamer. Additive; older servers omit it. */ + lifecycle?: SessionLifecycle } export interface ConversationPresentationInput { @@ -149,6 +151,25 @@ export function deriveSessionPresentation( } } + // Prefer streamer `lifecycle` when present — `completedAt` is stamped on both + // a real exit and a hold, so it cannot separate these two branches. + if (session.lifecycle === 'completed' || session.lifecycle === 'failed') { + return { + kind: 'completed', + labelKey: + session.lifecycle === 'failed' || session.failureReason + ? 'status.failed' + : 'status.completed', + live: false, + externalLive: false, + colorToken: + session.lifecycle === 'failed' || session.failureReason ? 'failed' : 'completed', + confidence, + activityAt, + capabilities: IDLE_MANAGED_CAPS, + } + } + if (status === 'completed' || status === 'failed') { if (status === 'failed' || session.failureReason) { return { @@ -200,7 +221,14 @@ export function deriveSessionPresentation( } } - if (session.ownership === 'historical' || (external && !externalAlive)) { + // Held / rehydrated / historical: no live process, resume from conversation. + // `lifecycle: "resumable"` is the streamer's signal for a hold (and for + // rehydrated stubs); do not conflate it with `completed` / `failed`. + if ( + session.lifecycle === 'resumable' || + session.ownership === 'historical' || + (external && !externalAlive) + ) { // A rehydrated stub's `status` had to flatten to `idle`; `interruptedStatus` // is the streamer telling us what it was actually doing when it stopped it. // Label only — kind, colour and capabilities stay put, so the row is still @@ -295,17 +323,43 @@ export function isPresentationLive(session: SessionPresentationInput): boolean { return deriveSessionPresentation(session).live } -export type SessionPhase = 'starting' | 'live' | 'ended' +export type SessionPhase = 'starting' | 'live' | 'ended' | 'resumable' /** + * Coarse client phase derived from the streamer's `lifecycle` when present. + * * `ptyAttached === false && status === 'idle'` is ambiguous on its own: it is - * equally true of a session that has not started yet (`/api/sessions/start` - * and `/api/sessions/resume` both respond before the PTY attaches) and one - * that has already ended. `completedAt` is the field the server only sets on - * a genuine end, so it — not the idle/detached pair — is the discriminator. + * equally true of a held (still-resumable) session and one that has genuinely + * ended, and `completedAt` is stamped on both. Prefer `lifecycle`. + * + * Without `lifecycle`, fall back to attach/status only — never to `completedAt`. */ export function sessionPhase(s: SessionPresentationInput): SessionPhase { - if (s.completedAt) return 'ended' + switch (s.lifecycle) { + case 'attached': + case 'detached': + case 'orphaned': + return 'live' + case 'resumable': + return 'resumable' + case 'completed': + case 'failed': + return 'ended' + default: + break + } if (s.ptyAttached) return 'live' return s.status === 'idle' ? 'starting' : 'live' } + +/** + * Conversation history is the right surface when there is no live process to + * attach to — both a genuine end and a hold/rehydrate (resume lives there). + */ +export function sessionOpensAsHistory(s: SessionPresentationInput): boolean { + if (s.lifecycle != null) { + const phase = sessionPhase(s) + return phase === 'ended' || phase === 'resumable' + } + return s.ptyAttached === false && s.status === 'idle' +} diff --git a/services/live-activity.ts b/services/live-activity.ts index 14c3fae3..8dca8beb 100644 --- a/services/live-activity.ts +++ b/services/live-activity.ts @@ -1,7 +1,7 @@ import type { LiveActivity } from 'expo-widgets' import SessionLiveActivity from '@/widgets/SessionLiveActivity' -import { sessionPhase } from '@/lib/sessionPresentation' +import { sessionOpensAsHistory, sessionPhase } from '@/lib/sessionPresentation' import type { Session } from '@/types/api' import { LAST_OUTPUT_MAX_CHARS, @@ -17,19 +17,20 @@ export function liveActivityKey(serverId: string, sessionId: string): string { } /** - * A session is over when any of three signals fire. Managed servers set - * `completedAt`; external ones report `processLiveness`; older servers do - * neither, leaving the legacy idle-without-a-PTY heuristic as the only tell. + * A session should leave the Live Activity surface when there is no process + * left to mirror. Prefer streamer `lifecycle` (ended or resumable/held); + * `completedAt` alone is not enough — holds stamp it too. Older servers omit + * `lifecycle`, leaving the idle-without-a-PTY heuristic as the only tell — + * safe here because a Live Activity only ever exists for a session that + * already went live. */ export function isTerminal(session: Session): boolean { - return ( - session.processLiveness === 'gone' || - sessionPhase(session) === 'ended' || - // A live activity only ever exists for a session that already went live, - // so unlike a cold landing this can't be a not-yet-started session — - // idle+detached here means it ended on a server too old to set completedAt. - (session.status === 'idle' && !session.ptyAttached) - ) + if (session.processLiveness === 'gone') return true + if (session.lifecycle != null) { + const phase = sessionPhase(session) + return phase === 'ended' || phase === 'resumable' + } + return sessionOpensAsHistory(session) } function truncateOutput(raw: string): string { diff --git a/types/api.ts b/types/api.ts index af124014..92e9ed75 100644 --- a/types/api.ts +++ b/types/api.ts @@ -3,6 +3,20 @@ import type { DeviceCapability } from '@/types/devices' export type SessionStatus = 'running' | 'waiting_input' | 'idle' +/** + * Process-lifetime axis from the streamer, orthogonal to `status`. + * Additive; older servers omit it. Prefer this over inferring end/hold from + * `ptyAttached` + `status` or from `completedAt` (which is stamped on both a + * real exit and a hold). + */ +export type SessionLifecycle = + | 'attached' + | 'detached' + | 'orphaned' + | 'resumable' + | 'completed' + | 'failed' + export interface Session { id: string provider?: ProviderName @@ -41,7 +55,21 @@ export interface Session { elapsedMs: number promptCount: number startedAt: string + /** + * ISO timestamp when the streamer recorded an end-or-hold. Not a reliable + * "session ended" signal on its own — `putOnHold` stamps it too. Prefer + * `lifecycle`. Additive; older servers omit it. + */ completedAt?: string + /** + * Process-lifetime axis from the streamer. Additive; older servers omit it. + * When present, prefer this over `idle && !ptyAttached` for ended/hold/live. + */ + lifecycle?: SessionLifecycle + /** How `lifecycle` was determined. Additive; older servers omit it. */ + lifecycleSource?: 'spawn' | 'exit' | 'probe' | 'reconcile' + /** ISO timestamp when `lifecycle` last changed. Additive; older servers omit it. */ + lifecycleUpdatedAt?: string failureReason?: string /** Set when this session was started via `/api/sessions/resume`. */ resumedFromConversationId?: string | null