diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 368ec98f..23023319 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -47,7 +47,7 @@ web-probe 入口分三类: - `run`:repo-owned 标准 DOM probe,适合固定 P4 验收和已有脚本。 - `script`:受控 Playwright 托管脚本,适合一轮 55 秒内完成的 DOM/API 断言、截图、route/intercept 和边界采样。 -- `observe`:纯客户端长程观测,适合同一 Workbench session 多轮任务、realtime/projection 问题、长时间 trace/DOM/network 采样和无副作用报告生成。长程 Workbench 观测默认同时打开两个浏览器页面:control 页面只执行显式 `observe command` 用户动作,observer 页面只打开同一个 session 做被动观察,用来抓多用户/多页面下同一 session 的投影差异、历史 trace 丢失、耗时跳变和 loading 差异。 +- `observe`:纯客户端长程观测,适合同一 Workbench session 多轮任务、realtime/projection 问题、长时间 trace/DOM/network 采样和无副作用报告生成。长程 Workbench 观测默认同时打开两个浏览器页面:control 页面只执行显式 `observe command` 用户动作,observer 页面只打开同一个 session 做被动观察,并默认每 3 分钟整页刷新一次同一 session,模拟用户离开后返回,用来抓多用户/多页面下同一 session 的投影差异、历史 trace 丢失、耗时跳变和 loading 差异。 需要 Playwright route/intercept、延迟 API、读取 in-flight DOM 或截图时仍使用受控 `web-probe script`,不要裸写 Playwright: @@ -94,7 +94,7 @@ bun scripts/cli.ts hwlab nodes web-probe observe analyze webobs-xxxx 约束: - `web-probe script` 不运行默认探针,必须通过 stdin heredoc 或 `--script-file ` 提供脚本;只需要 repo-owned 标准 DOM probe 时使用 `web-probe run`。 -- `web-probe observe start` 默认是被动观测:记录 DOM 摘要、自然页面 request/response/requestfailed、截图和 performance 样本,不主动 fetch Workbench API、不 reload、不切换 session、不拦截路由、不调用 repair helper。长程 Workbench 观测必须保留 control/observer 双页面模型:control 页面执行显式 command,observer 页面只同步到同一 session URL 后被动采样;两页的 `pageRole`、`pageId`、`sampleGroupSeq` 必须进入样本和 analyzer 报表。任何 `newSession`、`selectProvider`、`sendPrompt`、`goto`、`screenshot`、`mark`、`stop` 都必须通过 `observe command` 显式下发,并进入 `control.jsonl`。 +- `web-probe observe start` 默认是被动观测:记录 DOM 摘要、自然页面 request/response/requestfailed、截图和 performance 样本,不主动 fetch Workbench API、不切换 control session、不拦截路由、不调用 repair helper。长程 Workbench 观测必须保留 control/observer 双页面模型:control 页面执行显式 command,observer 页面只同步到同一 session URL 后被动采样,并按默认 180000ms 周期整页刷新同一 session 来模拟用户往返;周期刷新只作用于 observer,不得改变 control active session 或作为通过条件。两页的 `pageRole`、`pageId`、`sampleGroupSeq` 必须进入样本和 analyzer 报表。任何 `newSession`、`selectProvider`、`sendPrompt`、`goto`、`screenshot`、`mark`、`stop` 都必须通过 `observe command` 显式下发,并进入 `control.jsonl`。 - `web-probe observe` 的 issue evidence 优先记录 observer id、stateDir、report JSON/Markdown SHA、samples/control/network/artifact 计数、routeSessionId、activeSessionId、prompt hash/textBytes、traceId、AgentRun runId/commandId、最终 status 和必要摘要;不要把 prompt 原文、assistant 大段正文、完整 stdout/stderr 或 provider payload 粘贴到 issue。 - 多轮 Workbench 采样必须证明同一个 `sessionId` 连续承载所有轮次;每轮至少记录 prompt hash、traceId、终态、最终回答摘要和性能/产物表。若 Web UI 投影卡住但 Code Agent/AgentRun result 已 terminal,应同时登记“执行终态”和“Workbench 投影未收敛”,不得用 `goto`、reload、切 session 或 result polling 把 UI 失败伪装成通过。 - `observe analyze` 是离线分析,只读取 artifact JSONL 并写 `analysis/report.md` 与 `analysis/report.json`,不访问 Workbench API、不驱动浏览器。报告必须输出采样点 vs 每个 turn 的总耗时/最近更新时间表、可见“加载中”的数量/归属/并发 owner/连续出现区间、DOM diagnostic/HTTP/console/requestfailed/runtime execution error 分组、page asset provenance segment、同源 API Resource Timing 分位表和超过 5s 的慢路径 finding;页面/API 加载超过 5s 视为不可用级性能红线,可见“加载中”持续超过 5s 也必须作为真实慢加载证据登记到上游问题。修复必须降低真实请求、投影、渲染或后端路径耗时,禁止为了减少“加载中”出现时间而提前展示未加载完的内容,也不能靠下游 retry/reload/fallback 掩盖。报告里的 `final-response-flicker`、`uncommanded-visible-state-change`、session changed、network 503 等 finding 是排障线索;用于 closeout 时必须结合原始 session/trace/DOM 证据解释,避免把采样噪声直接当作业务结论。 diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts index eb2aa9ff..6318841b 100644 --- a/scripts/src/hwlab-node-web-observe-runner-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-source.ts @@ -20,6 +20,7 @@ const targetPath = process.env.UNIDESK_WEB_OBSERVE_TARGET_PATH || "/workbench"; const sampleIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SAMPLE_INTERVAL_MS, 5000); const screenshotIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_SCREENSHOT_INTERVAL_MS, 300000); const maxSamples = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_MAX_SAMPLES, 0); +const observerRefreshIntervalMs = positiveInteger(process.env.UNIDESK_WEB_OBSERVE_OBSERVER_REFRESH_INTERVAL_MS, 180000); const viewport = parseViewport(process.env.UNIDESK_WEB_OBSERVE_VIEWPORT || "1440x900"); const playwrightProxy = proxyConfigFromEnv(baseUrl); const chromiumLaunchOptions = chromiumLaunchOptionsForProxy(playwrightProxy); @@ -48,6 +49,7 @@ let browser; let context; let page; let observerPage; +let lastObserverRefreshAtMs = Date.now(); let sampleSeq = 0; let commandSeq = 0; let artifactSeq = 0; @@ -119,7 +121,7 @@ async function writeManifest(extra = {}) { network: publicNetwork(playwrightProxy), pageAuthority: { browser: "chromium", context: "shared-auth", pageMode: "dual-control-observer", controlPageId: pageId, observerPageId, continuityBreaksRecorded: true }, pageProvenance: compactPageProvenance(currentPageProvenance), - sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerInitiatedDefault: false, responseBodyReadDefault: false }, + sampling: { mode: "passive", sampleIntervalMs, screenshotIntervalMs, maxSamples, observerRefreshIntervalMs, observerInitiatedDefault: false, responseBodyReadDefault: false }, commandDirs: dirs, artifacts: files, safety: { pureClient: true, inboundApi: false, database: false, queueConsumer: false, k8sWorkload: false, valuesRedacted: true, secretValuesPrinted: false }, @@ -141,6 +143,8 @@ async function writeHeartbeat(extra = {}) { baseUrl, currentUrl: currentPageUrl(), observerUrl: pageUrl(observerPage), + observerRefreshIntervalMs, + lastObserverRefreshAt: Number.isFinite(lastObserverRefreshAtMs) ? new Date(lastObserverRefreshAtMs).toISOString() : null, pageProvenance: compactPageProvenance(currentPageProvenance), sampleSeq, commandSeq, @@ -259,14 +263,15 @@ async function withObserverSync(result, reason) { return { ...result, observer: await syncObserverPageToControlSession(reason, result?.sessionId ?? null) }; } -async function syncObserverPageToControlSession(reason, explicitSessionId = null) { +async function syncObserverPageToControlSession(reason, explicitSessionId = null, options = {}) { if (!observerPage || observerPage.isClosed()) return { ok: false, reason, pageRole: "observer", pageId: observerPageId, failureKind: "observer-page-unavailable" }; + const forceRefresh = options?.forceRefresh === true; const snapshot = await workbenchSessionSnapshot(); const sessionId = explicitSessionId || snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl()); const target = sessionId ? "/workbench/sessions/" + encodeURIComponent(sessionId) : targetPath; const beforeUrl = pageUrl(observerPage); const beforeSessionId = routeSessionIdFromUrl(beforeUrl); - if (sessionId && beforeSessionId === sessionId) return { ok: true, reason, changed: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId }; + if (sessionId && beforeSessionId === sessionId && !forceRefresh) return { ok: true, reason, changed: false, observerRoundTrip: false, sessionId, beforeUrl, afterUrl: beforeUrl, pageRole: "observer", pageId: observerPageId }; let status = null; let statusText = null; const response = await observerPage.goto(new URL(target, baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 45000 }).catch((error) => ({ observerGotoError: errorSummary(error) })); @@ -274,7 +279,24 @@ async function syncObserverPageToControlSession(reason, explicitSessionId = null status = typeof response?.status === "function" ? response.status() : null; statusText = typeof response?.statusText === "function" ? response.statusText() : null; await observerPage.waitForTimeout(1000); - return { ok: true, reason, changed: true, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, httpStatus: status, statusText, valuesRedacted: true }; + lastObserverRefreshAtMs = Date.now(); + return { ok: true, reason, changed: true, observerRoundTrip: forceRefresh, sessionId: sessionId ?? null, targetPath: target, beforeUrl, afterUrl: pageUrl(observerPage), pageRole: "observer", pageId: observerPageId, httpStatus: status, statusText, valuesRedacted: true }; +} + +async function maybeRefreshObserverPage(reason) { + if (!observerPage || observerPage.isClosed()) return null; + if (!observerRefreshIntervalMs || observerRefreshIntervalMs <= 0) return null; + if (Date.now() - lastObserverRefreshAtMs < observerRefreshIntervalMs) return null; + const result = await syncObserverPageToControlSession("observer-periodic-refresh", null, { forceRefresh: true }); + await appendJsonl(files.control, eventRecord("observer-periodic-refresh", { + pageRole: "observer", + pageId: observerPageId, + reason, + intervalMs: observerRefreshIntervalMs, + result, + valuesRedacted: true + })); + return result; } async function runControlCommand(command, fn) { @@ -918,6 +940,7 @@ async function preflightSummary() { } async function samplePage(reason) { + await maybeRefreshObserverPage(reason); const groupSeq = sampleSeq + 1; if (page && !page.isClosed()) await sampleOnePage(page, { reason, groupSeq, pageRole: "control", targetPageId: pageId }); if (observerPage && !observerPage.isClosed()) { @@ -3825,7 +3848,7 @@ function compactManifest(value) { function compactHeartbeat(value) { if (!value) return null; - return { jobId: value.jobId, pid: value.pid, status: value.status, sampleSeq: value.sampleSeq, commandSeq: value.commandSeq, pageId: value.pageId ?? null, observerPageId: value.observerPageId ?? null, currentUrl: value.currentUrl, observerUrl: value.observerUrl ?? null, pageProvenance: value.pageProvenance ?? null, updatedAt: value.updatedAt, uptimeMs: value.uptimeMs }; + return { jobId: value.jobId, pid: value.pid, status: value.status, sampleSeq: value.sampleSeq, commandSeq: value.commandSeq, pageId: value.pageId ?? null, observerPageId: value.observerPageId ?? null, currentUrl: value.currentUrl, observerUrl: value.observerUrl ?? null, observerRefreshIntervalMs: value.observerRefreshIntervalMs ?? null, lastObserverRefreshAt: value.lastObserverRefreshAt ?? null, pageProvenance: value.pageProvenance ?? null, updatedAt: value.updatedAt, uptimeMs: value.uptimeMs }; } function renderTurnTimingTable(sampleMetrics) {