fix(web): v0.2 unify Code Agent trace polling path + persist fail (HWLAB #802) (#806)

Follow-up to PR #798 (#795 final). PR #798 closed the total-timeout /
hard-cap issue, but left two residual code-path inconsistencies that
#802 captures:

- Submit path and refresh path used different trace-polling code:
  submitMessage called `pollRunnerTrace` + `waitForAgentResult` (a
  dual function pair from pre-#798), while hydrate() never subscribed
  to the running trace at all. After a page refresh, the user saw
  the cloud-api "running" state but no live trace events.
- The submit fail branch only dispatched a local "Code Agent 超时"
  message; it never called `persistConversation`, so the failure was
  invisible after F5 — the cloud-api conversation would show
  "running" forever from the user's perspective.

This PR collapses both into a single `subscribeToTrace` entry point
and wires the refresh path into the same active subscription:

- `state/runner-trace.ts` — replace `waitForAgentResult` +
  `pollRunnerTrace` with a single `subscribeToTrace(config)` that
  handles both the result endpoint and the trace endpoint in one
  `for(;;)` loop. Calls `onActivity` on EVERY successful poll (not
  just on new events) so the per-poll inactivity window in
  `fetchJson` does not fire on a healthy polling loop. Terminal
  status -> `onComplete`; 5xx -> keep polling, no activity; 4xx ->
  `onInfrastructureError`; `signal.aborted` -> silent return. The
  hard-cap / 4x / `TRACE_HARD_CAP_ATTEMPTS` machinery is gone (was
  already removed by #798).
- `state/trace-reattach.ts` — new `useTraceReattach` hook that
  subscribes to the running trace on hydrate (same `subscribeToTrace`
  call as submitMessage). Reuses an existing agent message slot for
  the traceId if one exists; otherwise creates a placeholder. Persists
  on both onComplete and onInfrastructureError. Path unification
  achieved: one entry, two callers.
- `state/workbench.ts`:
  - `submitMessage` calls `subscribeToTrace` directly with a
    single-line `onActivity: () => updateActivity()` plus
    `onComplete` / `onInfrastructureError` callbacks. The
    `onInfrastructureError` branch now ALSO calls
    `persistConversation`, persisting the fail state to cloud-api.
  - The re-attach useEffect is replaced with a one-liner
    `useTraceReattach({...})` call (workbench.ts stays under the
    400-line guard).
- `scripts/fetchJson-inactivity.test.ts` — third case tightened:
  200ms-cadence activity for 10s with `timeoutMs: 3000`; the
  request must live the full 10s and abort only after activity
  stops. Asserts `elapsed >= 10_000`.
- `docs/reference/spec-v02-hwlab-cloud-web.md` — the "Code Agent
  Timeout Model" section is renamed "Code Agent Timeout Model 与
  Path Unification" and now also pins: (a) `subscribeToTrace` is
  the only entry; `waitForAgentResult` / `pollRunnerTrace` /
  `TRACE_HARD_CAP_ATTEMPTS` MUST NOT exist; (b) `useTraceReattach`
  MUST be wired in; (c) `persistConversation` is required in both
  onComplete and onInfrastructureError; (d) `state.workspace.activeTraceId`
  changes MUST re-attach. Includes a distilled history of
  #775 / #777 / #791 / #795 / #797 / #798 / #802.

Verification
- `bun run scripts/tsc-check.ts` → strict React TSX, 0 explicit any
- `bun run check` → 5 pass / 0 fail (3 inactivity + 2 dist-contract)
- `bun run build` → 9 dist files verified fresh; `app.js`
  238460 → 240990 B
- `state/trace-reattach.ts` is now wired in; `state/runner-trace.ts`
  exports only `subscribeToTrace` (verified by tsc + grep)
- `workbench.ts` line count 400 (under the 400-line guard)
- New 200ms-cadence 10s test passes; old 100ms-cadence 2s test
  and total-timeout 200ms test still pass.

Refs: HWLAB #802, #795, #777, #791, #798

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 11:08:20 +08:00
committed by GitHub
parent 088414747b
commit 10c03e6c32
5 changed files with 272 additions and 129 deletions
+29 -1
View File
@@ -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` 串到 `<CommandBar onTyping>`+ `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 之前 abortplaceholder 消息用 `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 <traceId> --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)` 兜底 capPR #798 删 hard-cap 改成 `for(;;)`,但前端 fail 状态机没改(fail 消息只入本地 statecloud-api 不知情)+ hydrate path 没有 re-attach 主动订阅。
- #802 收口:用 `subscribeToTrace` 一个入口统一 submit + hydrate`onActivity` 在每次成功 poll 上调让 inactivity 永远不 firefail 消息也 `persistConversation`
权威规则:Cloud Web 的 `state/workbench.ts` `submitMessage``state/runner-trace.ts` `waitForAgentResult` / `pollRunnerTrace` 必须遵守以下 contract,禁止用任何 wall-clock / 轮询次数 / 4× / +60s 兜底来"安全网"式杀掉活跃 turn
@@ -11,18 +11,6 @@ if (!win.window) {
};
}
function bindSignalFetch(): typeof fetch {
return (async (_input, init) => {
return await new Promise<Response>((_, 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<Response>((_, 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 inactivityactivityRef 每 100ms 推一次活动,stop 后 ~2
}
});
test("fetchJson inactivitytrace 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();
+116 -91
View File
@@ -89,109 +89,134 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
} as AgentChatResultResponse;
}
export async function waitForAgentResult(
initial: AgentChatResponse,
totalTimeoutMs: number,
activityRef?: ActivityRefSource
): Promise<AgentChatResultResponse | null> {
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<void>((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<TraceSnapshot | null> {
export async function subscribeToTrace(config: TraceSubscriptionConfig): Promise<void> {
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<void>((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<TraceSnapshot | null> {
@@ -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<typeof subscribeToTrace>[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]);
}
+43 -18
View File
@@ -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<TraceSnapshot | null>(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 请求失败" });