[codex] Handle chat run abort lifecycle (#454)

* feat: call upstream stop API when aborting a run

- Modified handleAbort to call POST /v1/runs/{run_id}/stop endpoint
- Use profile-specific upstream URL and API key from gatewayManager
- Add 5-second timeout with error handling and logging
- Keep local abortController.abort() for EventSource cleanup
- Change handleAbort to async method and update call site

This ensures the upstream Hermes gateway is properly notified
when a user aborts a run, allowing graceful termination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: close ChatRunSocket connections on shutdown to prevent hanging

- Add close() method to ChatRunSocket to abort all active runs
  and clear session state
- Pass chatRunServer to bindShutdown and close it before
  groupChatServer during shutdown
- This prevents EventSource connections and abort controllers
  from keeping the process alive during nodemon restart

Fixes the "still waiting for sub-process to finish" issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Handle chat run abort lifecycle

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-05-05 13:03:14 +08:00
committed by GitHub
parent f13ce3a080
commit e3d28f4659
8 changed files with 524 additions and 231 deletions
+105 -102
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getApiKey } from '@/api/client'
import { defineStore } from 'pinia'
@@ -219,7 +219,6 @@ function mapHermesSession(s: SessionSummary): Session {
const STORAGE_KEY_PREFIX = 'hermes_active_session_'
const LEGACY_STORAGE_KEY = 'hermes_active_session'
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes
// 获取当前 profile 名称,用于隔离缓存。
// 从 profiles store 的 activeProfileName(同步 localStorage)读取,
@@ -234,22 +233,6 @@ function getProfileName(): string {
function storageKey(): string { return STORAGE_KEY_PREFIX + getProfileName() }
function legacyStorageKey(): string | null { return getProfileName() === 'default' ? LEGACY_STORAGE_KEY : null }
function inFlightKey(sid: string): string { return `hermes_in_flight_v1_${getProfileName()}_${sid}` }
function legacyInFlightKey(sid: string): string | null { return getProfileName() === 'default' ? `hermes_in_flight_v1_${sid}` : null }
interface InFlightRun {
runId: string
startedAt: number
}
function loadJson<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : null
} catch {
return null
}
}
function isQuotaExceededError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false
@@ -301,14 +284,6 @@ function setItemBestEffort(key: string, value: string) {
}
}
function saveJson(key: string, value: unknown) {
try {
setItemBestEffort(key, JSON.stringify(value))
} catch {
// quota exceeded or private mode — ignore, cache is best-effort
}
}
function removeItem(key: string) {
try {
localStorage.removeItem(key)
@@ -317,23 +292,6 @@ function removeItem(key: string) {
}
}
function loadJsonWithFallback<T>(key: string, legacyKey?: string | null): T | null {
const value = loadJson<T>(key)
if (value != null) return value
if (!legacyKey) return null
return loadJson<T>(legacyKey)
}
function saveJsonWithLegacy(key: string, value: unknown, legacyKey?: string | null) {
saveJson(key, value)
if (legacyKey) removeItem(legacyKey)
}
function removeItemWithLegacy(key: string, legacyKey?: string | null) {
removeItem(key)
if (legacyKey) removeItem(legacyKey)
}
// Strip the circular `file: File` reference from attachments before caching —
// File objects don't serialize and we only need name/type/size/url for display.
@@ -375,6 +333,17 @@ export const useChatStore = defineStore('chat', () => {
compressionState.value = state
}
const abortState = ref<{
aborting: boolean
synced: boolean | null
error?: string
} | null>(null)
const isAborting = computed(() => abortState.value?.aborting === true)
function setAbortState(state: typeof abortState.value) {
abortState.value = state
}
const activeSession = ref<Session | null>(null)
const messages = computed<Message[]>(() => activeSession.value?.messages || [])
@@ -382,30 +351,11 @@ export const useChatStore = defineStore('chat', () => {
return streamStates.value.has(sessionId) || serverWorking.value.has(sessionId)
}
function markInFlight(sid: string, runId: string) {
saveJsonWithLegacy(inFlightKey(sid), { runId, startedAt: Date.now() } as InFlightRun, legacyInFlightKey(sid))
}
function clearInFlight(sid: string) {
removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid))
}
function readInFlight(sid: string): InFlightRun | null {
const rec = loadJsonWithFallback<InFlightRun>(inFlightKey(sid), legacyInFlightKey(sid))
if (!rec) return null
if (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) {
removeItemWithLegacy(inFlightKey(sid), legacyInFlightKey(sid))
return null
}
return rec
}
async function loadSessions() {
isLoadingSessions.value = true
try {
const list = await fetchSessions()
const fresh = list.map(mapHermesSession)
const freshIds = new Set(fresh.map(s => s.id))
// Preserve already-loaded messages for sessions that are still present,
// so we don't blow away the active session's messages on refresh.
const msgsByIdBefore = new Map(sessions.value.map(s => [s.id, s.messages]))
@@ -413,19 +363,7 @@ export const useChatStore = defineStore('chat', () => {
const prev = msgsByIdBefore.get(s.id)
if (prev && prev.length) s.messages = prev
}
// Preserve local-only sessions the server hasn't seen yet — e.g. a chat
// that was just created and whose first run is still in-flight. Without
// this, refreshing mid-run would wipe the session and fall back to
// sessions[0], which is exactly what the user reported.
// Sessions without an active in-flight run are considered deleted and
// cleaned up along with their cached messages.
const localOnly = sessions.value.filter(s => {
if (freshIds.has(s.id)) return false
if (readInFlight(s.id)) return true
removeItemWithLegacy(inFlightKey(s.id), legacyInFlightKey(s.id))
return false
})
sessions.value = [...localOnly, ...fresh]
sessions.value = fresh
// Restore last active session, fallback to most recent
const savedId = activeSessionId.value
@@ -500,6 +438,11 @@ export const useChatStore = defineStore('chat', () => {
} else {
serverWorking.value.delete(sessionId)
}
if ((data as any).isAborting) {
setAbortState({ aborting: true, synced: null })
} else if (!data.isWorking) {
setAbortState(null)
}
if (data.inputTokens != null) activeSession.value!.inputTokens = data.inputTokens
if (data.outputTokens != null) activeSession.value!.outputTokens = data.outputTokens
if (data.messages?.length) {
@@ -533,6 +476,10 @@ export const useChatStore = defineStore('chat', () => {
compressed: e.compressed ?? false,
error: e.error,
})
} else if (e.event === 'abort.started') {
setAbortState({ aborting: true, synced: null })
} else if (e.event === 'abort.completed') {
setAbortState({ aborting: false, synced: e.synced ?? false })
}
}
}
@@ -546,7 +493,7 @@ export const useChatStore = defineStore('chat', () => {
}
// Resume in-flight run event listeners if needed
resumeInFlightRun(sessionId)
resumeServerWorkingRun(sessionId)
}
function newChat() {
@@ -744,6 +691,33 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'abort.started': {
console.log('[chat abort] pause started', evt)
setAbortState({ aborting: true, synced: null })
break
}
case 'abort.completed': {
console.log('[chat abort] pause completed', evt)
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'done' }
}
})
cleanup()
if (sid === activeSessionId.value) {
void refreshActiveSession()
}
setAbortState(null)
break
}
case 'reasoning.delta':
case 'thinking.delta': {
const text = evt.text || evt.delta || ''
@@ -943,13 +917,6 @@ export const useChatStore = defineStore('chat', () => {
cleanup()
updateSessionTitle(sid)
// the in-flight marker. If the browser is reloading right now
// and kills us between the two localStorage writes, we want
// the next page load to still see in-flight === true (so
// polling kicks in and recovers) rather than the other way
// around (cleared in-flight + stale streaming cache = UI stuck).
clearInFlight(sid)
break
}
@@ -976,8 +943,6 @@ export const useChatStore = defineStore('chat', () => {
}
})
cleanup()
clearInFlight(sid)
break
}
@@ -1020,10 +985,7 @@ export const useChatStore = defineStore('chat', () => {
void refreshActiveSession()
}
},
// onStarted — called when server acks with run_id
(runId: string) => {
markInFlight(sid, runId)
},
undefined,
)
streamStates.value.set(sid, ctrl)
@@ -1042,11 +1004,11 @@ export const useChatStore = defineStore('chat', () => {
* Emits 'resume' to join the session room on the server,
* then sets up event listeners to receive ongoing events.
*/
function resumeInFlightRun(sid: string) {
function resumeServerWorkingRun(sid: string) {
// Don't register duplicate listeners if already streaming
if (streamStates.value.has(sid)) return
// Only set up listeners if there's an actual in-flight run
if (!readInFlight(sid)) return
// Only set up listeners if the server reported an active run during resume.
if (!serverWorking.value.has(sid)) return
let closed = false
let runProducedAssistantText = false
@@ -1098,6 +1060,33 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'abort.started': {
console.log('[chat abort] resumed pause started', evt)
setAbortState({ aborting: true, synced: null })
break
}
case 'abort.completed': {
console.log('[chat abort] resumed pause completed', evt)
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
msgs.forEach((m, i) => {
if (m.role === 'tool' && m.toolStatus === 'running') {
msgs[i] = { ...m, toolStatus: 'done' }
}
})
cleanup()
if (sid === activeSessionId.value) {
void refreshActiveSession()
}
setAbortState(null)
break
}
case 'reasoning.delta':
case 'thinking.delta': {
const text = evt.text || evt.delta || ''
@@ -1252,8 +1241,6 @@ export const useChatStore = defineStore('chat', () => {
cleanup()
updateSessionTitle(sid)
clearInFlight(sid)
break
}
@@ -1280,8 +1267,6 @@ export const useChatStore = defineStore('chat', () => {
}
})
cleanup()
clearInFlight(sid)
break
}
@@ -1309,6 +1294,8 @@ export const useChatStore = defineStore('chat', () => {
onRunFailed: (evt) => handleEvent(evt),
onCompressionStarted: (evt) => handleEvent(evt),
onCompressionCompleted: (evt) => handleEvent(evt),
onAbortStarted: (evt) => handleEvent(evt),
onAbortCompleted: (evt) => handleEvent(evt),
onUsageUpdated: (evt) => handleEvent(evt),
})
@@ -1316,25 +1303,29 @@ export const useChatStore = defineStore('chat', () => {
// Server already joined room and replayed events.
// Just set up handlers for ongoing streaming events.
// Mark as streaming so UI shows the indicator
streamStates.value.set(sid, { abort: cleanup })
// Mark as streaming so UI shows the indicator and can still abort after refresh.
streamStates.value.set(sid, {
abort: () => {
getChatRunSocket()?.emit('abort', { session_id: sid })
},
})
}
function stopStreaming() {
const sid = activeSessionId.value
if (!sid) return
if (isAborting.value) return
const ctrl = streamStates.value.get(sid)
if (ctrl) {
console.log('[chat abort] stop requested', { sessionId: sid })
setAbortState({ aborting: true, synced: null })
ctrl.abort()
const msgs = getSessionMsgs(sid)
const lastMsg = msgs[msgs.length - 1]
if (lastMsg?.isStreaming) {
updateMessage(sid, lastMsg.id, { isStreaming: false })
}
streamStates.value.delete(sid)
serverWorking.value.delete(sid)
}
clearInFlight(sid)
}
// Tab visibility: re-sync when returning to foreground
@@ -1345,11 +1336,21 @@ export const useChatStore = defineStore('chat', () => {
if (sid && !streamStates.value.has(sid)) {
// Re-load messages via resume (server loads from DB)
resumeSession(sid, (data) => {
if (data.isWorking) {
serverWorking.value.add(sid)
} else {
serverWorking.value.delete(sid)
}
if (data.isAborting) {
setAbortState({ aborting: true, synced: null })
} else if (!data.isWorking) {
setAbortState(null)
}
if (data.messages?.length && activeSession.value) {
activeSession.value.messages = mapHermesMessages(data.messages as any[])
}
resumeServerWorkingRun(sid)
})
resumeInFlightRun(sid)
}
}
})
@@ -1431,6 +1432,8 @@ export const useChatStore = defineStore('chat', () => {
isRunActive,
isSessionLive,
compressionState,
abortState,
isAborting,
isLoadingSessions,
sessionsLoaded,
isLoadingMessages,