fix: preserve mantis recordings on record errors (#78768)

This commit is contained in:
Peter Steinberger
2026-05-07 05:52:35 +01:00
parent 9f7abf9e3a
commit 96b7d9e6d8
3 changed files with 115 additions and 11 deletions

View File

@@ -97,6 +97,7 @@ Docs: https://docs.openclaw.ai
- Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed.
- Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc.
- Plugin SDK/fs-safe: expose reusable atomic replacement, sibling-temp writes, and cross-device move fallback helpers through `plugin-sdk/security-runtime`, and move OpenClaw's duplicated safe filesystem write paths onto the shared `@openclaw/fs-safe` package.
- Plugin SDK/fs-safe: route browser, media, channel, and QA external output producers through staged fs-safe writes before final publication. (#78768)
- Plugin SDK/fs-safe: rename the public temp workspace helpers to `tempWorkspace`, `withTempWorkspace`, `tempWorkspaceSync`, and `withTempWorkspaceSync`, matching the cleaner `@openclaw/fs-safe` API before the package is published.
- Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc.
- Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc.

View File

@@ -199,6 +199,93 @@ describe("mantis visual task runtime", () => {
});
});
it("preserves the video artifact when recording fails after writing output", async () => {
const commands: { args: readonly string[]; command: string }[] = [];
let stagedVideoPath = "";
const runner = vi.fn(async (command: string, args: readonly string[]) => {
commands.push({ command, args });
if (command === "/tmp/crabbox" && args[0] === "warmup") {
return { stdout: "ready lease cbx_abc123\n", stderr: "" };
}
if (command === "/tmp/crabbox" && args[0] === "inspect") {
return {
stdout: `${JSON.stringify({
id: "cbx_abc123",
provider: "hetzner",
slug: "brisk-mantis",
state: "active",
})}\n`,
stderr: "",
};
}
if (command === "/tmp/crabbox" && args[0] === "record") {
const outputPath = args[args.indexOf("--output") + 1];
const outputDir = args[args.indexOf("--output-dir") + 1];
stagedVideoPath = outputPath;
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, "mp4");
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(path.join(outputDir, "visual-task.png"), "png");
await fs.writeFile(
path.join(outputDir, "mantis-visual-task-driver-result.json"),
`${JSON.stringify({
browserUrl: "https://example.net",
finishedAt: "2026-05-04T12:00:05.000Z",
matched: true,
outputDir,
screenshotPath: path.join(outputDir, "visual-task.png"),
startedAt: "2026-05-04T12:00:01.000Z",
status: "pass",
vision: {
mode: "metadata",
timeoutMs: 120000,
},
})}\n`,
);
throw new Error("crabbox record failed after writing video");
}
return { stdout: "", stderr: "" };
});
const result = await runMantisVisualTask({
commandRunner: runner,
crabboxBin: "/tmp/crabbox",
env: { PATH: process.env.PATH },
now: () => new Date("2026-05-04T12:00:00.000Z"),
outputDir: ".artifacts/qa-e2e/mantis/visual-task-recording-preserved",
repoRoot,
settleMs: 0,
visionMode: "metadata",
});
expect(result.status).toBe("fail");
expect(result.videoPath).toBe(
path.join(
repoRoot,
".artifacts/qa-e2e/mantis/visual-task-recording-preserved/visual-task.mp4",
),
);
await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4");
await expect(fs.stat(stagedVideoPath)).rejects.toThrow();
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
artifacts?: { videoPath?: string };
error?: string;
recording?: { error?: string; required: boolean };
status: string;
};
expect(summary).toMatchObject({
artifacts: {
videoPath: result.videoPath,
},
error: "crabbox record failed after writing video",
recording: {
error: "crabbox record failed after writing video",
required: true,
},
status: "fail",
});
});
it("drives a lease, screenshots it, and verifies image-describe text", async () => {
const commands: { args: readonly string[]; command: string }[] = [];
const runner = vi.fn(async (command: string, args: readonly string[]) => {

View File

@@ -291,22 +291,36 @@ async function runCommandWithExternalOutput(params: {
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
preserveOutputOnError?: (params: { error: unknown; tempPath: string }) => Promise<boolean>;
runner: CommandRunner;
stdio?: "inherit" | "pipe";
}) {
return await writeExternalFileWithinRoot({
}): Promise<void> {
let deferredError: unknown;
await writeExternalFileWithinRoot({
rootDir: path.dirname(params.outputPath),
path: path.basename(params.outputPath),
write: async (tempPath) =>
await runCommand({
command: params.command,
args: params.buildArgs(tempPath),
cwd: params.cwd,
env: params.env,
runner: params.runner,
stdio: params.stdio,
}),
write: async (tempPath) => {
try {
await runCommand({
command: params.command,
args: params.buildArgs(tempPath),
cwd: params.cwd,
env: params.env,
runner: params.runner,
stdio: params.stdio,
});
} catch (error) {
if (await params.preserveOutputOnError?.({ error, tempPath })) {
deferredError = error;
return;
}
throw error;
}
},
});
if (deferredError) {
throw deferredError;
}
}
async function warmupCrabbox(params: {
@@ -840,6 +854,8 @@ export async function runMantisVisualTask(
],
cwd: repoRoot,
env,
preserveOutputOnError: async ({ tempPath }) =>
(await pathExists(driverResultPath)) && (await nonEmptyFileExists(tempPath)),
runner,
stdio: "inherit",
});