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:
Junmo Kim
2026-08-03 18:06:39 +08:00
committed by GitHub
parent 1b8cc334ea
commit f44c9ff3e6
41 changed files with 3209 additions and 73 deletions
+6 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { SessionSummary } from '@/types/api'
import type { Session } from '@/types/api'
import { isGlobalScopedMessageStreamEvent, isRenderIrrelevantPatch, isRenderIrrelevantSessionPatch } from './useSSE'
import { isGlobalScopedMessageStreamEvent, isRenderIrrelevantPatch, isRenderIrrelevantSessionPatch, shouldInvalidateSessionListForEvent } from './useSSE'
function makeSummary(overrides: Partial<SessionSummary> = {}): SessionSummary {
return {
@@ -25,6 +25,11 @@ function makeSummary(overrides: Partial<SessionSummary> = {}): SessionSummary {
}
describe('useSSE scope handling', () => {
it('invalidates the global session list when message ownership changes', () => {
expect(shouldInvalidateSessionListForEvent('global', 'messages-invalidated')).toBe(true)
expect(shouldInvalidateSessionListForEvent('full', 'messages-invalidated')).toBe(false)
})
it('treats message stream events as global-scoped skips', () => {
expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true)
expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true)
+8
View File
@@ -34,6 +34,10 @@ export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: Syn
return scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(eventType)
}
export function shouldInvalidateSessionListForEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean {
return scope === 'global' && eventType === 'messages-invalidated'
}
type VisibilityState = 'visible' | 'hidden'
type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
@@ -528,6 +532,10 @@ export function useSSE(options: {
return
}
if (shouldInvalidateSessionListForEvent(scope, event.type)) {
queueSessionListInvalidation()
}
if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) {
if (event.type === 'message-received' && event.message.scheduledAt != null) {
queueSessionListInvalidation()
+24
View File
@@ -46,6 +46,7 @@ import { inactiveSessionCanResume } from '@/lib/sessionResume'
import { markSessionSeen } from '@/lib/sessionLastSeen'
import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle'
import { clearCodexImportedSession } from '@/lib/codexImportedSessions'
import { getSupersedingSessionId, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession'
import { migrateSuppressedSendError } from '@/lib/suppressed-send-error'
import FilesPage from '@/routes/sessions/files'
import FilePage from '@/routes/sessions/file'
@@ -764,6 +765,29 @@ function SessionDetailRoute() {
useSessionBrowserTitle(session)
const basePath = `/sessions/${sessionId}`
const isChat = pathname === basePath || pathname === `${basePath}/`
const supersedingSessionId = getSupersedingSessionId(sessionId, session?.metadata)
const observedSessionRef = useRef<{
sessionId: string
supersedingSessionId: string | null
} | null>(null)
useEffect(() => {
if (!session) {
return
}
const shouldFollow = shouldFollowSupersedingSession(
observedSessionRef.current,
sessionId,
session.metadata
)
observedSessionRef.current = { sessionId, supersedingSessionId }
if (!shouldFollow || !supersedingSessionId) return
navigate({
to: '/sessions/$sessionId',
params: { sessionId: supersedingSessionId },
replace: true
})
}, [navigate, session, sessionId, supersedingSessionId])
useEffect(() => {
if (!sessionNotFound) {
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { getSupersedingSessionId, shouldFollowSupersedingSession } from './followSupersedingSession'
describe('getSupersedingSessionId', () => {
it('follows a different persisted replacement identity', () => {
expect(getSupersedingSessionId('source', { supersededBySessionId: 'fresh' })).toBe('fresh')
})
it('does not self-navigate for missing, blank, or identical values', () => {
expect(getSupersedingSessionId('source', undefined)).toBeNull()
expect(getSupersedingSessionId('source', { supersededBySessionId: ' ' })).toBeNull()
expect(getSupersedingSessionId('source', { supersededBySessionId: 'source' })).toBeNull()
})
})
describe('shouldFollowSupersedingSession', () => {
it('follows a replacement only when the open view witnessed the source session before supersession', () => {
expect(shouldFollowSupersedingSession({
sessionId: 'source',
supersedingSessionId: null
}, 'source', {
supersededBySessionId: 'fresh'
})).toBe(true)
})
it('keeps an archived conversation accessible when opened after it was already superseded', () => {
expect(shouldFollowSupersedingSession(null, 'source', {
supersededBySessionId: 'fresh'
})).toBe(false)
expect(shouldFollowSupersedingSession({
sessionId: 'other-session',
supersedingSessionId: null
}, 'source', {
supersededBySessionId: 'fresh'
})).toBe(false)
expect(shouldFollowSupersedingSession({
sessionId: 'source',
supersedingSessionId: 'fresh'
}, 'source', {
supersededBySessionId: 'fresh'
})).toBe(false)
})
})
@@ -0,0 +1,20 @@
export function getSupersedingSessionId(
currentSessionId: string,
metadata: { supersededBySessionId?: string } | null | undefined
): string | null {
const replacement = metadata?.supersededBySessionId?.trim()
if (!replacement || replacement === currentSessionId) {
return null
}
return replacement
}
export function shouldFollowSupersedingSession(
previous: { sessionId: string; supersedingSessionId: string | null } | null,
currentSessionId: string,
metadata: { supersededBySessionId?: string } | null | undefined
): boolean {
return previous?.sessionId === currentSessionId
&& previous.supersedingSessionId === null
&& getSupersedingSessionId(currentSessionId, metadata) !== null
}