diff --git a/scripts/src/hwlab-node-web-observe-analyzer-source.ts b/scripts/src/hwlab-node-web-observe-analyzer-source.ts index 98e6c309..979ac3b0 100644 --- a/scripts/src/hwlab-node-web-observe-analyzer-source.ts +++ b/scripts/src/hwlab-node-web-observe-analyzer-source.ts @@ -2467,11 +2467,15 @@ function detectWorkbenchTerminalApiDomLag(samples, network) { for (const event of terminalEvents) { const pageSamples = rowsByPage.get(event.pageKey) || []; if (pageSamples.length === 0 || event.tsMs < pageSamples[0].tsMs) continue; + const alreadyVisible = lastWorkbenchSampleAtOrBefore(pageSamples, event.tsMs, event, (row) => workbenchSampleHasTerminalProjection(row.sample, event)); + if (alreadyVisible) continue; const firstAfter = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event); + const firstMiss = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + budgetMs, event, (row) => !workbenchSampleHasTerminalProjection(row.sample, event)); const resolved = firstWorkbenchSampleAfter(pageSamples, event.tsMs, event.tsMs + windowMs, event, (row) => workbenchSampleHasTerminalProjection(row.sample, event)); const deltaMs = resolved ? Math.max(0, Math.round(resolved.tsMs - event.tsMs)) : null; const unresolved = !resolved; const exceedsBudget = unresolved || (Number.isFinite(deltaMs) && deltaMs > budgetMs); + if (!firstMiss) continue; if (!exceedsBudget) continue; overBudget.push({ ts: event.ts, @@ -2490,6 +2494,7 @@ function detectWorkbenchTerminalApiDomLag(samples, network) { resolvedDeltaMs: deltaMs, unresolvedWithinWindow: unresolved, firstAfterSample: compactWorkbenchProjectionSample(firstAfter?.sample, event), + firstMissSample: compactWorkbenchProjectionSample(firstMiss?.sample, event), resolvedSample: compactWorkbenchProjectionSample(resolved?.sample, event), valuesRedacted: true }); @@ -2547,6 +2552,17 @@ function isReliableWorkbenchTerminalApiEvent(summary, routeKind) { return Number(summary.runningStatusCount ?? 0) <= 0; } +function lastWorkbenchSampleAtOrBefore(rows, tsMs, event, predicate = null) { + let result = null; + for (const row of rows || []) { + if (row.tsMs > tsMs) break; + if (!workbenchSampleMatchesTerminalEvent(row.sample, event)) continue; + if (typeof predicate === "function" && !predicate(row)) continue; + result = row; + } + return result; +} + function firstWorkbenchSampleAfter(rows, startMs, endMs, event, predicate = null) { for (const row of rows || []) { if (row.tsMs < startMs) continue; diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index ee43aa2e..f0c1abda 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -324,9 +324,16 @@ async function drainOneCommand() { function startCommandActiveSampler(command) { const intervalMs = Math.max(1000, Number(sampleIntervalMs) || 5000); + const heartbeatIntervalMs = Math.min(5000, intervalMs); let stopped = false; let timer = null; + let heartbeatTimer = null; let inFlight = false; + const heartbeat = () => { + if (stopped) return; + void writeHeartbeat({ status: terminalStatus, activeCommandId: command.id, activeCommandType: command.type, commandActive: true }) + .catch((error) => appendJsonl(files.errors, eventRecord("command-active-heartbeat-error", { commandId: command.id, commandType: command.type, error: errorSummary(error) }))); + }; const schedule = () => { if (stopped) return; timer = setTimeout(tick, intervalMs); @@ -346,10 +353,13 @@ function startCommandActiveSampler(command) { schedule(); }); }; + heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs); + if (heartbeatTimer && typeof heartbeatTimer.unref === "function") heartbeatTimer.unref(); schedule(); return () => { stopped = true; if (timer) clearTimeout(timer); + if (heartbeatTimer) clearInterval(heartbeatTimer); }; } @@ -1605,7 +1615,7 @@ async function sendPrompt(text, options = {}) { const editor = await primaryEditor.isVisible().catch(() => false) ? primaryEditor : page.locator('textarea, [role="textbox"], [contenteditable="true"], input[type="text"]').last(); - await editor.waitFor({ state: "visible", timeout: 15000 }); + await withHardTimeout(editor.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt composer editor did not become visible within 20s"); await fillComposerEditor(editor, text); const primarySubmitSelector = '#command-send, #command-submit, [data-testid="command-submit"], [data-testid="composer-submit"], [data-testid="send-command"]'; const primarySubmit = page.locator(primarySubmitSelector).last(); @@ -1619,7 +1629,7 @@ async function sendPrompt(text, options = {}) { '[aria-label*="send" i]', '[aria-label*="发送"]' ].join(", ")).last(); - await submit.waitFor({ state: "visible", timeout: 15000 }); + await withHardTimeout(submit.waitFor({ state: "visible", timeout: 15000 }), 20000, "sendPrompt submit button did not become visible within 20s"); if (options.expectedAction) { const configuredActionWaitMs = options.expectedActionWaitMs === null || options.expectedActionWaitMs === undefined || options.expectedActionWaitMs === "" ? null @@ -4405,5 +4415,18 @@ function errorSummary(error) { function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); } + +function withHardTimeout(promise, timeoutMs, message) { + const guarded = Promise.resolve(promise); + let timer = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), Math.max(1, Number(timeoutMs) || 1)); + if (timer && typeof timer.unref === "function") timer.unref(); + }); + return Promise.race([guarded, timeout]).finally(() => { + if (timer) clearTimeout(timer); + guarded.catch(() => {}); + }); +} `; }