diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 62aac9b7..469eeae6 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -311,6 +311,9 @@ lanes: HWLAB_OBSERVABILITY_CONFIG_REVISION: PJ2026-01060505-20260619-long-trace-timeout-mitigation HWLAB_CODE_AGENT_TURN_STATUS_REFRESH_TIMEOUT_MS: "30000" HWLAB_CODE_AGENT_AGENTRUN_NO_RESPONSE_TIMEOUT_MS: "600000" + HWLAB_CODE_AGENT_AGENTRUN_DISPATCH_RETRY_MAX: "5" + HWLAB_CODE_AGENT_AGENTRUN_DISPATCH_RETRY_BASE_MS: "1000" + HWLAB_CODE_AGENT_AGENTRUN_DISPATCH_RETRY_MAX_DELAY_MS: "30000" HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_SYNC_POLL_INTERVAL_MS: "1000" HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_ENABLED: "1" HWLAB_CODE_AGENT_AGENTRUN_PROJECTION_RESUME_INITIAL_DELAY_MS: "5000" diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 6b4ca805..67742654 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -288,11 +288,16 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI try { const reusableParams = paramsWithAgentRunSessionThread(params, reusable.mapping.threadId); const commandInput = buildAgentRunCommandInput({ params: reusableParams, traceId, backendProfile, sessionId: reusable.sessionId }); - const command = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/commands`, { + const command = await agentRunDispatchJson({ fetchImpl, managerUrl, path: `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/commands`, method: "POST", body: commandInput, timeoutMs, - env + env, + traceStore, + traceId, + backendProfile, + runId: reusable.mapping.runId, + stage: "command-create-reused" }); const commandId = requiredString(command?.id, "command.id"); traceStore.append(traceId, agentRunTraceEvent({ @@ -309,11 +314,17 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI let runnerJob = null; try { const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile }); - runnerJob = await agentRunJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/runner-jobs`, { + runnerJob = await agentRunDispatchJson({ fetchImpl, managerUrl, path: `/api/v1/runs/${encodeURIComponent(reusable.mapping.runId)}/runner-jobs`, method: "POST", body: runnerJobInput, timeoutMs, - env + env, + traceStore, + traceId, + backendProfile, + runId: reusable.mapping.runId, + commandId, + stage: "runner-job-ensure-reused" }); } catch (error) { if (!isAgentRunCommandAlreadyClaimed(error)) throw error; @@ -424,7 +435,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI } const runInput = buildAgentRunCreateRunInput({ params: dispatchParams, env, traceId, backendProfile, sessionId, toolCapabilities }); const runCreateStartedAt = Date.now(); - run = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs", { method: "POST", body: runInput, timeoutMs, env }); + run = await agentRunDispatchJson({ fetchImpl, managerUrl, path: "/api/v1/runs", method: "POST", body: runInput, timeoutMs, env, traceStore, traceId, backendProfile, stage: "run-create" }); const runId = requiredString(run?.id, "run.id"); void emitCodeAgentOtelSpan("agentrun_run_create", traceId, env, { startTimeMs: runCreateStartedAt, attributes: { runId, backendProfile, managerUrl } }); traceStore.append(traceId, agentRunTraceEvent({ @@ -435,7 +446,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI }, backendProfile)); const commandInput = buildAgentRunCommandInput({ params: dispatchParams, traceId, backendProfile, sessionId }); const commandCreateStartedAt = Date.now(); - command = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs/" + encodeURIComponent(runId) + "/commands", { method: "POST", body: commandInput, timeoutMs, env }); + command = await agentRunDispatchJson({ fetchImpl, managerUrl, path: "/api/v1/runs/" + encodeURIComponent(runId) + "/commands", method: "POST", body: commandInput, timeoutMs, env, traceStore, traceId, backendProfile, runId, stage: "command-create" }); const commandId = requiredString(command?.id, "command.id"); void emitCodeAgentOtelSpan("agentrun_command_create", traceId, env, { startTimeMs: commandCreateStartedAt, attributes: { runId, commandId, backendProfile, managerUrl } }); traceStore.append(traceId, agentRunTraceEvent({ @@ -447,7 +458,7 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI const runnerJobInput = buildAgentRunRunnerJobInput({ env, traceId, commandId, ownerApiKey, toolCapabilities, backendProfile }); try { const runnerJobStartedAt = Date.now(); - runnerJob = await agentRunJson(fetchImpl, managerUrl, "/api/v1/runs/" + encodeURIComponent(runId) + "/runner-jobs", { method: "POST", body: runnerJobInput, timeoutMs, env }); + runnerJob = await agentRunDispatchJson({ fetchImpl, managerUrl, path: "/api/v1/runs/" + encodeURIComponent(runId) + "/runner-jobs", method: "POST", body: runnerJobInput, timeoutMs, env, traceStore, traceId, backendProfile, runId, commandId, stage: "runner-job-create" }); void emitCodeAgentOtelSpan("agentrun_runner_job_create", traceId, env, { startTimeMs: runnerJobStartedAt, attributes: { runId, commandId, backendProfile, managerUrl, jobName: runnerJob?.jobName ?? runnerJob?.jobIdentity?.name ?? null } }); } catch (error) { if (attempt === 0 && await shouldResetSessionAfterEviction("session-store-evicted", error?.message)) { @@ -477,6 +488,72 @@ export async function submitAgentRunChatTurn({ params = {}, options = {}, traceI return decorateAgentRunRunningResult({ base: initialAgentRunChatResult({ params: finalDispatchParams, options, traceId }), mapping, traceStore, traceId }); } +async function agentRunDispatchJson({ fetchImpl, managerUrl, path, method = "GET", body = undefined, timeoutMs, env = process.env, traceStore = defaultCodeAgentTraceStore, traceId, backendProfile, runId = null, commandId = null, stage = "agentrun-dispatch" } = {}) { + const policy = agentRunDispatchRetryPolicy(env); + let retryAttempt = 0; + for (;;) { + try { + return await agentRunJson(fetchImpl, managerUrl, path, { method, body, timeoutMs, env }); + } catch (error) { + const failureKind = agentRunDispatchFailureKind(error); + if (!agentRunDispatchRetryable(error, failureKind) || retryAttempt >= policy.maxRetries) throw Object.assign(error, { retryAttempt, retryMax: policy.maxRetries, failureKind }); + retryAttempt += 1; + const retryDelayMs = agentRunDispatchRetryDelayMs(policy, retryAttempt); + const nextRetryAt = new Date(Date.now() + retryDelayMs).toISOString(); + traceStore.append(traceId, agentRunTraceEvent({ + type: "provider_retry", + eventType: "backend", + status: "retrying", + label: `agentrun:dispatch-retry:${failureKind}`, + errorCode: failureKind, + failureKind, + retryable: true, + willRetry: true, + retryAttempt, + retryMax: policy.maxRetries, + retryDelayMs, + nextRetryAt, + runId, + commandId, + waitingFor: "agentrun-dispatch-retry", + message: `AgentRun dispatch transient ${failureKind}; retry ${retryAttempt}/${policy.maxRetries} after ${retryDelayMs}ms at stage ${stage}.`, + terminal: false, + valuesPrinted: false + }, backendProfile)); + await sleepAgentRunDispatchRetry(retryDelayMs); + } + } +} + +function agentRunDispatchRetryPolicy(env = process.env) { + return { + maxRetries: parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_DISPATCH_RETRY_MAX, 5), + baseDelayMs: parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_DISPATCH_RETRY_BASE_MS, 1000), + maxDelayMs: parsePositiveInteger(env.HWLAB_CODE_AGENT_AGENTRUN_DISPATCH_RETRY_MAX_DELAY_MS, 30000) + }; +} + +function agentRunDispatchRetryDelayMs(policy, retryAttempt) { + const base = Math.max(1, Number(policy.baseDelayMs ?? 1000)); + const cap = Math.max(base, Number(policy.maxDelayMs ?? 30000)); + return Math.min(cap, Math.trunc(base * Math.pow(2, Math.max(0, Number(retryAttempt ?? 1) - 1)))); +} + +function agentRunDispatchRetryable(error, failureKind) { + const status = Number(error?.statusCode ?? error?.status ?? error?.agentRunError?.statusCode ?? 0); + if ([429, 502, 503, 504].includes(status)) return true; + if (["provider-unavailable", "provider-stream-disconnected", "agentrun-timeout", "agentrun-timeout", "timeout", "fetch-failed", "network-error"].includes(failureKind)) return true; + return /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|fetch failed|network|timeout/iu.test(String(error?.message ?? "")); +} + +function agentRunDispatchFailureKind(error) { + return normalizedFailureKind(error?.agentRunError?.failureKind ?? error?.failureKind ?? error?.code ?? error?.name ?? (Number(error?.statusCode) ? `http-${error.statusCode}` : "agentrun-dispatch-error")); +} + +function sleepAgentRunDispatchRetry(ms) { + return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0))); +} + function isAgentRunCommandAlreadyClaimed(error) { const statusCode = Number(error?.statusCode ?? 0); const message = String(error?.message ?? error?.agentRunError?.message ?? ""); @@ -1703,7 +1780,7 @@ function canReuseAgentRunSessionForOwner(session, params = {}, options = {}) { function agentRunReuseBlocker(run = {}, mapping = {}) { const status = String(run?.terminalStatus ?? run?.status ?? mapping.runStatus ?? "").toLowerCase(); - if (TERMINAL_RUN_STATUSES.has(status)) return `run_status_${status}`; + if (TERMINAL_RUN_STATUSES.has(status) && status !== "completed") return `run_status_${status}`; if (!run?.claimedBy) return "runner_not_claimed"; if (runLeaseExpired(run.leaseExpiresAt)) return "runner_reuse_window_expired"; return null; @@ -2383,7 +2460,7 @@ function mapAgentRunEvent(event, mapping = {}) { return { ...base, type: "diff", eventType: "status", status: "running", label: "agentrun:diff", message: textPayload(payload, "diff") }; } if (event.type === "error") { - if (isRetryableProviderInterruptionPayload(payload)) return agentRunProviderRetryEvent({ base, payload, status: "retrying", label: `agentrun:provider-retry:${String(payload.failureKind ?? "provider-stream-disconnected")}` }); + if (isRetryableProviderInterruptionPayload(payload, { activeError: true })) return agentRunProviderRetryEvent({ base, payload: { ...payload, willRetry: true }, status: "retrying", label: `agentrun:provider-retry:${String(payload.failureKind ?? "provider-stream-disconnected")}` }); return { ...base, type: "error", eventType: "error", status: "failed", label: `agentrun:error:${String(payload.failureKind ?? "backend")}`, errorCode: payload.failureKind ?? "agentrun_error", failureKind: payload.failureKind ?? null, willRetry: payload.willRetry === true ? true : undefined, message: textPayload(payload, "AgentRun error") }; } if (event.type === "terminal_status") { @@ -2408,6 +2485,10 @@ function agentRunProviderRetryEvent({ base = {}, payload = {}, status = "retryin retryable: true, transient: true, terminal: false, + retryAttempt: numberOrNull(payload.retryAttempt), + retryMax: numberOrNull(payload.retryMax), + retryDelayMs: numberOrNull(payload.retryDelayMs), + nextRetryAt: firstNonEmpty(payload.nextRetryAt) || null, retryOf: payload.retryOf ?? null, terminalStatus: payload.terminalStatus ?? null, message: textPayload(payload, "Provider stream disconnected; AgentRun will retry."), @@ -2415,14 +2496,22 @@ function agentRunProviderRetryEvent({ base = {}, payload = {}, status = "retryin }; } -function isRetryableProviderInterruptionPayload(payload = {}) { - return normalizedFailureKind(payload?.failureKind ?? payload?.errorCode ?? payload?.code) === "provider-stream-disconnected" && payload?.willRetry === true; +function isRetryableProviderInterruptionPayload(payload = {}, options = {}) { + const kind = normalizedFailureKind(payload?.failureKind ?? payload?.errorCode ?? payload?.code); + if (kind === "provider-stream-disconnected") return payload?.willRetry === true; + if (kind === "provider-unavailable") return payload?.willRetry === true || (options.activeError === true && payload?.terminal !== true && payload?.final !== true); + return false; } function normalizedFailureKind(value) { return String(value ?? "").trim().toLowerCase().replace(/_/gu, "-") || null; } +function numberOrNull(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + function agentRunTraceEvent(event = {}, backendSource = {}) { const source = backendSource && typeof backendSource === "object" ? backendSource : { backendProfile: backendSource }; const backendProfile = firstNonEmpty(event.backendProfile, source.backendProfile, source.providerProfile, source.agentRun?.backendProfile); diff --git a/internal/cloud/workbench-projection-writer.ts b/internal/cloud/workbench-projection-writer.ts index fdebce3c..7938f5cf 100644 --- a/internal/cloud/workbench-projection-writer.ts +++ b/internal/cloud/workbench-projection-writer.ts @@ -352,7 +352,8 @@ function normalizeMessageFact(message = {}, index, { traceId, sessionId, convers const appliesToContextTrace = role !== "user" && Boolean(traceId && messageTraceId && messageTraceId === traceId); const messageTiming = normalizeTimingProjection(message.timing) ?? normalizeTimingProjection(message); const contextTimingProjection = appliesToContextTrace ? normalizeTimingProjection(contextTiming) : null; - const timing = role === "user" ? messageTiming ?? emptyTimingProjection() : messageTiming ?? contextTimingProjection ?? emptyTimingProjection(); + const timing = role === "user" ? messageTiming ?? emptyTimingProjection() : contextTimingProjection ?? messageTiming ?? emptyTimingProjection(); + const terminalCompleted = messageTerminal && status === "completed"; return { ...message, messageId, @@ -376,6 +377,7 @@ function normalizeMessageFact(message = {}, index, { traceId, sessionId, convers durationMs: timing.durationMs, createdAt: timestampValue(message.createdAt ?? timestamp), updatedAt: timestampValue(message.updatedAt ?? timestamp), + ...(terminalCompleted ? { error: null, blocker: null, projection: null, projectionStatus: null, projectionHealth: null, staleMs: null } : {}), valuesPrinted: false }; } diff --git a/internal/cloud/workbench-turn-projection.ts b/internal/cloud/workbench-turn-projection.ts index 4abc1f21..42ae0c69 100644 --- a/internal/cloud/workbench-turn-projection.ts +++ b/internal/cloud/workbench-turn-projection.ts @@ -116,15 +116,16 @@ export function createWorkbenchTurnTimingProjection({ result = null, session = n export function projectionDiagnostics({ traceId = null, projection = null, result = null, trace = null, refreshError = null } = {}) { const turn = projection ?? createWorkbenchTurnProjection({ traceId, result, trace }); - const source = projectionDiagnosticSource(trace); - const blocker = refreshError ?? source?.blocker ?? trace?.blocker ?? result?.blocker ?? result?.error ?? null; + const sealedCompleted = turn.terminal === true && turn.status === "completed" && Boolean(turn.finalResponse?.text); + const source = sealedCompleted ? null : projectionDiagnosticSource(trace); + const blocker = sealedCompleted ? null : refreshError ?? source?.blocker ?? trace?.blocker ?? result?.blocker ?? result?.error ?? null; const hasProjectionInput = hasTraceProjection(trace) || Boolean(result || result?.agentRun); - const sourceStatus = normalizeProjectionStatus(source?.projectionStatus ?? trace?.projectionStatus); - const status = sourceStatus ?? (blocker ? "blocked" : turn.terminal ? "caught-up" : hasProjectionInput ? "projecting" : "unknown"); + const sourceStatus = sealedCompleted ? null : normalizeProjectionStatus(source?.projectionStatus ?? trace?.projectionStatus); + const status = sealedCompleted ? "caught-up" : sourceStatus ?? (blocker ? "blocked" : turn.terminal ? "caught-up" : hasProjectionInput ? "projecting" : "unknown"); const diagnostic = blocker ? diagnosticBlocker(blocker) : null; - const projectionHealth = normalizeProjectionHealth(source?.projectionHealth ?? trace?.projectionHealth) + const projectionHealth = sealedCompleted ? "healthy" : normalizeProjectionHealth(source?.projectionHealth ?? trace?.projectionHealth) ?? projectionHealthFor({ status, turn, hasProjectionInput, blocker: diagnostic }); - const staleMs = projectionStaleMs(source?.staleMs ?? trace?.staleMs, turn.updatedAt ?? trace?.updatedAt ?? result?.updatedAt); + const staleMs = sealedCompleted ? null : projectionStaleMs(source?.staleMs ?? trace?.staleMs, turn.updatedAt ?? trace?.updatedAt ?? result?.updatedAt); return { projectionStatus: status, projectionHealth, @@ -282,8 +283,18 @@ function retryableProviderInterruptionRecord(value) { const record = objectValue(value); if (!record) return null; const kind = normalizeWorkbenchStatus(record.failureKind ?? record.errorCode ?? record.code ?? record.name); - if (kind === "provider-stream-disconnected" && record.willRetry === true) { - return { failureKind: kind, willRetry: true, runId: textValue(record.runId) || null, commandId: textValue(record.commandId) || null, valuesRedacted: true }; + if ((kind === "provider-stream-disconnected" || kind === "provider-unavailable") && record.willRetry === true) { + return { + failureKind: kind, + willRetry: true, + retryAttempt: numberOrNull(record.retryAttempt), + retryMax: numberOrNull(record.retryMax), + retryDelayMs: numberOrNull(record.retryDelayMs), + nextRetryAt: textValue(record.nextRetryAt) || null, + runId: textValue(record.runId) || null, + commandId: textValue(record.commandId) || null, + valuesRedacted: true + }; } for (const nested of [record.payload, record.providerTrace, record.agentRun, record.error, record.blocker, record.terminalEvidence]) { const found = retryableProviderInterruptionRecord(nested); @@ -406,6 +417,11 @@ function textValue(value) { return String(value ?? "").trim(); } +function numberOrNull(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + function messageAuthorityTextValue(value) { const text = String(value ?? "").replace(/\r\n?/gu, "\n"); if (!text.trim() || text.trim() === "[object Object]") return ""; diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 7198dd62..97ebd769 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -530,8 +530,6 @@ export const useWorkbenchStore = defineStore("workbench", () => { appendActiveMessages(user, pending); startWorkbenchSubmitJourney({ traceId, sessionId, entry: submitEntry, backend: providerProfile.value, transport: "sse" }); chatPending.value = true; - currentRequest.value = { traceId, sessionId, threadId, status: "running" }; - projectOptimisticRunningTurn({ sessionId, threadId, traceId, userText: value }); rememberRecentDraft(value); recordActivity("submit"); const payload = { message: value, prompt: value, sessionId, threadId: providerThreadId, traceId, providerProfile: providerProfile.value, gatewayShellTimeoutMs: gatewayShellTimeoutMs.value, targetTraceId: composer.value.targetTraceId, steerTraceId }; @@ -555,16 +553,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { const canonicalTraceId = alignOptimisticTurnMessages(traceId, response.data); if (canonicalTraceId !== traceId) { reduceServerState({ type: "turn.forget", traceId }); - if (currentRequest.value?.traceId === traceId) { - currentRequest.value = { - ...currentRequest.value, - traceId: canonicalTraceId, - sessionId: firstNonEmptyString(response.data.sessionId, currentRequest.value.sessionId), - threadId: firstNonEmptyString(response.data.threadId, currentRequest.value.threadId), - status: response.data.status ?? currentRequest.value.status - }; - } } + currentRequest.value = { + traceId: canonicalTraceId, + sessionId: firstNonEmptyString(response.data.sessionId, sessionId), + threadId: firstNonEmptyString(response.data.threadId, threadId), + status: response.data.status ?? "running" + }; applyTurnStatusSnapshot(canonicalTraceId, response.data); void refreshSessions(sessionId); if ((response.data as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(response.data.status)) { @@ -720,7 +715,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { updatedAt: new Date().toISOString() } as NonNullable; const runnerTrace = mergeRunnerTrace(message.runnerTrace, nextTrace); - const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); + const error = isCompletedResultWithFinalResponse(result) ? null : normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null; const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); rememberTraceAuthority(runnerTrace); @@ -1043,7 +1038,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message; const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result); rememberTraceAuthority(runnerTrace); - const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); + const error = isCompletedResultWithFinalResponse(result) ? null : normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null; const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); return { ...message, ...messageTimingPatch(result), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; @@ -1082,7 +1077,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (!messageMatchesTraceAuthority(message, traceId, authoritySessionId, ownerSessionId)) return message; const runnerTrace = mergeTerminalResultTrace(message.runnerTrace, result); rememberTraceAuthority(runnerTrace); - const error = normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); + const error = isCompletedResultWithFinalResponse(result) ? null : normalizeAgentError(result.error ?? runnerTrace?.error ?? message.error); const agentRun = agentRunFromResult(result, runnerTrace) ?? agentRunFromMessage(message); const projection = projectionFromResult(result) ?? runnerTrace.projection ?? message.projection ?? null; return { ...message, ...messageTimingPatch(result), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error: error ?? message.error ?? null, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; @@ -1103,6 +1098,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const message = [...messages.value].reverse().find((item) => shouldApplyTraceToMessage(item, traceId, activeSessionId.value)) ?? null; const sessionId = normalizeWorkbenchSessionId(firstNonEmptyString(message?.sessionId, message?.runnerTrace?.sessionId, activeSessionId.value)); const existing = turnStatusAuthority.value[traceId]; + if (existing?.terminal === true && normalizedStatusText(existing.status) === "completed") return; const status = existing?.status ?? normalizedStatusText(message?.status) ?? null; const running = existing?.running === true || (!isTerminalMessageStatus(message?.status) && isTraceActiveStatus(message?.status)); const now = new Date().toISOString(); @@ -1739,6 +1735,14 @@ function projectedAgentMessageText(input: { status: unknown; finalText?: string return firstNonEmptyString(input.errorText, text) ?? ""; } +function isCompletedResultWithFinalResponse(value: unknown): boolean { + const record = recordValue(value); + if (!record) return false; + const status = normalizedStatusText(record.status); + if (status !== "completed") return false; + return Boolean(finalResponseText(record.finalResponse) ?? messageText(record.assistantText) ?? messageText(record.text) ?? messageText(record.summary)); +} + function isFailureTerminalStatus(value: unknown): boolean { return ["failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(value ?? "").trim().toLowerCase().replace(/_/gu, "-")); }