From 909715c335b306ae19271ee8edabd22701206696 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:01:42 +0800 Subject: [PATCH] fix(web): v0.2 wire workbench activityRef end-to-end (HWLAB #795) (#797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The React migration (#756) and the 7-round #775 patch left the `fetchJson` inactivity-timeout surface half-wired: `client.ts` and the `api.*` helpers accepted an `ActivityRef`, but `state/workbench.ts` `submitMessage` and `state/runner-trace.ts` `waitForAgentResult` / `pollRunnerTrace` never built or forwarded one. The Web UI was therefore falling back to a **total** timeout: a long-running Code Agent operation (e.g. `bootsharp` that finished in ~9.7s but the assistant message chain took ~37s) was killed at `codeAgentTimeoutMs` even while trace events kept flowing, producing "Code Agent 在超时内未返回可显示正文". User-facing symptom in #795 is exactly this: the 4th turn on `D601-F103-V2 bootshar[p]` timed out in the Web UI while the backend completed the work and emitted `agentrun:result:completed`. Changes - `state/workbench.ts` adds a `useRef`-backed `lastActivityAtRef`, `updateActivity`, `buildActivityRef` and exposes `recordActivity` on the store. `submitMessage` now bumps activity on the initial submit, on every trace snapshot, and threads a single `submitActivityRef` through `api.sendAgentMessage` / `api.steerAgentMessage`, `pollRunnerTrace`, and `waitForAgentResult`. - `state/runner-trace.ts` `waitForAgentResult` and `pollRunnerTrace` accept an `ActivityRefSource`, pass it to `api.getAgentChatResult` so each per-poll `fetchJson` uses the inactivity-timeout branch, and `pollRunnerTrace` mutates the ref's `lastActivityAt` / `lastEventLabel` / `waitingFor` whenever a fresh snapshot arrives. The outer `while`-loop hard ceiling becomes `max(totalTimeoutMs * 4, totalTimeoutMs + 60s)` as a safety net for runaway operations; the per-request inactivity-timeout (driven by `activityRef`) is the primary abort signal, matching the pre-React `app-device-pod.ts:fetchJson` contract. - `components/command-bar/CommandBar.tsx` adds an `onTyping` prop and fires it from the textarea `onChange` so user-typing resets the inactivity clock. - `App.tsx` adds a stable `noteActivity` (`useCallback`) wired to `store.recordActivity` and passes it as `onTyping` to ``. Test - `scripts/fetchJson-inactivity.test.ts` adds three cases: 1. `fetchJson` without `activityRef` honours the total window. 2. `fetchJson` with 100ms-cadence activity survives the configured window and aborts ~1-2s after activity stops with the "无新活动" error (the HWLAB #795 regression assertion). 3. `fetchJson` with 1.5s-cadence activity (the actual `pollRunnerTrace` interval) survives past the 5s window and only aborts after activity stops at 6.5s. Verification - `bun run scripts/tsc-check.ts` passes (strict React TSX, 0 explicit any) - `bun run check` 5 pass / 0 fail (3 new + 2 dist-contract) - `bun run build` 9 dist files verified fresh; `app.js` 234676 → 238740 B - orphan-listener check (per HWLAB #748): no `el.X.addEventListener` in `*.tsx` whose X is not in the `el` literal. Refs - HWLAB #795 (this issue) - HWLAB #775 round 1 #777 added the `fetchJson` `activityRef` plumbing in `client.ts` but stopped short of the workbench / runner-trace layer - HWLAB #756 React migration baseline Co-authored-by: Codex --- .../scripts/fetchJson-inactivity.test.ts | 112 ++++++++++++++++++ web/hwlab-cloud-web/src/App.tsx | 13 +- .../src/components/command-bar/CommandBar.tsx | 13 +- web/hwlab-cloud-web/src/state/runner-trace.ts | 51 ++++++-- web/hwlab-cloud-web/src/state/workbench.ts | 56 ++++++++- 5 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts diff --git a/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts b/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts new file mode 100644 index 00000000..32dbfe3d --- /dev/null +++ b/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ActivityRef } from "../src/services/api/client.ts"; + +const win = globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }; +if (!win.window) { + win.window = { + setTimeout: (handler, delay) => setTimeout(handler, delay), + clearTimeout: (timer) => clearTimeout(timer as unknown as ReturnType) + }; +} + +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, + lastActivityIso: new Date(initial).toISOString(), + waitingFor: "test", + lastEventLabel: null + }; + return { + ref, + update: (at: number) => { + ref.lastActivityAt = at; + ref.lastActivityIso = new Date(at).toISOString(); + } + }; +} + +test("fetchJson 总超时:未传 activityRef 时 timeoutMs 走总窗口", { timeout: 5000 }, async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = bindSignalFetch(); + try { + const { fetchJson } = await import("../src/services/api/client.ts"); + const startedAt = Date.now(); + const result = await fetchJson("/v1/agent/chat", { timeoutMs: 200, timeoutName: "test-total" }); + const elapsed = Date.now() - startedAt; + assert.equal(result.ok, false, "fetch should fail when no activity is supplied"); + assert.match(result.error ?? "", /超时/u, `expected timeout error, got: ${result.error}`); + assert.ok(elapsed < 1500, `fetchJson should abort near 200ms but took ${elapsed}ms`); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("fetchJson inactivity:activityRef 每 100ms 推一次活动,stop 后 ~2s abort", { timeout: 15000 }, async () => { + const { ref, update } = buildMutableActivityRef(Date.now()); + const originalFetch = globalThis.fetch; + globalThis.fetch = bindSignalFetch(); + + const interval = setInterval(() => { + update(Date.now()); + }, 100); + + try { + const { fetchJson } = await import("../src/services/api/client.ts"); + const startedAt = Date.now(); + const promise = fetchJson("/v1/agent/chat", { timeoutMs: 2000, timeoutName: "test-inactivity", activityRef: () => ref }); + setTimeout(() => clearInterval(interval), 1800); + const result = await promise; + const elapsed = Date.now() - startedAt; + assert.equal(result.ok, false, "fetch should fail once activity stops"); + assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`); + assert.ok(elapsed >= 1800, `should not abort before 1800ms with continuous activity, got ${elapsed}ms`); + assert.ok(elapsed < 6000, `should abort ~1-2s after activity stops, got ${elapsed}ms`); + } finally { + clearInterval(interval); + globalThis.fetch = originalFetch; + } +}); + +test("fetchJson inactivity:trace poll 风格的 1.5s 间隔在 6.5s 内不会触发总超时", { timeout: 20000 }, async () => { + // HWLAB #795 regression: the runner-trace poll loop used a total timeout + // equal to the user-configured codeAgentTimeoutMs, so an operation that + // kept emitting events every 1.5s would still be killed by the outer + // total cap. With activityRef, the inactivity window resets on every + // snapshot so the request only aborts after timeoutMs of NO activity. + const { ref, update } = buildMutableActivityRef(Date.now()); + const originalFetch = globalThis.fetch; + globalThis.fetch = bindSignalFetch(); + + const interval = setInterval(() => { + update(Date.now()); + }, 1500); + + try { + const { fetchJson } = await import("../src/services/api/client.ts"); + const startedAt = Date.now(); + const promise = fetchJson("/v1/agent/chat/result/abc", { timeoutMs: 5000, timeoutName: "trace-poll", activityRef: () => ref }); + setTimeout(() => clearInterval(interval), 6500); + const result = await promise; + const elapsed = Date.now() - startedAt; + assert.equal(result.ok, false, "fetch should fail once activity stops"); + assert.ok(elapsed >= 6000, `continuous 1.5s activity should keep the request alive past the per-poll inactivity window; got ${elapsed}ms`); + assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`); + } finally { + clearInterval(interval); + globalThis.fetch = originalFetch; + } +}); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index a18b84c3..0e1ae2c4 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback } from "react"; import { useAuth } from "./hooks/useAuth"; import { useSidebarResize } from "./hooks/useSidebarResize"; @@ -70,6 +71,16 @@ export function App(): ReactElement { setPickedDraft(null); } + // HWLAB #795: wire the command bar's onTyping to the workbench activity + // clock so the Code Agent inactivity-timeout (driven by activityRef in + // state/workbench.ts) does not falsely fire while the user is reading the + // trace / composing a follow-up. The bar only emits onTyping on actual + // input changes; we keep this stable via useCallback so the CommandBar + // does not re-render on every keystroke. + const noteActivity = useCallback(() => { + store.recordActivity(); + }, [store]); + async function submitWithDraftRecord(value: string): Promise { recordDraft(value); await store.submitMessage(value); @@ -128,7 +139,7 @@ export function App(): ReactElement { {route === "help" ? : null} {route === "settings" ? : null} - +
hwlab -