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:
@@ -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 之前 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 <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)` 兜底 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:
|
||||
|
||||
|
||||
@@ -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 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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 请求失败" });
|
||||
|
||||
Reference in New Issue
Block a user