fix: make web probe observer stop fail-safe

This commit is contained in:
Codex
2026-07-17 10:54:30 +02:00
parent 7bec56e17b
commit 62ffa03239
3 changed files with 76 additions and 12 deletions
+4
View File
@@ -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 因内存、浏览器资源或受控启动策略阻断时:
@@ -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({
@@ -997,6 +997,20 @@ export function buildWebObserveCommandVisibility(input: {
};
}
export function webObserveStopFallbackReason(input: {
stopCommand: boolean;
exitCode: number;
commandResult: Record<string, unknown> | 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<string, unknown> | 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),