diff --git a/.agents/skills/unidesk-webdev/SKILL.md b/.agents/skills/unidesk-webdev/SKILL.md index 5423d0bd..83b45af5 100644 --- a/.agents/skills/unidesk-webdev/SKILL.md +++ b/.agents/skills/unidesk-webdev/SKILL.md @@ -27,6 +27,10 @@ description: UniDesk Web 开发与受控浏览器验证技能。用户提到 Web - `web-probe` 内部可以使用浏览器引擎,但认证、目标解析、截图、脱敏、artifact 和报告必须由 `web-probe` 合同统一管理; - 缺少所需动作时,先在 owning YAML 和 `web-probe` typed command 中补齐通用能力,不得绕过为第二操作面。 - Web-probe 正式 CLI 入口是 `bun scripts/cli.ts web-probe ...`;旧 `hwlab nodes web-probe` 已移除,只能按 CLI 提示迁移,不要在 issue 或长期文档中继续记录旧入口。 +- `web-probe observe stop` 是 observer 生命周期清理入口: + - 不得依赖业务页面 readiness 才执行停止; + - graceful command 失败、超时、未消费或缺少有效结果时,默认自动进入 force-stop; + - 输出中的 `cleanup.state=confirmed` 才证明进程已停止,`cleanup.state=unknown` 表示远端传输或清理证据仍不完整。 - 涉及 Web 哨兵、`web-probe sentinel`、`monitor.pikapython.com`、定期/周期巡检或新建巡检时,必须同时加载 `$unidesk-monitor`。 - 真实用户入口验证优先;源码检查、构建通过或截图局部正常不能替代原入口验收。 - WebProbe 因内存、浏览器资源或受控启动策略阻断时: diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts index 11c1906e..d6c632aa 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts @@ -7,7 +7,7 @@ import { readFileSync } from "node:fs"; import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes"; import { rootPath } from "../config"; -import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, nodeWebProbeWorkbenchTraceReadability, resolveWebObserveActionJson } from "./web-probe-observe-actions"; +import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, nodeWebProbeWorkbenchTraceReadability, resolveWebObserveActionJson, webObserveStopFallbackReason } from "./web-probe-observe-actions"; test("realtime authority runner contract derives topics, groups, and transactional capabilities from owning YAML", () => { const profiles = nodeWebProbeRealtimeFanoutProfiles(hwlabRuntimeLaneSpecForNode("v03", "NC01")); @@ -120,6 +120,34 @@ test("web observe command visibility does not equate control completion with asy assert.equal(visibility.asyncTurn.terminalStatusSource, "not-evaluated-by-control-command"); }); +test("observe stop automatically falls back when graceful cleanup is not proven", () => { + assert.equal(webObserveStopFallbackReason({ + stopCommand: true, + exitCode: 255, + commandResult: null, + }), "graceful-stop-failed"); + assert.equal(webObserveStopFallbackReason({ + stopCommand: true, + exitCode: 0, + commandResult: null, + }), "graceful-stop-contract-missing"); + assert.equal(webObserveStopFallbackReason({ + stopCommand: true, + exitCode: 0, + commandResult: { ok: true, queued: true, waitTimedOut: true }, + }), "graceful-stop-not-consumed"); + assert.equal(webObserveStopFallbackReason({ + stopCommand: true, + exitCode: 0, + commandResult: { ok: true, queued: false, status: "done" }, + }), null); + assert.equal(webObserveStopFallbackReason({ + stopCommand: false, + exitCode: 255, + commandResult: null, + }), null); +}); + test("web observe action JSON recovery accepts concrete start contract", () => { const resolved = resolveWebObserveActionJson({ stdout: JSON.stringify({ diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts index 2afa7b76..c3ad62db 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts @@ -997,6 +997,20 @@ export function buildWebObserveCommandVisibility(input: { }; } +export function webObserveStopFallbackReason(input: { + stopCommand: boolean; + exitCode: number; + commandResult: Record | null; +}): string | null { + if (!input.stopCommand) return null; + if (input.exitCode !== 0) return "graceful-stop-failed"; + if (input.commandResult === null) return "graceful-stop-contract-missing"; + if (input.commandResult.ok === false || input.commandResult.status === "failed") return "graceful-stop-rejected"; + if (input.commandResult.waitTimedOut === true) return "graceful-stop-not-consumed"; + if (input.commandResult.queued === true) return "graceful-stop-queued"; + return null; +} + export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec, stopCommand: boolean): Record | RenderedCliResult { const type = options.commandType ?? (stopCommand ? "stop" : null); if (type === null) throw new Error("web-probe observe command requires --type"); @@ -1080,7 +1094,7 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, forceBeforeQueueReason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, null); } const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); - const waitMs = options.force && stopCommand ? Math.max(options.waitMs, 5000) : options.waitMs; + const waitMs = stopCommand ? Math.max(options.waitMs, 5000) : options.waitMs; const script = [ "set -eu", nodeWebObserveResolveStateDirShell(options), @@ -1091,21 +1105,18 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds); const commandResolution = resolveWebObserveActionJson(result, "command"); const commandResult = commandResolution.parsed; - if (options.force && stopCommand && (result.exitCode !== 0 || commandResult?.waitTimedOut === true || commandResult?.queued === true)) { - const reason = result.exitCode !== 0 - ? "graceful-stop-failed" - : commandResult?.waitTimedOut === true - ? "graceful-stop-not-consumed" - : "graceful-stop-queued"; - return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, reason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, result); + const stopFallbackReason = webObserveStopFallbackReason({ stopCommand, exitCode: result.exitCode, commandResult }); + if (stopFallbackReason !== null) { + return runNodeWebProbeObserveForceStop(options, spec, payload, commandId, stopFallbackReason, preStopStatus?.result ?? null, preStopStatus?.status ?? null, result); } const visibility = buildWebObserveCommandVisibility({ commandType: type, commandResult, exitCode: result.exitCode, }); + const commandOk = result.exitCode === 0 && commandResult !== null && commandResult.ok !== false; const payloadResult = { - ok: result.exitCode === 0 && commandResult !== null && commandResult.ok !== false, + ok: commandOk, status: visibility.status, command: webObserveCommandLabel(stopCommand ? "stop" : "command", options), id: webObserveIdFromOptions(options), @@ -1117,6 +1128,13 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption observer: commandResult, control: visibility.control, asyncTurn: visibility.asyncTurn, + cleanup: stopCommand ? { + attempted: true, + mode: "graceful-command", + state: commandOk ? "requested" : "unknown", + terminationConfirmed: false, + valuesRedacted: true, + } : null, wrapper: buildWebObserveWrapperForObserveOptions(stopCommand ? "stop" : "command", options, spec.workspace, { commandType: type }), stdoutJson: commandResolution.diagnostics, result: compactCommandResult(result), @@ -1143,9 +1161,13 @@ export function runNodeWebProbeObserveForceStop( ].join("\n"), 55); const forceResolution = resolveWebObserveActionJson(killResult, "force-stop"); const forcePayload = forceResolution.parsed; + const cleanupConfirmed = killResult.exitCode === 0 + && forcePayload !== null + && forcePayload.ok !== false + && forcePayload.aliveAfter !== true; const payloadResult = { - ok: killResult.exitCode === 0 && forcePayload !== null && forcePayload.ok !== false, - status: killResult.exitCode === 0 && forcePayload !== null && forcePayload.ok !== false ? "forced-stopped" : "blocked", + ok: cleanupConfirmed, + status: cleanupConfirmed ? "forced-stopped" : "blocked", command: webObserveCommandLabel("stop", options), id: webObserveIdFromOptions(options), node: options.node, @@ -1156,6 +1178,16 @@ export function runNodeWebProbeObserveForceStop( observer: forcePayload, wrapper: buildWebObserveWrapperForObserveOptions("stop", options, spec.workspace, { commandType: "stop" }), forceReason: reason, + cleanup: { + attempted: true, + mode: "force-stop", + state: cleanupConfirmed ? "confirmed" : "unknown", + terminationConfirmed: cleanupConfirmed, + processAliveAfter: forcePayload?.aliveAfter ?? null, + transportExitCode: killResult.exitCode, + reason, + valuesRedacted: true, + }, preflightObserver: preflightStatus, preflightResult: preflightResult === null ? null : compactCommandResult(preflightResult), gracefulResult: gracefulResult === null ? null : compactCommandResult(gracefulResult),