f7731d1421
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>
117 lines
4.8 KiB
TypeScript
117 lines
4.8 KiB
TypeScript
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 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 + 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;
|
||
}
|
||
});
|