diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 55609c7d..258d68dd 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -26,7 +26,35 @@ ## 内部架构 -## Code Agent Timeout Model(与 #795 PR 同步固化) +## Code Agent Timeout Model 与 Path Unification(与 #795 / #802 同步固化) + +权威规则:Cloud Web 的 `state/workbench.ts` `submitMessage` 和 `state/trace-reattach.ts` `useTraceReattach` 必须遵守以下 contract,禁止分裂成 normal / refresh 两套不同代码路径,禁止任何 wall-clock / 轮询次数 / 4× / +60s 兜底来"安全网"式杀掉活跃 turn: + +- **单一入口 `subscribeToTrace`**:`state/runner-trace.ts::subscribeToTrace` 是 trace 轮询的唯一入口。`waitForAgentResult` + `pollRunnerTrace` 旧 dual 函数对已经删除(HWLAB #802 收口)。`submitMessage` 在 POST 拿到 202 后调它;`useTraceReattach` 在 `state.workspace.activeTraceId` 变化时调它(路径归一化:refresh 路径也走同一份 active polling,不再是被动显示)。 +- **路径归一化**:submit path 和 hydrate path 走同一份 `subscribeToTrace` 逻辑。`onActivity` 在每一次**成功**的 result poll 和 trace poll 上都调(不是只在新 snapshot 上调),所以在正常操作下 per-poll inactivity 窗口**永远不 fire**。 +- **失败持久化**:`subscribeToTrace` 的 `onInfrastructureError`(4xx / non-5xx 非 200)会被 `submitMessage` 和 `useTraceReattach` 同时调 `persistConversation`,让 cloud-api 留痕前端超时/失败。这样 F5 之后用户能直接看到前端 fail 上下文,不只是 backend completed。 +- **唯一 abort 信号**:per-poll `fetchJson` 的 inactivity-timeout,由 `activityRef` 驱动。`activityRef.lastActivityAt` 在 `totalTimeoutMs` 窗口内被刷新就不 abort。 +- **没有 total-timeout / 轮询次数上限**:外层 `for(;;)` 循环没有任何累计计时跳出条件。per-poll `getAgentChatResult(url, totalTimeoutMs, activityRef)` 直接传 `totalTimeoutMs`,**不**随墙钟缩小成 `totalTimeoutMs - (Date.now() - startedAt)`。 +- **没有 `Math.max(totalTimeoutMs * N, ...)` 兜底**:之前的 `max(4x, +60s)` 已经按 PR 反馈彻底删除(PR #798)。 +- **activity 来源**:`store.recordActivity()`(`App.tsx` 串到 ``)+ `submitMessage` `updateActivity()` on submit + `subscribeToTrace` 的 `onActivity` 在每次成功 poll 上调(保证 inactivity 不被误触)。 +- **Runaway 保护责任**:用户在 Web 上有 cancel / steer / 关 tab 三个明确逃生口;CLI 上有 `--timeout-ms` 让调用方自行决定。**不**在浏览器侧加隐式硬上限。 +- **Hydrate 路径必须 re-attach running trace**:`useTraceReattach` 在 mount / `activeTraceId` 变化时通过 `subscribeToTrace` 主动订阅到 terminal;不在 `useEffect` cleanup 之前 abort;placeholder 消息用 `nextProtocolId("msg")` 创建。 +- **POST 失败分类**:cold-start runner / network down → `onInfrastructureError` 走 `persistConversation` fail 分支;不要把"POST 超时"和"backend 5xx"写死成同一字符串。 + +不变量(用于 #802 / 未来回归测试): + +1. `bun run --cwd web/hwlab-cloud-web check` 全过:`bun run scripts/tsc-check.ts` 严格 React TSX 0 explicit any;`bun test` 5 pass / 0 fail。 +2. `scripts/fetchJson-inactivity.test.ts` 含 `elapsed >= 10_000` 断言(10s 持续活动窗口内永远活)。 +3. `state/runner-trace.ts` 只能导出 `subscribeToTrace`;`waitForAgentResult` / `pollRunnerTrace` / `TRACE_HARD_CAP_ATTEMPTS` 必须不存在。 +4. `state/trace-reattach.ts` 必须存在并被 `workbench.ts` 引用;`workbench.ts` 不再直接 `await subscribeToTrace` 以外的方式做 trace 轮询。 +5. live `http://74.48.78.17:19666/app.js` 不含 `Math.max(t*4`、`while (...)` 配 `totalTimeoutMs` 的 `>=` 比较、`> 120` 之类的硬上限 pattern。 +6. CLI end-to-end:`hwlab-cli client agent send --wait` 跑通 + refresh 后用 `client agent trace --render web` 拿同一份 row。 + +历史与收敛(蒸馏自 #775 / #777 / #791 / #795 / #797 / #798 / #802 的过程): + +- 迁移前 `app-device-pod.ts:fetchJson` 走 inactivity-timeout(`scheduleTimeout` 每秒重算 `remainingMs = timeoutMs - (now - lastActivityAt)`)。React 迁移后 #777 把 `client.ts` 加了 `ActivityRef` 抽象,但 `workbench.ts` / `runner-trace.ts` 没把 ref 串起来,于是 Web UI 退化成 total-timeout,#795 出现"bootshar 9.7s 跑完但 Web 37s 处 timeout"的回归。 +- #795 PR #797 修了一版但留了 `max(4x, +60s)` 兜底 cap;PR #798 删 hard-cap 改成 `for(;;)`,但前端 fail 状态机没改(fail 消息只入本地 state,cloud-api 不知情)+ hydrate path 没有 re-attach 主动订阅。 +- #802 收口:用 `subscribeToTrace` 一个入口统一 submit + hydrate;`onActivity` 在每次成功 poll 上调让 inactivity 永远不 fire;fail 消息也 `persistConversation`。 权威规则:Cloud Web 的 `state/workbench.ts` `submitMessage` 和 `state/runner-trace.ts` `waitForAgentResult` / `pollRunnerTrace` 必须遵守以下 contract,禁止用任何 wall-clock / 轮询次数 / 4× / +60s 兜底来"安全网"式杀掉活跃 turn: diff --git a/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts b/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts index a3fb5c35..d8f1a1c4 100644 --- a/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts +++ b/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts @@ -11,18 +11,6 @@ if (!win.window) { }; } -function bindSignalFetch(): typeof fetch { - return (async (_input, init) => { - return await new Promise((_, reject) => { - const signal = init?.signal; - if (signal) { - if (signal.aborted) reject(new DOMException("aborted", "AbortError")); - else signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); - } - }); - }) as typeof fetch; -} - function buildMutableActivityRef(initial: number): { ref: ActivityRef; update: (at: number) => void } { const ref: ActivityRef = { lastActivityAt: initial, @@ -39,6 +27,18 @@ function buildMutableActivityRef(initial: number): { ref: ActivityRef; update: ( }; } +function bindSignalFetch(): typeof fetch { + return (async (_input, init) => { + return await new Promise((_, reject) => { + const signal = init?.signal; + if (signal) { + if (signal.aborted) reject(new DOMException("aborted", "AbortError")); + else signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); + } + }); + }) as typeof fetch; +} + test("fetchJson 总超时:未传 activityRef 时 timeoutMs 走总窗口", { timeout: 5000 }, async () => { const originalFetch = globalThis.fetch; globalThis.fetch = bindSignalFetch(); @@ -81,13 +81,13 @@ test("fetchJson inactivity:activityRef 每 100ms 推一次活动,stop 后 ~2 } }); -test("fetchJson inactivity:trace poll 风格的 1.5s 间隔在 6.5s 内不会触发总超时", { timeout: 20000 }, async () => { - // HWLAB #795 regression + PR feedback: the runner-trace poll loop must - // NOT use any client-side total timeout. The fetchJson request aborts - // only on inactivity (no activity for timeoutMs). To make the contract - // explicit, this case runs 200ms-cadence activity for 10 seconds with - // a 3-second inactivity window set on the request — the request must - // keep living for the full 10 seconds even though 3s << 10s. +test("fetchJson 无 total timeout:10s 持续活动窗口内不会被总上限打掉", { timeout: 30000 }, async () => { + // HWLAB #795 PR feedback + #802: the per-request inactivity window is the + // SOLE abort signal. There is no client-side total-timeout ceiling on + // waitForAgentResult / pollRunnerTrace. A continuous 200ms-cadence + // activity stream must keep a fetchJson request alive well past the + // 3s inactivity window set on the test, and the test only ends when + // the activity stream stops. const { ref, update } = buildMutableActivityRef(Date.now()); const originalFetch = globalThis.fetch; globalThis.fetch = bindSignalFetch(); diff --git a/web/hwlab-cloud-web/src/state/runner-trace.ts b/web/hwlab-cloud-web/src/state/runner-trace.ts index cdb58c2c..98bfe102 100644 --- a/web/hwlab-cloud-web/src/state/runner-trace.ts +++ b/web/hwlab-cloud-web/src/state/runner-trace.ts @@ -89,109 +89,134 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac } as AgentChatResultResponse; } -export async function waitForAgentResult( - initial: AgentChatResponse, - totalTimeoutMs: number, - activityRef?: ActivityRefSource -): Promise { - if (!isResultUrlStatus(initial) || !initial.resultUrl) { - return isTerminalStatus(initial.status) ? initial : null; - } - if (isTerminalStatus(initial.status)) return initial; - // No total-timeout: the per-poll inactivity-timeout in fetchJson (driven - // by activityRef) is the sole abort signal. Hard caps were removed - // because they reintroduced the HWLAB #795 regression class — a long - // operation that keeps emitting trace events could still be killed by - // the outer `while` ceiling even with an active activity ref. The user - // can still cancel / steer / use the session button; runaway protection - // is the caller's responsibility, not a browser-side hard cap. See - // docs/reference/spec-v02-hwlab-cloud-web.md "Code Agent Timeout Model". - // TRACE_HARD_CAP_ATTEMPTS is intentionally not used here: there is no - // client-side ceiling on how many times we may poll, only on how long - // a single fetchJson may go without activity. If the upstream never - // returns a terminal status and never goes silent, the loop continues - // until the user cancels / navigates away / the tab is closed. - for (;;) { - await new Promise((resolve) => window.setTimeout(resolve, TRACE_POLL_INTERVAL_MS)); - const polled = await api.getAgentChatResult( - initial.resultUrl, - // Pass the full totalTimeoutMs so the per-poll window is a pure - // inactivity budget; do not shrink it as wall-clock time passes. - totalTimeoutMs, - activityRef ?? null - ); - if (polled.ok && polled.data) { - if (isTerminalStatus(polled.data.status)) return polled.data; - } else if (!polled.ok && polled.status >= 500) { - // server hiccup; keep polling — only the inactivity window can abort - } else if (!polled.ok) { - return null; - } - } - return null; +/** + * Unified trace subscription (HWLAB #802). One entry point used by both + * `submitMessage` (new submit) and `hydrate` (re-attach to a running trace + * after page refresh). The previous `waitForAgentResult` + `pollRunnerTrace` + * pair was a normal-path-only dual function set; refresh path was passive + * (no pollRunnerTrace / waitForAgentResult subscription) which is exactly + * the path divergence that #802 closes. + * + * Design contract (path unification + no-false-timeout): + * - `for(;;)` loop, no total-timeout, no attempt cap. + * - `onActivity` is called on EVERY successful poll (not just on new + * events) so the per-poll inactivity window in `fetchJson` only fires + * when the polling itself is broken (network down or backend + * unresponsive for `inactivityTimeoutMs`). In normal operation the + * 1.5s polling cadence keeps `lastActivityAt` fresh and the inactivity + * abort never triggers. + * - Terminal status -> `onComplete` and return. + * - 5xx -> keep polling, do NOT call `onActivity` (the inactivity + * window should detect the real backend outage). + * - 4xx (non-5xx) -> `onInfrastructureError` and return. + * - `signal.aborted` -> silent return, no callbacks. + */ +export interface TraceSubscriptionConfig { + traceId: string; + initial: AgentChatResponse; + onActivity: () => void; + onSnapshot: (snapshot: TraceSnapshot) => void; + onComplete: (result: AgentChatResultResponse) => void; + onInfrastructureError: (error: string) => void; + signal: AbortSignal; + inactivityTimeoutMs: number; } -export async function pollRunnerTrace( - resultUrl: string, - traceId: string, - onSnapshot: (snapshot: TraceSnapshot) => void, - totalTimeoutMs: number, - activityRef?: ActivityRefSource -): Promise { +export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise { + const { traceId, initial, onActivity, onSnapshot, onComplete, onInfrastructureError, signal, inactivityTimeoutMs } = config; + + // If the initial response is already terminal, fire onComplete and return. + if (isTerminalStatus(initial.status)) { + onComplete(initial as AgentChatResultResponse); + return; + } + if (!initial.resultUrl) { + onInfrastructureError("Code Agent initial response is missing resultUrl"); + return; + } + + const resultUrl = initial.resultUrl; const traceUrl = traceUrlFromResultUrl(resultUrl, traceId); let lastEventCount = -1; let lastStatus: string | null = null; let lastUpdatedAt: string | null = null; - // See waitForAgentResult above — pure inactivity-timeout, no total cap. + for (;;) { + if (signal.aborted) return; await new Promise((resolve) => window.setTimeout(resolve, TRACE_POLL_INTERVAL_MS)); - const polled = await api.getAgentChatResult( - traceUrl, - totalTimeoutMs, - activityRef ?? null - ); - if (!polled.ok || !polled.data) continue; - const events = Array.isArray(polled.data.events) ? polled.data.events : []; - const status = String(polled.data.status ?? ""); - const eventCount = Number(polled.data.eventCount ?? events.length) || 0; - const updatedAt: string | null = typeof polled.data.updatedAt === "string" ? polled.data.updatedAt : null; - if (eventCount !== lastEventCount || status !== lastStatus || updatedAt !== lastUpdatedAt) { - lastEventCount = eventCount; - lastStatus = status; - lastUpdatedAt = updatedAt; - const lastEvent = events[events.length - 1]; - // Note activity whenever a fresh snapshot arrives so the inactivity clock - // resets; this is what makes the user-configured codeAgentTimeoutMs an - // inactivity budget instead of a total budget (HWLAB #795 regression). - if (activityRef) { - const ref: ActivityRef | null = activityRef(); - if (ref && ref.lastActivityAt > 0) { - ref.lastActivityAt = Date.now(); - ref.lastEventLabel = String(lastEvent?.label ?? lastEvent?.type ?? polled.data.lastEventLabel ?? ""); - ref.waitingFor = String(polled.data.waitingFor ?? ref.waitingFor ?? null); - } + if (signal.aborted) return; + + // Step 1: poll the result endpoint for terminal status. + const resultPolled = await api.getAgentChatResult(resultUrl, inactivityTimeoutMs, null); + if (signal.aborted) return; + + if (resultPolled.ok && resultPolled.data) { + // Every successful poll keeps the activity clock alive so the + // per-poll inactivity window does not fire on a healthy polling + // loop (HWLAB #802: this is what makes the path "never trigger + // a timeout" in normal operation). + onActivity(); + if (isTerminalStatus(resultPolled.data.status)) { + onComplete(resultPolled.data); + return; } - const snapshot: TraceSnapshot = { - traceId, - status: status || undefined, - sessionId: polled.data.sessionId ?? null, - threadId: polled.data.threadId ?? null, - events, - eventCount, - eventsCompacted: false, - agentRun: polled.data.agentRun, - lastEventLabel: String(lastEvent?.label ?? lastEvent?.type ?? polled.data.lastEventLabel ?? ""), - waitingFor: String(polled.data.waitingFor ?? ""), - updatedAt: updatedAt ?? new Date().toISOString() - }; - onSnapshot(snapshot); + } else if (!resultPolled.ok && resultPolled.status >= 500) { + // server hiccup: keep polling, do NOT call onActivity — the + // inactivity window should detect a real backend outage. + } else if (!resultPolled.ok) { + onInfrastructureError(resultPolled.error ?? "Code Agent result poll failed (non-5xx)"); + return; } - if (isTerminalStatus(status)) { - const last = events.at(-1); - return { ...polled.data, traceId, events, eventCount, lastEventLabel: String(last?.label ?? last?.type ?? "") }; + + // Step 2: poll the trace endpoint for live events. + if (signal.aborted) return; + const tracePolled = await api.getAgentChatResult(traceUrl, inactivityTimeoutMs, null); + if (signal.aborted) return; + + if (tracePolled.ok && tracePolled.data) { + onActivity(); + const events = Array.isArray(tracePolled.data.events) ? tracePolled.data.events : []; + const status = String(tracePolled.data.status ?? ""); + const eventCount = Number(tracePolled.data.eventCount ?? events.length) || 0; + const updatedAt: string | null = typeof tracePolled.data.updatedAt === "string" ? tracePolled.data.updatedAt : null; + if (eventCount !== lastEventCount || status !== lastStatus || updatedAt !== lastUpdatedAt) { + lastEventCount = eventCount; + lastStatus = status; + lastUpdatedAt = updatedAt; + const lastEvent = events[events.length - 1]; + const snapshot: TraceSnapshot = { + traceId, + status: status || undefined, + sessionId: tracePolled.data.sessionId ?? null, + threadId: tracePolled.data.threadId ?? null, + events, + eventCount, + eventsCompacted: false, + agentRun: tracePolled.data.agentRun, + lastEventLabel: String(lastEvent?.label ?? lastEvent?.type ?? tracePolled.data.lastEventLabel ?? ""), + waitingFor: String(tracePolled.data.waitingFor ?? ""), + updatedAt: updatedAt ?? new Date().toISOString() + }; + onSnapshot(snapshot); + } + if (isTerminalStatus(status)) { + const last = events.at(-1); + onComplete({ + ...tracePolled.data, + traceId, + events, + eventCount, + lastEventLabel: String(last?.label ?? last?.type ?? "") + } as AgentChatResultResponse); + return; + } + } else if (!tracePolled.ok && tracePolled.status >= 500) { + // server hiccup: keep polling, no activity update. + } else if (!tracePolled.ok) { + onInfrastructureError(tracePolled.error ?? "Code Agent trace poll failed (non-5xx)"); + return; } } - return null; } export async function replayFullTrace(traceId: string, totalTimeoutMs = 15000): Promise { diff --git a/web/hwlab-cloud-web/src/state/trace-reattach.ts b/web/hwlab-cloud-web/src/state/trace-reattach.ts new file mode 100644 index 00000000..2e5a866c --- /dev/null +++ b/web/hwlab-cloud-web/src/state/trace-reattach.ts @@ -0,0 +1,65 @@ +import { useEffect } from "react"; +import { firstNonEmptyString, nextProtocolId } from "../utils"; +import { makeMessage, messageFromAgentResponse, persistConversation } from "./conversation"; +import { mergeTraceResults, snapshotToRunnerTrace, subscribeToTrace, type TraceSnapshot } from "./runner-trace"; +import type { Action } from "./workbench-reducer"; +import type { WorkbenchState } from "./workbench-state"; +import type { AgentChatResultResponse, ChatMessage, WorkspaceRecord } from "../types/domain"; + +export interface UseTraceReattachConfig { + enabled: boolean; + activeTraceId: string | null; + codeAgentTimeoutMs: number; + chatPending: boolean; + workspace: WorkbenchState["workspace"]; + messages: ChatMessage[]; + onActivity: () => void; + onDispatch: (action: Action) => void; + onPersist: typeof persistConversation; +} + +export function useTraceReattach(config: UseTraceReattachConfig): void { + const { enabled, activeTraceId, codeAgentTimeoutMs, chatPending, workspace, messages, onActivity, onDispatch, onPersist } = config; + useEffect(() => { + if (!enabled || !activeTraceId || chatPending) return; + const resultUrl = `/v1/agent/chat/result/${encodeURIComponent(activeTraceId)}`; + const conversationId = firstNonEmptyString(workspace?.selectedConversationId, workspace?.workspace?.selectedConversationId) ?? ""; + const sessionId = workspace?.selectedAgentSessionId ?? workspace?.workspace?.selectedAgentSessionId ?? null; + const threadId = workspace?.workspace?.threadId ?? null; + const existing = messages.find((m) => m.traceId === activeTraceId && m.role === "agent"); + const placeholderId = existing?.id ?? nextProtocolId("msg"); + if (!existing) { + const placeholder = makeMessage("agent", "Re-attaching to running turn…", "running", { traceId: activeTraceId, conversationId, sessionId, threadId, title: "Code Agent 处理中" }); + const user = { ...placeholder, role: "user" as const, text: "(re-attach)", title: "用户" } as ChatMessage; + onDispatch({ type: "message:pending", user, pending: placeholder, request: { traceId: activeTraceId, conversationId, sessionId, threadId } }); + } + const controller = new AbortController(); + let lastSnapshot: TraceSnapshot | null = null; + const ws: WorkspaceRecord | null = workspace; + void subscribeToTrace({ + traceId: activeTraceId, + initial: { status: "running", resultUrl, traceId: activeTraceId } as Parameters[0]["initial"], + inactivityTimeoutMs: codeAgentTimeoutMs, + signal: controller.signal, + onActivity: () => onActivity(), + onSnapshot: (snapshot) => { + lastSnapshot = snapshot; + onDispatch({ type: "message:trace", messageId: placeholderId, trace: snapshotToRunnerTrace(snapshot) }); + }, + onComplete: (result: AgentChatResultResponse) => { + const stub = messages.find((m) => m.id === placeholderId) ?? ({ id: placeholderId, role: "agent" as const, text: "", status: "running" as const, traceId: activeTraceId, conversationId, sessionId, threadId, createdAt: new Date().toISOString() } as ChatMessage); + const completed = messageFromAgentResponse(placeholderId, stub, mergeTraceResults(result, lastSnapshot)); + onDispatch({ type: "message:complete", messageId: placeholderId, message: completed, availability: result.availability ?? null, workspace: ws ?? undefined }); + void onPersist({ workspace: ws, conversationId, sessionId: result.sessionId ?? sessionId, threadId: result.threadId ?? threadId, messages: [...messages, completed] }); + controller.abort(); + }, + onInfrastructureError: (error) => { + const failed = makeMessage("agent", `Code Agent re-attach ${codeAgentTimeoutMs}ms 无活动(${error})`, "failed", { traceId: activeTraceId, conversationId, sessionId, threadId, title: "Code Agent 超时" }); + onDispatch({ type: "message:fail", messageId: placeholderId, message: failed }); + void onPersist({ workspace: ws, conversationId, sessionId, threadId, messages: [...messages, failed] }); + controller.abort(); + } + }).finally(() => controller.abort()); + return () => controller.abort(); + }, [enabled, activeTraceId, chatPending, codeAgentTimeoutMs, workspace, messages, onActivity, onDispatch, onPersist]); +} diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts index 96968074..ca4f7ae1 100644 --- a/web/hwlab-cloud-web/src/state/workbench.ts +++ b/web/hwlab-cloud-web/src/state/workbench.ts @@ -33,15 +33,15 @@ import { import type { ComposerState, WorkbenchState } from "./workbench-state"; import { conversationsToTabs, ensureWorkspace, makeMessage, messageFromAgentResponse, messagesFromWorkspace, persistConversation } from "./conversation"; import { - mergeRunnerTrace, isResultUrlStatus, isTerminalStatus, + mergeRunnerTrace, mergeTraceResults, - pollRunnerTrace, snapshotToRunnerTrace, - type TraceSnapshot, - waitForAgentResult + subscribeToTrace, + type TraceSnapshot } from "./runner-trace"; +import { useTraceReattach } from "./trace-reattach"; import { cancelAgentMessageAction, replayAgentTraceAction, @@ -186,6 +186,9 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { return () => window.clearInterval(timer); }, [enabled, refreshLive]); + // HWLAB #802: re-attach to a running trace on hydrate. + useTraceReattach({ enabled, activeTraceId: firstNonEmptyString(state.workspace?.activeTraceId, state.workspace?.workspace?.activeTraceId), codeAgentTimeoutMs: state.codeAgentTimeoutMs, chatPending: state.chatPending, workspace: state.workspace, messages: state.messages, onActivity: updateActivity, onDispatch: dispatch, onPersist: persistConversation }); + const createSession = useCallback(async () => { const workspace = await ensureWorkspace(state.workspace); if (!workspace) return; @@ -267,23 +270,45 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { if (conversations.ok) dispatch({ type: "conversation:list", conversations: conversations.data?.conversations ?? [] }); }; if (response.ok && response.data) { - const subscribeTrace = (response.data.resultUrl && isResultUrlStatus(response.data)) - ? pollRunnerTrace(response.data.resultUrl, traceId, (snapshot) => { - // Trace snapshot update counts as activity; pollRunnerTrace also - // bumps the ref internally when a fresh snapshot arrives. - updateActivity(); + // HWLAB #802: single unified path. onActivity on every successful + // poll keeps the per-poll inactivity window from firing on a + // healthy loop. Persist on both onComplete AND onInfrastructureError + // so cloud-api records the fail state too. + const subscriptionController = new AbortController(); + let lastSnapshot: TraceSnapshot | null = null; + let terminalResult: AgentChatResultResponse | null = null; + try { + await subscribeToTrace({ + traceId, + initial: response.data, + inactivityTimeoutMs: state.codeAgentTimeoutMs, + signal: subscriptionController.signal, + onActivity: () => updateActivity(), + onSnapshot: (snapshot) => { + lastSnapshot = snapshot; dispatch({ type: "message:trace", messageId: pending.id, trace: snapshotToRunnerTrace(snapshot) }); - }, state.codeAgentTimeoutMs, submitActivityRef) - : Promise.resolve(null); - const terminal = await waitForAgentResult(response.data, state.codeAgentTimeoutMs, submitActivityRef); - const traceTerminal = await subscribeTrace; - if (terminal) { - await finalize(mergeTraceResults(terminal, traceTerminal), response.data.availability ?? null); + }, + onComplete: (result) => { + terminalResult = result; + }, + onInfrastructureError: (error) => { + // HWLAB #802: persist fail too. The user refreshing should see + // the frontend-reported failure, not a silent "running" state. + const failed = makeMessage("agent", `Code Agent 在 ${state.codeAgentTimeoutMs}ms 无活动后中断(${error});可以继续 steer 或查看 trace。`, "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 超时" }); + dispatch({ type: "message:fail", messageId: pending.id, message: failed }); + void persistConversation({ workspace: state.workspace, conversationId, sessionId, threadId, messages: [...state.messages, user, failed] }); + subscriptionController.abort(); + } + }); + } finally { + subscriptionController.abort(); + } + if (terminalResult) { + const traceTerminal = lastSnapshot; + await finalize(mergeTraceResults(terminalResult, traceTerminal), response.data.availability ?? null); } else if (isResultUrlStatus(response.data) && isTerminalStatus(response.data.status)) { + const traceTerminal = lastSnapshot; await finalize(mergeTraceResults(response.data, traceTerminal), response.data.availability ?? null); - } else { - const failed = makeMessage("agent", "Code Agent 在超时内未返回可显示正文;可以继续 steer 或查看 trace。", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 超时" }); - dispatch({ type: "message:fail", messageId: pending.id, message: failed }); } } else { const failed = makeMessage("agent", response.error ?? "Code Agent 请求失败", "failed", { traceId, conversationId, sessionId, threadId, title: "Code Agent 请求失败" });