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:
@@ -388,8 +388,9 @@ watch(() => route.name, (newPage) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice FAB Button -->
|
||||
<!-- Voice FAB Button (hidden) -->
|
||||
<button
|
||||
v-show="false"
|
||||
class="voice-fab"
|
||||
:class="{ active: showVoice, 'sheet-open': showVoice || showTranscriptDebug, 'ptt-active': voicePTTActive, 'keyboard-visible': keyboardVisible }"
|
||||
@click="handleVoiceFabClick"
|
||||
|
||||
@@ -38,6 +38,7 @@ const {
|
||||
hookMeta,
|
||||
openTerminals,
|
||||
activeTerminalSessionId,
|
||||
lastStartError,
|
||||
init,
|
||||
switchAgent,
|
||||
selectSession,
|
||||
@@ -470,6 +471,7 @@ async function handleModalCreateNew(agent: AgentName, initialPrompt: string) {
|
||||
|
||||
async function handleModalResume(sessionId: string, agent: AgentName) {
|
||||
showNewSessionModal.value = false
|
||||
lastStartError.value = null
|
||||
if (agent !== selectedAgent.value) {
|
||||
await switchAgent(agent)
|
||||
}
|
||||
@@ -478,6 +480,11 @@ async function handleModalResume(sessionId: string, agent: AgentName) {
|
||||
selectedSessionId.value = sessionId
|
||||
await fetchSessionContent(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"
|
||||
:agents="agents"
|
||||
:current-agent="selectedAgent"
|
||||
@close="showNewSessionModal = false"
|
||||
:error="lastStartError"
|
||||
@close="showNewSessionModal = false; lastStartError = null"
|
||||
@create-new="handleModalCreateNew"
|
||||
@resume="handleModalResume"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ const props = defineProps<{
|
||||
visible: boolean
|
||||
agents: { id: AgentName; label: string }[]
|
||||
currentAgent: AgentName
|
||||
error?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,7 +35,8 @@ const hasAnySessions = computed(() =>
|
||||
// Reset state when modal opens
|
||||
watch(() => props.visible, async (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
|
||||
resumeFilter.value = 'all'
|
||||
initialPrompt.value = ''
|
||||
@@ -129,6 +131,16 @@ function handleResume(sessionId: string, agent: AgentName) {
|
||||
</button>
|
||||
</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 -->
|
||||
<div class="nsm-body">
|
||||
<!-- Tab: New session -->
|
||||
@@ -333,6 +345,24 @@ function handleResume(sessionId: string, agent: AgentName) {
|
||||
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 */
|
||||
.nsm-body {
|
||||
flex: 1;
|
||||
|
||||
@@ -90,6 +90,10 @@ export function useTranscriptDebug() {
|
||||
|
||||
const awaitingNewSession = ref(false)
|
||||
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 ──
|
||||
|
||||
@@ -151,8 +155,10 @@ export function useTranscriptDebug() {
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
|
||||
console.error(`[TranscriptDebug] Failed to create terminal: ${err.error}`)
|
||||
lastStartError.value = err.error || 'Failed to create terminal'
|
||||
return
|
||||
}
|
||||
lastStartError.value = null
|
||||
|
||||
const { ephemeralSessionId } = await res.json()
|
||||
|
||||
@@ -235,6 +241,7 @@ export function useTranscriptDebug() {
|
||||
async function switchToTerminal(transcriptSessionId: string) {
|
||||
if (transcriptSessionId === activeTerminalSessionId.value) return
|
||||
|
||||
pendingSwitchTarget.value = transcriptSessionId
|
||||
transitioning.value = true
|
||||
transitionError.value = null
|
||||
|
||||
@@ -281,6 +288,7 @@ export function useTranscriptDebug() {
|
||||
// Connect to the terminal
|
||||
connectToTerminal(transcriptSessionId)
|
||||
|
||||
pendingSwitchTarget.value = null
|
||||
transitioning.value = false
|
||||
}
|
||||
|
||||
@@ -561,6 +569,14 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
|
||||
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
|
||||
const aliveEntry = serverRegistry.value.find(e => e.alive)
|
||||
if (aliveEntry) {
|
||||
@@ -569,6 +585,12 @@ export function useTranscriptDebug() {
|
||||
|
||||
await fetchSessions()
|
||||
|
||||
// Re-check after async gap — switchToTerminal() may have started during fetchSessions
|
||||
if (pendingSwitchTarget.value) {
|
||||
connectRealtime()
|
||||
return
|
||||
}
|
||||
|
||||
let targetSession: string | null = null
|
||||
|
||||
if (aliveEntry) {
|
||||
@@ -589,6 +611,12 @@ export function useTranscriptDebug() {
|
||||
selectedSessionId.value = 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)
|
||||
const existing = serverRegistry.value.find(
|
||||
e => e.transcriptSessionId === targetSession && e.alive
|
||||
@@ -968,6 +996,7 @@ export function useTranscriptDebug() {
|
||||
terminalReady,
|
||||
hookMeta,
|
||||
awaitingNewSession,
|
||||
lastStartError,
|
||||
openTerminals,
|
||||
activeTerminalSessionId,
|
||||
init,
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
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 { 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 {
|
||||
id: string
|
||||
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
|
||||
const ephemeralSessionId = `pty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user