import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { setCliSessionBinding } from "../agents/cli-session.js"; import { loadSessionEntryReadOnly, replaceSessionEntry, } from "../config/sessions/session-accessor.js"; import type { InternalSessionEntry } from "../config/sessions/types.js"; import { resolveRuntimeWorkerArgv, resolveRuntimeWorkerUrl } from "../infra/runtime-worker-url.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { cliRecoveryEntrypoints } from "./cli-entrypoint.test-support.js"; import { runCliProcessChild } from "./cli-process-child.test-helpers.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => { closeOpenClawAgentDatabasesForTest(); }); describe("CLI fork recovery process", () => { it("keeps a concurrent durable rebind when a stale fork reports its successor", async () => { const root = tempDirs.make("openclaw-cli-fork-recovery-"); const stateDir = path.join(root, "state"); const tmpDir = path.join(root, "tmp"); const workspaceDir = path.join(root, "workspace"); const pluginDir = path.join(root, "plugin"); const storePath = path.join(stateDir, "agents/main/sessions/sessions.json"); const sessionKey = "agent:main:cli-fork-process"; const sourceCliSessionId = "source-cli-session"; const newerCliSessionId = "newer-cli-session"; const successorCliSessionId = "stale-successor-session"; const checkpointId = "source-checkpoint"; const backendScript = path.join(root, "backend.mjs"); const rebindScript = path.join(root, "rebind.mjs"); const spawnLog = path.join(root, "spawn.jsonl"); await Promise.all([ fs.mkdir(tmpDir, { recursive: true }), fs.mkdir(workspaceDir, { recursive: true }), fs.mkdir(pluginDir, { recursive: true }), ]); await writeProofPlugin({ pluginDir, backendScript, rebindScript, }); const configPath = path.join(root, "openclaw.json"); await fs.writeFile( configPath, JSON.stringify({ session: { store: path.join(stateDir, "agents/{agentId}/sessions/sessions.json") }, plugins: { allow: ["cli-fork-process-proof"], load: { paths: [pluginDir] }, entries: { "cli-fork-process-proof": { enabled: true } }, }, agents: { defaults: { workspace: workspaceDir, model: { primary: "proof-cli/proof-model" }, models: { "proof-cli/proof-model": { agentRuntime: { id: "proof-cli" } } }, }, }, }), ); const entry: InternalSessionEntry = { sessionId: "openclaw-process-session", lifecycleRevision: "process-lifecycle", activeWriterRunId: "process-writer", updatedAt: 1, }; setCliSessionBinding(entry, "proof-cli", { sessionId: sourceCliSessionId, forceReuse: true, forkNextResume: true, resumeCheckpointId: checkpointId, }); await replaceSessionEntry({ sessionKey, storePath }, entry); closeOpenClawAgentDatabasesForTest(); const result = await runCliProcessChild({ nodeArgs: [ ...resolveRuntimeWorkerArgv(resolveRuntimeWorkerUrl(cliRecoveryEntrypoints.cli)), "agent", "--local", "--session-key", sessionKey, "--model", "proof-cli/proof-model", "--message", "prove stale fork ownership", "--json", ], env: { ...process.env, HOME: root, USERPROFILE: root, TMPDIR: tmpDir, NODE_DISABLE_COMPILE_CACHE: "1", NODE_ENV: undefined, NODE_OPTIONS: undefined, OPENCLAW_CONFIG_PATH: configPath, OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", OPENCLAW_HOME: root, OPENCLAW_NO_RESPAWN: "1", OPENCLAW_STATE_DIR: stateDir, PR135168_BACKEND_SCRIPT: backendScript, PR135168_NEWER_CLI_SESSION_ID: newerCliSessionId, PR135168_REBIND_SCRIPT: rebindScript, PR135168_SESSION_KEY: sessionKey, PR135168_SPAWN_LOG: spawnLog, PR135168_STORE_PATH: storePath, PR135168_SUCCESSOR_CLI_SESSION_ID: successorCliSessionId, VITEST: undefined, }, }); expect(result, JSON.stringify(result)).toMatchObject({ code: 1, signal: null }); expect(result.stderr).toContain( "CLI turn failed and its fork successor could not be persisted", ); closeOpenClawAgentDatabasesForTest(); expect( loadSessionEntryReadOnly({ sessionKey, storePath })?.cliSessionBindings?.["proof-cli"] ?.sessionId, ).toBe(newerCliSessionId); const spawn = JSON.parse((await fs.readFile(spawnLog, "utf8")).trim()) as { argv: string[]; }; expect(spawn.argv).toEqual([ "--resume", sourceCliSessionId, "--fork-session", "--resume-session-at", checkpointId, ]); }); }); async function writeProofPlugin(params: { pluginDir: string; backendScript: string; rebindScript: string; }): Promise { await Promise.all([ fs.writeFile( path.join(params.pluginDir, "package.json"), JSON.stringify({ name: "cli-fork-process-proof", private: true, type: "module", openclaw: { extensions: ["./index.js"] }, }), ), fs.writeFile( path.join(params.pluginDir, "openclaw.plugin.json"), JSON.stringify({ id: "cli-fork-process-proof", name: "CLI fork process proof", cliBackends: ["proof-cli"], activation: { onStartup: false }, modelCatalog: { providers: { "proof-cli": { models: [ { id: "proof-model", name: "Proof model", reasoning: false, input: ["text"], contextWindow: 8192, maxTokens: 1024, }, ], }, }, discovery: { "proof-cli": "static" }, }, configSchema: { type: "object", additionalProperties: false }, }), ), fs.writeFile( path.join(params.pluginDir, "index.js"), `import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const need = (name) => { const value = process.env[name]?.trim(); if (!value) throw new Error("missing " + name); return value; }; export default { id: "cli-fork-process-proof", name: "CLI fork process proof", register(api) { api.registerCliBackend({ id: "proof-cli", modelProvider: "proof-cli", config: { command: process.execPath, args: [need("PR135168_BACKEND_SCRIPT")], resumeArgs: [need("PR135168_BACKEND_SCRIPT"), "--resume", "{sessionId}"], output: "json", resumeOutput: "json", input: "stdin", sessionMode: "existing", sessionIdFields: ["session_id"], forkArg: "--fork-session", resumeAtArg: "--resume-session-at", systemPromptWhen: "never", serialize: true }, async prepareExecution() { return { beforeExecution: async () => { await execFileAsync(process.execPath, ["--import", "tsx", need("PR135168_REBIND_SCRIPT")]); } }; } }); } }; `, ), fs.writeFile( params.backendScript, `import fs from "node:fs/promises"; const stdin = []; for await (const chunk of process.stdin) stdin.push(chunk); await fs.appendFile(process.env.PR135168_SPAWN_LOG, JSON.stringify({ argv: process.argv.slice(2) }) + "\\n"); process.stdout.write(JSON.stringify({ result: "stale recovery ran", session_id: process.env.PR135168_SUCCESSOR_CLI_SESSION_ID }) + "\\n"); `, ), fs.writeFile( params.rebindScript, `const accessor = await import(${JSON.stringify(resolveRuntimeWorkerUrl(cliRecoveryEntrypoints.sessionAccessor).href)}); const cliSession = await import(${JSON.stringify(resolveRuntimeWorkerUrl(cliRecoveryEntrypoints.cliSession).href)}); const scope = { sessionKey: process.env.PR135168_SESSION_KEY, storePath: process.env.PR135168_STORE_PATH }; const current = accessor.loadSessionEntry({ ...scope, readConsistency: "latest" }); if (!current) throw new Error("proof session row missing"); const rebound = structuredClone(current); cliSession.setCliSessionBinding(rebound, "proof-cli", { sessionId: process.env.PR135168_NEWER_CLI_SESSION_ID, forceReuse: true }); if (!await accessor.replaceSessionEntry(scope, rebound)) throw new Error("proof rebind failed"); `, ), ]); }