fix: validate transcript sessions before resume and fix FAB race condition

Server now checks that transcript .jsonl files exist before creating
terminals, preventing dead sessions from --resume errors. Frontend
shows error banner in modal when resume fails. Fixed race condition
where init() would overwrite FAB terminal selection after page refresh
by guarding with pendingSwitchTarget flag.
This commit is contained in:
2026-02-21 12:51:15 -06:00
parent de16be38a9
commit ba4a1a0059
5 changed files with 109 additions and 3 deletions

View File

@@ -388,8 +388,9 @@ watch(() => route.name, (newPage) => {
</div> </div>
</div> </div>
<!-- Voice FAB Button --> <!-- Voice FAB Button (hidden) -->
<button <button
v-show="false"
class="voice-fab" class="voice-fab"
:class="{ active: showVoice, 'sheet-open': showVoice || showTranscriptDebug, 'ptt-active': voicePTTActive, 'keyboard-visible': keyboardVisible }" :class="{ active: showVoice, 'sheet-open': showVoice || showTranscriptDebug, 'ptt-active': voicePTTActive, 'keyboard-visible': keyboardVisible }"
@click="handleVoiceFabClick" @click="handleVoiceFabClick"

View File

@@ -38,6 +38,7 @@ const {
hookMeta, hookMeta,
openTerminals, openTerminals,
activeTerminalSessionId, activeTerminalSessionId,
lastStartError,
init, init,
switchAgent, switchAgent,
selectSession, selectSession,
@@ -470,6 +471,7 @@ async function handleModalCreateNew(agent: AgentName, initialPrompt: string) {
async function handleModalResume(sessionId: string, agent: AgentName) { async function handleModalResume(sessionId: string, agent: AgentName) {
showNewSessionModal.value = false showNewSessionModal.value = false
lastStartError.value = null
if (agent !== selectedAgent.value) { if (agent !== selectedAgent.value) {
await switchAgent(agent) await switchAgent(agent)
} }
@@ -478,6 +480,11 @@ async function handleModalResume(sessionId: string, agent: AgentName) {
selectedSessionId.value = sessionId selectedSessionId.value = sessionId
await fetchSessionContent(sessionId) await fetchSessionContent(sessionId)
await startTerminal(sessionId) await startTerminal(sessionId)
// If startTerminal failed (e.g. transcript not found), re-open modal
if (lastStartError.value) {
showNewSessionModal.value = true
}
} }
// ============================================================================ // ============================================================================
@@ -870,7 +877,8 @@ onBeforeUnmount(() => {
:visible="showNewSessionModal" :visible="showNewSessionModal"
:agents="agents" :agents="agents"
:current-agent="selectedAgent" :current-agent="selectedAgent"
@close="showNewSessionModal = false" :error="lastStartError"
@close="showNewSessionModal = false; lastStartError = null"
@create-new="handleModalCreateNew" @create-new="handleModalCreateNew"
@resume="handleModalResume" @resume="handleModalResume"
/> />

View File

@@ -6,6 +6,7 @@ const props = defineProps<{
visible: boolean visible: boolean
agents: { id: AgentName; label: string }[] agents: { id: AgentName; label: string }[]
currentAgent: AgentName currentAgent: AgentName
error?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -34,7 +35,8 @@ const hasAnySessions = computed(() =>
// Reset state when modal opens // Reset state when modal opens
watch(() => props.visible, async (open) => { watch(() => props.visible, async (open) => {
if (open) { if (open) {
activeTab.value = 'new' // If reopening after a resume error, show the resume tab
activeTab.value = props.error ? 'resume' : 'new'
selectedAgent.value = props.currentAgent selectedAgent.value = props.currentAgent
resumeFilter.value = 'all' resumeFilter.value = 'all'
initialPrompt.value = '' initialPrompt.value = ''
@@ -129,6 +131,16 @@ function handleResume(sessionId: string, agent: AgentName) {
</button> </button>
</div> </div>
<!-- Error banner -->
<div v-if="error" class="nsm-error">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
{{ error }}
</div>
<!-- Body --> <!-- Body -->
<div class="nsm-body"> <div class="nsm-body">
<!-- Tab: New session --> <!-- Tab: New session -->
@@ -333,6 +345,24 @@ function handleResume(sessionId: string, agent: AgentName) {
border-bottom-color: #6366f1; border-bottom-color: #6366f1;
} }
/* Error banner */
.nsm-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.12);
border-bottom: 1px solid rgba(239, 68, 68, 0.3);
color: #f87171;
font-size: 12px;
flex-shrink: 0;
}
.nsm-error svg {
flex-shrink: 0;
color: #ef4444;
}
/* Body */ /* Body */
.nsm-body { .nsm-body {
flex: 1; flex: 1;

View File

@@ -90,6 +90,10 @@ export function useTranscriptDebug() {
const awaitingNewSession = ref(false) const awaitingNewSession = ref(false)
const pendingPrompt = ref<string | null>(null) const pendingPrompt = ref<string | null>(null)
const lastStartError = ref<string | null>(null)
// Guard: when a FAB click triggers switchToTerminal before init(), init() must not overwrite
const pendingSwitchTarget = ref<string | null>(null)
// ── Server registry HTTP helpers ── // ── Server registry HTTP helpers ──
@@ -151,8 +155,10 @@ export function useTranscriptDebug() {
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' })) const err = await res.json().catch(() => ({ error: 'Unknown error' }))
console.error(`[TranscriptDebug] Failed to create terminal: ${err.error}`) console.error(`[TranscriptDebug] Failed to create terminal: ${err.error}`)
lastStartError.value = err.error || 'Failed to create terminal'
return return
} }
lastStartError.value = null
const { ephemeralSessionId } = await res.json() const { ephemeralSessionId } = await res.json()
@@ -235,6 +241,7 @@ export function useTranscriptDebug() {
async function switchToTerminal(transcriptSessionId: string) { async function switchToTerminal(transcriptSessionId: string) {
if (transcriptSessionId === activeTerminalSessionId.value) return if (transcriptSessionId === activeTerminalSessionId.value) return
pendingSwitchTarget.value = transcriptSessionId
transitioning.value = true transitioning.value = true
transitionError.value = null transitionError.value = null
@@ -281,6 +288,7 @@ export function useTranscriptDebug() {
// Connect to the terminal // Connect to the terminal
connectToTerminal(transcriptSessionId) connectToTerminal(transcriptSessionId)
pendingSwitchTarget.value = null
transitioning.value = false transitioning.value = false
} }
@@ -561,6 +569,14 @@ export function useTranscriptDebug() {
} }
async function init() { async function init() {
// If a FAB click already triggered switchToTerminal(), let it handle the connection.
// Only load sessions and connect realtime — don't auto-connect to a terminal.
if (pendingSwitchTarget.value) {
await fetchSessions()
connectRealtime()
return
}
// Derive initial state from server registry: if there's an alive terminal, sync to it // Derive initial state from server registry: if there's an alive terminal, sync to it
const aliveEntry = serverRegistry.value.find(e => e.alive) const aliveEntry = serverRegistry.value.find(e => e.alive)
if (aliveEntry) { if (aliveEntry) {
@@ -569,6 +585,12 @@ export function useTranscriptDebug() {
await fetchSessions() await fetchSessions()
// Re-check after async gap — switchToTerminal() may have started during fetchSessions
if (pendingSwitchTarget.value) {
connectRealtime()
return
}
let targetSession: string | null = null let targetSession: string | null = null
if (aliveEntry) { if (aliveEntry) {
@@ -589,6 +611,12 @@ export function useTranscriptDebug() {
selectedSessionId.value = targetSession selectedSessionId.value = targetSession
await fetchSessionContent(targetSession) await fetchSessionContent(targetSession)
// Final check before connecting — bail if user clicked a FAB during content fetch
if (pendingSwitchTarget.value) {
connectRealtime()
return
}
// Connect to existing terminal if one exists (clients never create terminals — only the server does) // Connect to existing terminal if one exists (clients never create terminals — only the server does)
const existing = serverRegistry.value.find( const existing = serverRegistry.value.find(
e => e.transcriptSessionId === targetSession && e.alive e => e.transcriptSessionId === targetSession && e.alive
@@ -968,6 +996,7 @@ export function useTranscriptDebug() {
terminalReady, terminalReady,
hookMeta, hookMeta,
awaitingNewSession, awaitingNewSession,
lastStartError,
openTerminals, openTerminals,
activeTerminalSessionId, activeTerminalSessionId,
init, init,

View File

@@ -1,7 +1,33 @@
import { spawn, type IPty } from '@skitee3000/bun-pty' import { spawn, type IPty } from '@skitee3000/bun-pty'
import { existsSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES, MAX_TERMINALS } from '../config' import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES, MAX_TERMINALS } from '../config'
import { sessionState, type SessionStatePatch } from './session-state' import { sessionState, type SessionStatePatch } from './session-state'
// Agent transcript directories (mirrored from transcript-debug.ts)
const AGENT_TRANSCRIPT_DIRS: Record<string, string> = {
ejecutor: join(WORKING_DIR, '.claude-ejecutor', 'projects'),
nucleo000: join(WORKING_DIR, '.claude-nucleo000', 'projects'),
claude: join(homedir(), '.claude', 'projects')
}
const PROJECT_HASH = 'C--Users-jodar-agent-ui'
function getTranscriptProjectDir(agent: string): string | null {
const baseDir = AGENT_TRANSCRIPT_DIRS[agent]
if (!baseDir || !existsSync(baseDir)) return null
const exact = join(baseDir, PROJECT_HASH)
if (existsSync(exact)) return exact
return null
}
function transcriptSessionExists(agent: string, sessionId: string): boolean {
const projectDir = getTranscriptProjectDir(agent)
if (!projectDir) return false
return existsSync(join(projectDir, `${sessionId}.jsonl`))
}
interface TerminalSession { interface TerminalSession {
id: string id: string
pty: IPty pty: IPty
@@ -400,6 +426,18 @@ export function startTerminalServer() {
) )
} }
// Validate transcript session exists before resuming
const tsId = body.transcriptSessionId
if (tsId && tsId !== '__new__' && body.agent) {
if (!transcriptSessionExists(body.agent, tsId)) {
console.warn(`[Terminal] Transcript session not found: ${tsId} (agent: ${body.agent})`)
return Response.json(
{ error: `Transcript session "${tsId}" not found. It may have been deleted.` },
{ status: 404, headers: corsHeaders }
)
}
}
// Generate ephemeralSessionId server-side // Generate ephemeralSessionId server-side
const ephemeralSessionId = `pty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const ephemeralSessionId = `pty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`