Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts
T
Lyon f7731d1421 fix(web): v0.2 remove all total-timeout / hard cap on Code Agent turn (HWLAB #795 final) (#798)
Follow-up to PR #797: drop every wall-clock or attempt-count ceiling from
`state/runner-trace.ts` so the Web UI's Code Agent inactivity-timeout
contract is total-cap-free. The previous PR kept a
`max(totalTimeoutMs * 4, totalTimeoutMs + 60s)` outer-loop safety net
plus a `TRACE_HARD_CAP_ATTEMPTS = 120` poll-count cap, which would
re-introduce the same regression class that #795 was filed against: a
long-running operation that keeps emitting trace events could still be
killed by the outer ceiling even with an active `activityRef`. The
"Code Agent Timeout Model" section of
`docs/reference/spec-v02-hwlab-cloud-web.md` now pins the contract:

- The sole abort signal is per-poll `fetchJson` inactivity-timeout,
  driven by `activityRef`.
- The outer `waitForAgentResult` / `pollRunnerTrace` loops are
  `for (;;)`. They only exit when the upstream returns a terminal
  status, the request fails with a non-5xx, or the inactivity window
  fires inside `fetchJson`.
- The per-poll `getAgentChatResult` call passes the full
  `totalTimeoutMs`; the inactivity window is **not** shrunk by
  wall-clock elapsed time.
- `TRACE_HARD_CAP_ATTEMPTS` and the 4x / +60s outer cap are deleted.
- Runaway protection is the caller's responsibility: Web users have
  cancel / steer / close-tab; CLI has `--timeout-ms`. The browser does
  not introduce an implicit cap.

Changes

- `web/hwlab-cloud-web/src/state/runner-trace.ts`:
  - Drop `const hardCapMs = Math.max(totalTimeoutMs * 4, totalTimeoutMs + 60_000)`
    and the `if (Date.now() - startedAt >= hardCapMs) break;` checks in
    both `waitForAgentResult` and `pollRunnerTrace`.
  - Replace `while (Date.now() - startedAt < hardCapMs)` with
    `for (;;)`. Drop the now-unused `startedAt` locals and the
    `let attempt = 0; attempt > TRACE_HARD_CAP_ATTEMPTS` guard.
  - Pass `totalTimeoutMs` directly to `api.getAgentChatResult` instead
    of `totalTimeoutMs - (Date.now() - startedAt)` so the per-poll
    inactivity window is stable.
  - Delete `const TRACE_HARD_CAP_ATTEMPTS = 120;` (no longer referenced).

- `web/hwlab-cloud-web/scripts/fetchJson-inactivity.test.ts`:
  - Replace the "1.5s cadence 6.5s" case with a stricter "200ms cadence
    10s" case: `timeoutMs=3000` inactivity window, continuous 200ms
    activity for 10s, then stop. Assert `elapsed >= 10_000` and that
    the abort is the inactivity-timeout error. This pins the
    `elapsed >= 10000` invariant in the spec.

- `docs/reference/spec-v02-hwlab-cloud-web.md`:
  - Strengthen the existing bullet in "在系统中的职责划分" to
    explicitly forbid total-timeout, hard cap, `codeAgentTimeoutMs * N`,
    poll-count cap, and wall-clock-shrunk per-poll windows.
  - Add a dedicated "Code Agent Timeout Model" section after "## 内部架构"
    that distils the contract, lists the three invariants, and records
    the history of #775 / #777 / #791 / #795 leading to the final state.

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` 241802 → 238460 B
  (delta reflects the deleted hardCap / TRACE_HARD_CAP_ATTEMPTS code)
- New 200ms-cadence 10s inactivity test runs for ~13s and asserts the
  request lives the full window — proving the outer cap is gone.

Refs: HWLAB #795 (final), #777, #791, #775

Co-authored-by: Codex <codex@local>
2026-06-04 09:36:29 +08:00

117 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 + 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.
const { ref, update } = buildMutableActivityRef(Date.now());
const originalFetch = globalThis.fetch;
globalThis.fetch = bindSignalFetch();
const interval = setInterval(() => {
update(Date.now());
}, 200);
try {
const { fetchJson } = await import("../src/services/api/client.ts");
const startedAt = Date.now();
const promise = fetchJson("/v1/agent/chat/result/abc", { timeoutMs: 3000, timeoutName: "trace-poll", activityRef: () => ref });
setTimeout(() => clearInterval(interval), 10_000);
const result = await promise;
const elapsed = Date.now() - startedAt;
assert.equal(result.ok, false, "fetch should fail once activity stops");
assert.ok(
elapsed >= 10_000,
`continuous 200ms activity must keep the request alive for the full 10s; a total cap or wall-clock shrink would abort earlier; got ${elapsed}ms`
);
assert.match(result.error ?? "", /无新活动/u, `expected inactivity-timeout error, got: ${result.error}`);
} finally {
clearInterval(interval);
globalThis.fetch = originalFetch;
}
});