10c03e6c32
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>
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 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();
|
||
}
|
||
};
|
||
}
|
||
|
||
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();
|
||
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 无 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();
|
||
|
||
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;
|
||
}
|
||
});
|