From a677f0bb9f9712c71bd506d8412c7973b61e873b Mon Sep 17 00:00:00 2001 From: lyon Date: Tue, 16 Jun 2026 13:48:12 +0800 Subject: [PATCH] fix: sync workbench session tab status --- internal/cloud/access-control.ts | 33 +++++++- .../src/stores/workbench-session.ts | 77 ++++++++++++++++--- web/hwlab-cloud-web/src/stores/workbench.ts | 30 +++++--- web/hwlab-cloud-web/src/types/index.ts | 2 +- 4 files changed, 120 insertions(+), 22 deletions(-) diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 2878fa3e..a39b25b6 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -1035,7 +1035,8 @@ class AccessController { const conversations = await this.repairVisibleConversationsIfNeeded( auth.actor, conversationsFromAgentSessions(sessions), - projectId + projectId, + { deadlineMs: 1500 } ); return sendJson(response, 200, { ok: true, @@ -1479,11 +1480,24 @@ class AccessController { } } - async repairVisibleConversationsIfNeeded(actor, conversations = [], projectId = "") { + async repairVisibleConversationsIfNeeded(actor, conversations = [], projectId = "", options = {}) { if (!Array.isArray(conversations) || conversations.length === 0) return []; + const deadlineMs = positiveInteger(options.deadlineMs, 0); + const deadline = deadlineMs > 0 ? Date.now() + deadlineMs : 0; const repaired = []; for (const conversation of conversations) { - repaired.push(await this.repairVisibleConversationIfNeeded(actor, conversation, projectId) ?? conversation); + if (!terminalConversationRepairTraceId(conversation)) { + repaired.push(conversation); + continue; + } + const remainingMs = deadline > 0 ? deadline - Date.now() : 0; + if (deadline > 0 && remainingMs <= 0) { + repaired.push(conversation); + continue; + } + const repair = this.repairVisibleConversationIfNeeded(actor, conversation, projectId); + const next = deadline > 0 ? await resolveBeforeDeadline(repair, remainingMs) : await repair; + repaired.push(next ?? conversation); } return repaired; } @@ -2867,6 +2881,19 @@ function shouldUseSecureSessionCookie(request, env = process.env) { return false; } function errorPayload(code, message, status) { return { ok: false, status, error: { code, message } }; } +function positiveInteger(value, fallback = 0) { const number = Number(value); return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback; } +async function resolveBeforeDeadline(promise, timeoutMs) { + /** @type {ReturnType | null} */ + let timer = null; + const boundedTimeoutMs = Math.max(1, positiveInteger(timeoutMs, 1)); + const guarded = Promise.resolve(promise).catch(() => null); + const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), boundedTimeoutMs); }); + try { + return await Promise.race([guarded, timeout]); + } finally { + if (timer) clearTimeout(timer); + } +} function accessWriteErrorPayload(result = {}) { const error = result.error ?? {}; const code = textOr(error.code, "openfga_write_failed"); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 6b24e107..c0ed0a0f 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -107,11 +107,12 @@ export function mergeSelectedConversation(conversations: ConversationRecord[], w return next; } -export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | null, traceId: string, reason: string): WorkspaceRecord | null { +export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | null, traceId: string, reason: string, sessionStatus?: string | null): WorkspaceRecord | null { if (!workspace || !traceId) return workspace; const activeTraceId = activeTraceIdFromWorkspace(workspace); if (activeTraceId !== traceId) return workspace; const nested = workspace.workspace ?? {}; + const nextSessionStatus = normalizeSessionStatus(sessionStatus) ?? (isActiveStatus(nested.sessionStatus) ? "failed" : normalizeSessionStatus(nested.sessionStatus)); return { ...workspace, activeTraceId: null, @@ -120,7 +121,7 @@ export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | nul activeTraceId: null, staleActiveTraceId: traceId, staleActiveTraceReason: reason, - sessionStatus: isActiveStatus(nested.sessionStatus) ? "failed" : nested.sessionStatus, + sessionStatus: nextSessionStatus, updatedAt: new Date().toISOString(), source: "cloud-web-vue-active-trace-repair", valuesRedacted: true, @@ -161,14 +162,33 @@ export function conversationToSessionTab(conversation: ConversationRecord, activ }; } -export function resolveConversationSessionStatus(conversation: ConversationRecord): string { +export function resolveConversationSessionStatus(conversation: ConversationRecord, extraStatuses: unknown[] = []): string { const latestAgent = latestAgentMessage(conversation.messages); const latestMessage = conversation.messages?.at(-1); - return firstNonEmptyString( - conversation.status, + const stored = normalizeSessionStatus(conversation.status); + const terminalEvidence = firstNonActiveStatus( + ...extraStatuses, + conversation.session?.status, conversation.snapshot?.sessionStatus, + conversation.snapshot?.status, latestAgent?.status, latestAgent?.runnerTrace?.status, + latestAgent?.runnerTrace?.traceStatus, + runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "terminalStatus"), + runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "commandState"), + latestMessage?.status + ); + if (stored && isActiveStatus(stored) && terminalEvidence) return terminalEvidence; + return stored ?? firstNormalizedSessionStatus( + ...extraStatuses, + conversation.session?.status, + conversation.snapshot?.sessionStatus, + conversation.snapshot?.status, + latestAgent?.status, + latestAgent?.runnerTrace?.status, + latestAgent?.runnerTrace?.traceStatus, + runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "terminalStatus"), + runnerTraceAgentRunStatus(latestAgent?.runnerTrace, "commandState"), latestMessage?.status ) ?? "source"; } @@ -268,7 +288,7 @@ function selectedConversationStub(workspace: WorkspaceRecord | null, selectedCon const lastTraceId = firstNonEmptyString(workspace.workspace?.lastTraceId, workspace.activeTraceId, workspace.workspace?.activeTraceId, [...messages].reverse().find((message) => message.traceId)?.traceId) ?? null; const lastUserMessageAt = latestUserMessageAtFromMessages(messages); const latestAgent = latestAgentMessage(messages); - return { + const stub: ConversationRecord = { conversationId: selectedConversationId, projectId: firstNonEmptyString(workspace.projectId, workspace.workspace?.projectId) ?? null, sessionId, @@ -280,6 +300,7 @@ function selectedConversationStub(workspace: WorkspaceRecord | null, selectedCon messageCount: messages.length, messages }; + return { ...stub, status: resolveConversationSessionStatus(stub) }; } function mergeConversationRecords(existing: ConversationRecord, selected: ConversationRecord): ConversationRecord { @@ -289,7 +310,13 @@ function mergeConversationRecords(existing: ConversationRecord, selected: Conver const supplementalMessages = supplemental.messages ?? []; const messages = supplementalMessages.length > 0 ? supplementalMessages : primaryMessages; const lastUserMessageAt = firstNonEmptyString(primary.lastUserMessageAt, primary.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(primaryMessages), supplemental.lastUserMessageAt, supplemental.snapshot?.lastUserMessageAt, latestUserMessageAtFromMessages(supplementalMessages)) ?? null; - const status = firstNonEmptyString(primary.status, primary.snapshot?.sessionStatus, supplemental.status, supplemental.snapshot?.sessionStatus, statusFromMessages(messages)) ?? null; + const status = resolveConversationSessionStatus({ ...primary, messages }, [ + supplemental.status, + supplemental.session?.status, + supplemental.snapshot?.sessionStatus, + supplemental.snapshot?.status, + statusFromMessages(messages) + ]); return { ...supplemental, ...primary, @@ -335,11 +362,43 @@ function latestConversationMessage(messages: ChatMessage[], activeConversationId } function isActiveStatus(value: unknown): boolean { - return ["running", "pending", "accepted", "processing", "busy", "creating"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-")); + const status = normalizeSessionStatus(value); + return ["running", "pending", "accepted", "processing", "busy", "creating"].includes(status ?? ""); } function isTerminalStatus(value: unknown): boolean { - return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-")); + return ["completed", "failed", "blocked", "timeout", "canceled", "stale", "thread-resume-failed", "interrupted", "expired"].includes(normalizeSessionStatus(value) ?? ""); +} + +function firstNonActiveStatus(...values: unknown[]): string | null { + for (const value of values) { + const status = normalizeSessionStatus(value); + if (status && !isActiveStatus(status) && isTerminalStatus(status)) return status; + } + return null; +} + +function firstNormalizedSessionStatus(...values: unknown[]): string | null { + for (const value of values) { + const status = normalizeSessionStatus(value); + if (status) return status; + } + return null; +} + +function normalizeSessionStatus(value: unknown): string | null { + const text = firstNonEmptyString(value); + if (!text) return null; + const normalized = text.trim().toLowerCase().replace(/_/gu, "-"); + if (normalized === "cancelled") return "canceled"; + if (normalized === "in-flight") return "running"; + if (normalized === "error") return "failed"; + return normalized; +} + +function runnerTraceAgentRunStatus(runnerTrace: ChatMessage["runnerTrace"], key: string): unknown { + const agentRun = runnerTrace && typeof runnerTrace === "object" ? (runnerTrace as Record).agentRun : null; + return agentRun && typeof agentRun === "object" ? (agentRun as Record)[key] : null; } function profileOptionFromUnknown(item: unknown): ProviderProfileOption | null { diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 40180c47..a404b0c7 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -248,9 +248,10 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function cancelAgentMessage(message: ChatMessage): Promise { const traceId = message.traceId ?? message.runnerTrace?.traceId; if (!traceId) return; - await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value }); + const response = await api.agent.cancelAgentMessage({ traceId, projectId: activeProjectId.value, sessionId: message.sessionId ?? selectedSessionId.value, threadId: message.threadId ?? selectedThreadId.value, conversationId: message.conversationId ?? activeConversationId.value }); + const canceledStatus = workspaceSessionStatusFromChatStatus(firstNonEmptyString((response.data as Record | null)?.status, "canceled")); markMessage(traceId, { status: "canceled", text: "用户已取消该 turn。" }); - void clearActiveTrace(traceId, "cancel-agent-message"); + void clearActiveTrace(traceId, "cancel-agent-message", canceledStatus); if (message.status === "running") chatPending.value = false; currentRequest.value = null; } @@ -360,7 +361,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { reattachTrace(traceId); return; } - await clearActiveTrace(traceId, "reattach-terminal-result"); + await clearActiveTrace(traceId, "reattach-terminal-result", workspaceSessionStatusFromChatStatus(statusFromResult(result.data.status))); } function applyTraceSnapshot(traceId: string, snapshot: TraceSnapshot): void { @@ -371,17 +372,18 @@ export const useWorkbenchStore = defineStore("workbench", () => { function completeTrace(traceId: string, result: AgentChatResultResponse): void { const text = firstNonEmptyString(result.assistantText, finalResponseText(result.finalResponse), typeof result.reply === "string" ? result.reply : result.reply?.content, result.text, result.summary) ?? "Code Agent 已完成,但没有返回可展示的 final response。"; + const terminalStatus = result.status === "completed" ? "completed" : statusFromResult(result.status); messages.value = messages.value.map((message) => { if (message.traceId !== traceId || message.role !== "agent") return message; const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result); const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); - return { ...message, status: result.status === "completed" ? "completed" : statusFromResult(result.status), text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; + return { ...message, status: terminalStatus, text, runnerTrace, error: error ?? message.error ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; }); - bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: statusFromResult(result.status), updatedAt: resultActivityUpdatedAt(result) }); + bumpConversationActivity({ conversationId: firstNonEmptyString((result as Record).conversationId), sessionId: result.sessionId, threadId: result.threadId, traceId, status: terminalStatus, updatedAt: resultActivityUpdatedAt(result) }); chatPending.value = false; currentRequest.value = null; - void clearActiveTrace(traceId, "trace-terminal"); + void clearActiveTrace(traceId, "trace-terminal", workspaceSessionStatusFromChatStatus(terminalStatus)); } async function hydrateTerminalMessageDiagnostics(): Promise { @@ -411,7 +413,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { markMessage(traceId, { status: "failed", text: message }); chatPending.value = false; currentRequest.value = null; - void clearActiveTrace(traceId, "trace-infrastructure-error"); + void clearActiveTrace(traceId, "trace-infrastructure-error", "failed"); } function markMessage(traceId: string, patch: Partial): void { @@ -426,9 +428,9 @@ export const useWorkbenchStore = defineStore("workbench", () => { return workspace.value; } - async function clearActiveTrace(traceId: string, reason: string): Promise { + async function clearActiveTrace(traceId: string, reason: string, sessionStatus?: string | null): Promise { const requestEpoch = workspaceSelectionEpoch.value; - const current = workspaceWithClearedActiveTrace(workspace.value, traceId, reason); + const current = workspaceWithClearedActiveTrace(workspace.value, traceId, reason, sessionStatus); if (current === workspace.value) return; workspace.value = current; if (!current?.workspaceId) return; @@ -716,6 +718,16 @@ function statusFromResult(status: string | undefined): ChatMessage["status"] { return "failed"; } +function workspaceSessionStatusFromChatStatus(status: unknown): string | null { + const normalized = normalizedStatusText(status); + if (normalized === "completed") return "idle"; + if (normalized === "canceled" || normalized === "cancelled") return "canceled"; + if (normalized === "blocked") return "blocked"; + if (normalized === "timeout") return "timeout"; + if (normalized === "failed" || normalized === "error") return "failed"; + return null; +} + function isTraceActiveStatus(status: unknown): boolean { return ["accepted", "pending", "processing", "running", "busy", "creating"].includes(String(status ?? "").trim().toLowerCase().replace(/_/gu, "-")); } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index c1d7b494..fd101054 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -113,7 +113,7 @@ export interface ConversationRecord { name?: string | null; snapshot?: Record | null; userPreview?: string | null; - session?: { sessionId?: string | null; threadId?: string | null }; + session?: { sessionId?: string | null; threadId?: string | null; status?: string | null }; messages?: ChatMessage[]; }