fix(web): v0.2 wire workbench activityRef end-to-end (HWLAB #795) (#797)

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<number>`-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 `<CommandBar>`.

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 <codex@local>
This commit is contained in:
Lyon
2026-06-04 09:01:42 +08:00
committed by GitHub
parent 8d4fea5e19
commit 909715c335
5 changed files with 229 additions and 16 deletions
@@ -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<typeof setTimeout>)
};
}
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,
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 inactivityactivityRef 每 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 inactivitytrace 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;
}
});
+12 -1
View File
@@ -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<void> {
recordDraft(value);
await store.submitMessage(value);
@@ -128,7 +139,7 @@ export function App(): ReactElement {
{route === "help" ? <HelpView help={help} /> : null}
{route === "settings" ? <SettingsView onLogout={auth.logout} /> : null}
<DraftsList onPick={pickDraft} />
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} />
<CommandBar disabled={store.composer.disabled} providerProfile={store.state.providerProfile} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} gatewayShellTimeoutMs={store.state.gatewayShellTimeoutMs} onProviderProfile={store.setProviderProfile} onCodeAgentTimeout={store.setCodeAgentTimeoutMs} onGatewayShellTimeout={store.setGatewayShellTimeoutMs} onSubmit={submitWithDraftRecord} onClear={store.clearConversation} pickedDraft={pickedDraft} onPickedDraftConsumed={clearPickedDraft} disabledReason={store.composer.disabledReason} onTyping={noteActivity} />
</section>
<div
className="resize-handle right-sidebar-resize"
@@ -16,9 +16,18 @@ interface CommandBarProps {
pickedDraft: string | null;
onPickedDraftConsumed(): void;
disabledReason: string | null;
/**
* Notify the workbench that the user is actively typing. Wired by App.tsx
* to `store.recordActivity` so the Code Agent inactivity-timeout (driven
* by the activityRef plumbed in workbench.ts) treats a user who is reading
* or composing a follow-up as "active". This avoids HWLAB #795 style
* false-positive timeouts where the user is at the keyboard but the
* backend has not yet emitted the next trace event.
*/
onTyping(): void;
}
export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, ...props }: CommandBarProps): ReactElement {
export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, onTyping, ...props }: CommandBarProps): ReactElement {
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);
@@ -82,7 +91,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
</label>
<div className="input-shell">
<span className="prompt-mark">hwlab</span>
<textarea id="command-input" value={value} placeholder="输入任务,Enter 发送,Shift+Enter 换行" rows={1} onChange={(event) => setValue(event.currentTarget.value)} onKeyDown={keyDown} />
<textarea id="command-input" value={value} placeholder="输入任务,Enter 发送,Shift+Enter 换行" rows={1} onChange={(event) => { setValue(event.currentTarget.value); onTyping(); }} onKeyDown={keyDown} />
</div>
<button className="command-button" id="command-send" type="submit" disabled={props.disabled || pending}>{pending ? "发送中" : "发送"}</button>
<button className="command-button secondary" id="command-clear" type="button" onClick={() => { setValue(""); props.onClear(); }}></button>
+43 -8
View File
@@ -6,6 +6,7 @@ import type {
TraceEvent
} from "../types/domain";
import { api } from "../services/api/client";
import type { ActivityRef, ActivityRefSource } from "../services/api/client";
export interface TraceSnapshot {
traceId?: string;
@@ -89,18 +90,30 @@ export function mergeTraceResults(terminal: AgentChatResultResponse, trace: Trac
} as AgentChatResultResponse;
}
export async function waitForAgentResult(initial: AgentChatResponse, totalTimeoutMs: number): Promise<AgentChatResultResponse | null> {
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;
const startedAt = Date.now();
// Hard ceiling is a safety net for runaway operations; the per-poll
// inactivity-timeout in fetchJson (driven by activityRef) is the primary
// abort signal so active sessions are not falsely killed.
const hardCapMs = Math.max(totalTimeoutMs * 4, totalTimeoutMs + 60_000);
let attempt = 0;
while (Date.now() - startedAt < totalTimeoutMs) {
while (Date.now() - startedAt < hardCapMs) {
attempt += 1;
await new Promise<void>((resolve) => window.setTimeout(resolve, TRACE_POLL_INTERVAL_MS));
if (Date.now() - startedAt >= totalTimeoutMs) break;
const polled = await api.getAgentChatResult(initial.resultUrl, Math.min(8000, Math.max(4000, totalTimeoutMs - (Date.now() - startedAt))));
if (Date.now() - startedAt >= hardCapMs) break;
const polled = await api.getAgentChatResult(
initial.resultUrl,
Math.min(8000, Math.max(4000, totalTimeoutMs - (Date.now() - startedAt))),
activityRef ?? null
);
if (polled.ok && polled.data) {
if (isTerminalStatus(polled.data.status)) return polled.data;
if (attempt > TRACE_HARD_CAP_ATTEMPTS) return null;
@@ -113,17 +126,28 @@ export async function waitForAgentResult(initial: AgentChatResponse, totalTimeou
return null;
}
export async function pollRunnerTrace(resultUrl: string, traceId: string, onSnapshot: (snapshot: TraceSnapshot) => void, totalTimeoutMs: number): Promise<TraceSnapshot | null> {
export async function pollRunnerTrace(
resultUrl: string,
traceId: string,
onSnapshot: (snapshot: TraceSnapshot) => void,
totalTimeoutMs: number,
activityRef?: ActivityRefSource
): Promise<TraceSnapshot | null> {
const startedAt = Date.now();
const hardCapMs = Math.max(totalTimeoutMs * 4, totalTimeoutMs + 60_000);
const traceUrl = traceUrlFromResultUrl(resultUrl, traceId);
let lastEventCount = -1;
let lastStatus: string | null = null;
let lastUpdatedAt: string | null = null;
while (Date.now() - startedAt < totalTimeoutMs) {
while (Date.now() - startedAt < hardCapMs) {
await new Promise<void>((resolve) => window.setTimeout(resolve, TRACE_POLL_INTERVAL_MS));
if (Date.now() - startedAt >= totalTimeoutMs) break;
if (Date.now() - startedAt >= hardCapMs) break;
const remaining = totalTimeoutMs - (Date.now() - startedAt);
const polled = await api.getAgentChatResult(traceUrl, Math.min(8000, Math.max(3000, remaining)));
const polled = await api.getAgentChatResult(
traceUrl,
Math.min(8000, Math.max(3000, remaining)),
activityRef ?? null
);
if (!polled.ok || !polled.data) continue;
const events = Array.isArray(polled.data.events) ? polled.data.events : [];
const status = String(polled.data.status ?? "");
@@ -134,6 +158,17 @@ export async function pollRunnerTrace(resultUrl: string, traceId: string, onSnap
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);
}
}
const snapshot: TraceSnapshot = {
traceId,
status: status || undefined,
+51 -5
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { api } from "../services/api/client";
import type {
@@ -83,6 +83,18 @@ export interface WorkbenchStore {
retryAgentMessage(messageId: string): Promise<void>;
replayAgentTrace(messageId: string): Promise<void>;
clearConversation(): void;
/**
* Note recent user / system activity so the inactivity-timeout in fetchJson
* (services/api/client.ts) treats the user-configured codeAgentTimeoutMs as
* an inactivity budget rather than a total budget. Hooked to the Web
* composer onTyping callback in App.tsx; also called internally on submit
* and trace snapshot updates. This is the core of the HWLAB #795
* regression fix: the React migration removed the pre-React
* `app-device-pod.ts:fetchJson` activityRef wiring, so the Web UI was
* falling back to a total-timeout that killed long-running Code Agent
* operations even while trace events kept flowing.
*/
recordActivity(): void;
setProviderProfile(profile: ProviderProfile): void;
setCodeAgentTimeoutMs(value: number): void;
setGatewayShellTimeoutMs(value: number): void;
@@ -91,6 +103,28 @@ export interface WorkbenchStore {
export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const [state, dispatch] = useReducer(workbenchReducer, initialState);
// Single shared activity clock so submit, trace polling, and user-typing
// all reset the same inactivity window. Kept in a ref (not state) so
// updates never trigger re-renders.
const lastActivityAtRef = useRef<number>(Date.now());
const updateActivity = useCallback(() => {
lastActivityAtRef.current = Date.now();
}, []);
const buildActivityRef = useCallback(
(waitingFor: string, lastEventLabel: string | null): (() => import("../services/api/client").ActivityRef | null) => {
return () => {
const at = lastActivityAtRef.current;
if (!Number.isFinite(at) || at <= 0) return null;
return {
lastActivityAt: at,
lastActivityIso: new Date(at).toISOString(),
waitingFor,
lastEventLabel
};
};
},
[]
);
const activeConversationId = firstNonEmptyString(
state.workspace?.selectedConversationId,
@@ -204,6 +238,10 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const pendingWithRetry: ChatMessage = { ...pending, retryInput: value };
dispatch({ type: "message:pending", user, pending: pendingWithRetry, request: { traceId, conversationId, sessionId, threadId } });
const route = composer.submitMode === "steer" ? api.steerAgentMessage : api.sendAgentMessage;
// The submit call itself is a "kicked off" event; treat it as activity
// so the initial POST has a fresh inactivity window.
updateActivity();
const submitActivityRef = buildActivityRef("code-agent-submit", null);
const response = await route({
projectId: WORKBENCH_PROJECT_ID,
message: value,
@@ -217,7 +255,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
workspaceId: state.workspace?.workspaceId,
workspaceRevision: state.workspace?.revision,
targetTraceId: composer.targetTraceId
}, state.codeAgentTimeoutMs);
}, state.codeAgentTimeoutMs, submitActivityRef);
const finalize = async (result: AgentChatResultResponse, availability: CodeAgentAvailability | null): Promise<void> => {
const completed = messageFromAgentResponse(pending.id, pending, result);
dispatch({ type: "message:complete", messageId: pending.id, message: completed, availability: availability ?? result.availability ?? null });
@@ -228,10 +266,13 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
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();
dispatch({ type: "message:trace", messageId: pending.id, trace: snapshotToRunnerTrace(snapshot) });
}, state.codeAgentTimeoutMs)
}, state.codeAgentTimeoutMs, submitActivityRef)
: Promise.resolve<TraceSnapshot | null>(null);
const terminal = await waitForAgentResult(response.data, state.codeAgentTimeoutMs);
const terminal = await waitForAgentResult(response.data, state.codeAgentTimeoutMs, submitActivityRef);
const traceTerminal = await subscribeTrace;
if (terminal) {
await finalize(mergeTraceResults(terminal, traceTerminal), response.data.availability ?? null);
@@ -288,6 +329,11 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
}, [activeConversationId, state.workspace, state.messages, state.codeAgentTimeoutMs, submitMessage]);
const clearConversation = useCallback(() => dispatch({ type: "conversation:clear" }), []);
const recordActivity = useCallback(() => {
updateActivity();
}, [updateActivity]);
const setProviderProfile = useCallback((profile: ProviderProfile) => {
window.localStorage.setItem("hwlab.workbench.codeAgentProviderProfile.v1", profile);
dispatch({ type: "profile:set", profile });
@@ -306,7 +352,7 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
void refreshLive(devicePodId);
}, [refreshLive]);
return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, cancelAgentMessage, retryAgentMessage, replayAgentTrace, clearConversation, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod };
return { state, sessionTabs, activeConversationId, composer, hydrate, refreshLive, createSession, selectConversation, deleteCurrentSession, submitMessage, cancelAgentMessage, retryAgentMessage, replayAgentTrace, clearConversation, recordActivity, setProviderProfile, setCodeAgentTimeoutMs, setGatewayShellTimeoutMs, selectDevicePod };
}