diff --git a/docs/cli/agent.md b/docs/cli/agent.md
index 8af5d55057d4..475d83d5744f 100644
--- a/docs/cli/agent.md
+++ b/docs/cli/agent.md
@@ -28,7 +28,7 @@ By default, the command creates and later removes a temporary state directory, a
Config is layered in three parts, entirely in memory: exec composes the run config and publishes it as this process's runtime config rather than writing a copy to disk. Exec defaults apply only where your config leaves a setting unset: workspace bootstrap files are skipped, the agent sandbox is off, the `coding` tool profile is selected, filesystem tools are restricted to `--cwd`, and exec runs under the full execution policy a headless turn needs. Anything your config sets wins over those defaults, so a configured sandbox, shell env, or tool profile is never downgraded, and exec host routing stays with the sandbox when your config enables one. The invocation itself always wins last: the run is scoped to `--cwd` and never bootstraps.
-Use `--state-dir
` to retain sessions and other run state. The directory must already exist and is never created or deleted by the command.
+Use `--state-dir ` to retain sessions and other run state. The directory must already exist and is never created or deleted by the command. A retained state directory requires exclusive ownership: exec refuses to start while a Gateway or another embedded writer owns it, then holds the state lock for the complete run. Omit `--state-dir` for isolated temporary state, or stop the Gateway first with `openclaw gateway stop`.
When exec uses the ambient or a pinned config, installed plugins continue to resolve from the operator's ordinary plugin roots while sessions and other run state use the ephemeral directory. In those modes, `--state-dir` controls run state only; it is not required for configured providers, channels, or harnesses supplied by installed plugins.
diff --git a/docs/cli/models.md b/docs/cli/models.md
index 1f87a54752e3..079283c455fd 100644
--- a/docs/cli/models.md
+++ b/docs/cli/models.md
@@ -56,6 +56,8 @@ Options:
Probe rows can come from auth profiles, env credentials, or `models.json`. Probe status buckets: `ok`, `auth`, `rate_limit`, `billing`, `timeout`, `format`, `unknown`, `no_model`.
+Direct `models status --probe` runs create temporary internal sessions in the selected agent's canonical database, so the command requires exclusive ownership of the configured state directory. Stop a running Gateway with `openclaw gateway stop` before probing; the command removes its internal sessions and releases the state lock when it finishes or is interrupted.
+
Probe detail/reason codes to expect when a probe never reaches a model call:
- `excluded_by_auth_order`: a stored profile exists, but explicit `auth.order.` omitted it, so probe reports the exclusion instead of trying it.
diff --git a/docs/cli/tui.md b/docs/cli/tui.md
index 5bfa186edcc1..8756e00417b2 100644
--- a/docs/cli/tui.md
+++ b/docs/cli/tui.md
@@ -77,6 +77,10 @@ Aliases: `openclaw chat` and `openclaw terminal` invoke this command with
`agent::...`).
- Local mode uses the embedded agent runtime directly. Most local tools work,
but Gateway-only features are unavailable.
+- Local mode requires exclusive ownership of the configured state directory. It
+ refuses to start while a Gateway or another embedded writer owns that state;
+ run without `--local` to use the active Gateway, or stop it first with
+ `openclaw gateway stop`.
- Local mode adds `/auth [provider]` to the TUI command surface.
- Plugin approval gates still apply in local mode: tools that require approval
prompt for a decision in the terminal, nothing is silently auto-approved.
diff --git a/src/cli/gateway-cli/run.supervised-lock.test.ts b/src/cli/gateway-cli/run.supervised-lock.test.ts
index a2a2d4ba5ac9..5604b6a71c99 100644
--- a/src/cli/gateway-cli/run.supervised-lock.test.ts
+++ b/src/cli/gateway-cli/run.supervised-lock.test.ts
@@ -104,7 +104,7 @@ describe("supervised gateway lock recovery", () => {
it("preserves an agent-embedded owner error under a supervisor", async () => {
const err = new GatewayLockError(
- "another openclaw agent --local run is active (pid 123); lock timeout after 5000ms",
+ "another embedded OpenClaw state writer is active (pid 123); lock timeout after 5000ms",
);
const startLoop = vi.fn(async () => {
throw err;
diff --git a/src/commands/agent-exec.state-lock.test.ts b/src/commands/agent-exec.state-lock.test.ts
new file mode 100644
index 000000000000..115bc50d73f4
--- /dev/null
+++ b/src/commands/agent-exec.state-lock.test.ts
@@ -0,0 +1,151 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
+import { acquireGatewayLock, type GatewayLockOptions } from "../infra/gateway-lock.js";
+import type { RuntimeEnv } from "../runtime.js";
+import { agentExecCommand } from "./agent-exec.js";
+
+const tempDirs = useAutoCleanupTempDirTracker(afterEach);
+
+function createRuntime() {
+ const error = vi.fn();
+ const runtime: RuntimeEnv = { log: vi.fn(), error, exit: vi.fn() };
+ return { runtime, error };
+}
+
+function successResult() {
+ return {
+ payloads: [{ text: "done" }],
+ meta: {
+ durationMs: 1,
+ agentMeta: { sessionId: "session-result", provider: "openai", model: "gpt-5.6-sol" },
+ },
+ };
+}
+
+function createGatewayLockOptions(
+ stateDir: string,
+ overrides: Partial = {},
+): GatewayLockOptions {
+ return {
+ allowInTests: true,
+ env: {
+ ...process.env,
+ OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
+ OPENCLAW_STATE_DIR: stateDir,
+ },
+ lockDir: path.join(stateDir, "gateway-locks"),
+ timeoutMs: 100,
+ ...overrides,
+ };
+}
+
+function createSignalProcess() {
+ type SignalName = "SIGINT" | "SIGTERM";
+ const listeners = new Map void>>();
+ const processLike = {
+ on(signal: SignalName, handler: () => void) {
+ const current = listeners.get(signal) ?? new Set<() => void>();
+ current.add(handler);
+ listeners.set(signal, current);
+ return processLike;
+ },
+ off(signal: SignalName, handler: () => void) {
+ listeners.get(signal)?.delete(handler);
+ return processLike;
+ },
+ };
+ return {
+ processLike,
+ emit(signal: SignalName) {
+ for (const handler of listeners.get(signal) ?? []) {
+ handler();
+ }
+ },
+ };
+}
+
+describe("agent exec retained-state ownership", () => {
+ it("refuses a state directory owned by a live Gateway", async () => {
+ const stateDir = tempDirs.make("openclaw-agent-exec-gateway-owner-");
+ const lockOptions = createGatewayLockOptions(stateDir, {
+ readProcessStartTime: () => 123_456,
+ });
+ const gatewayLock = await acquireGatewayLock({ ...lockOptions, port: 28789 });
+ expect(gatewayLock).not.toBeNull();
+ if (!gatewayLock) {
+ throw new Error("Expected live Gateway fixture lock");
+ }
+ const runAgent = vi.fn(async () => successResult());
+ const { runtime, error } = createRuntime();
+
+ try {
+ const result = await agentExecCommand("inspect", { stateDir }, runtime, {
+ gatewayLockOptions: lockOptions,
+ runAgent,
+ });
+ expect(result.exitCode).toBe(1);
+ expect(runAgent).not.toHaveBeenCalled();
+ expect(error).toHaveBeenCalledWith(
+ `A Gateway is running for this state directory (pid ${process.pid}, port 28789). Omit --state-dir to use isolated temporary state, or stop the Gateway first (openclaw gateway stop).`,
+ );
+ } finally {
+ await gatewayLock.release();
+ }
+ });
+
+ it("holds and releases the embedded state lock around the run", async () => {
+ const stateDir = tempDirs.make("openclaw-agent-exec-lock-owner-");
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const stateLockPath = path.join(lockOptions.lockDir!, "gateway.state.lock");
+
+ await agentExecCommand("inspect", { stateDir }, createRuntime().runtime, {
+ gatewayLockOptions: lockOptions,
+ runAgent: vi.fn(async () => {
+ const payload = JSON.parse(await fs.readFile(stateLockPath, "utf8")) as {
+ pid?: number;
+ role?: string;
+ };
+ expect(payload).toMatchObject({ pid: process.pid, role: "agent-embedded" });
+ return successResult();
+ }),
+ });
+
+ await expect(fs.stat(stateLockPath)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it("releases the embedded state lock when SIGTERM aborts the run", async () => {
+ const stateDir = tempDirs.make("openclaw-agent-exec-signal-owner-");
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const stateLockPath = path.join(lockOptions.lockDir!, "gateway.state.lock");
+ const signals = createSignalProcess();
+ const { runtime } = createRuntime();
+ const runAgent = vi.fn(async (opts: Record) => {
+ const signal = opts.abortSignal as AbortSignal;
+ return await new Promise>((_, reject) => {
+ signal.addEventListener(
+ "abort",
+ () => {
+ const error = new Error("agent exec aborted");
+ error.name = "AbortError";
+ reject(error);
+ },
+ { once: true },
+ );
+ });
+ });
+
+ const run = agentExecCommand("inspect", { stateDir }, runtime, {
+ gatewayLockOptions: lockOptions,
+ process: signals.processLike,
+ runAgent,
+ });
+ await vi.waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
+ signals.emit("SIGTERM");
+ await run;
+
+ await expect(fs.stat(stateLockPath)).rejects.toMatchObject({ code: "ENOENT" });
+ expect(runtime.exit).toHaveBeenCalledWith(143, { resetStream: process.stderr });
+ });
+});
diff --git a/src/commands/agent-exec.test.ts b/src/commands/agent-exec.test.ts
index 8b6ad9b3bf13..093b489b4e2d 100644
--- a/src/commands/agent-exec.test.ts
+++ b/src/commands/agent-exec.test.ts
@@ -1001,6 +1001,21 @@ describe("agent exec run config layering", () => {
expect(config.agents?.entries?.ops?.model).toBe("openai/gpt-5.6-sol");
});
+ it("drops an inherited session store so the invocation state dir owns the agent database", () => {
+ const config = buildExecRunConfig({
+ base: {
+ session: {
+ store: "/persistent/agents/{agentId}/sessions/sessions.json",
+ mainKey: "primary",
+ },
+ },
+ cwd: "/run/here",
+ });
+
+ expect(config.session?.store).toBeUndefined();
+ expect(config.session?.mainKey).toBe("primary");
+ });
+
it("drops an inherited harness cwd so --cwd wins", () => {
const config = buildExecRunConfig({
base: {
diff --git a/src/commands/agent-exec.ts b/src/commands/agent-exec.ts
index ae335e28c7f1..820e89ee8c55 100644
--- a/src/commands/agent-exec.ts
+++ b/src/commands/agent-exec.ts
@@ -8,9 +8,15 @@ import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-w
import { findAgentRunTerminalOutcome } from "../agents/agent-run-terminal-error.js";
import type { EmbeddedAgentRunMeta } from "../agents/embedded-agent.js";
import { isExecutionIdentityCollectionEnabled } from "../audit/audit-config.js";
+import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { mergeDeep } from "../infra/deep-merge.js";
+import type {
+ EmbeddedStateLockHandle,
+ EmbeddedStateSignalProcess,
+} from "../infra/embedded-state-lock.js";
import { formatErrorMessage } from "../infra/errors.js";
+import type { GatewayLockIdentity, GatewayLockOptions } from "../infra/gateway-lock.js";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
import { writeRuntimeJson, writeRuntimeStdout, type RuntimeEnv } from "../runtime.js";
@@ -79,6 +85,8 @@ type AgentExecCommandResult = {
type AgentExecCommandDeps = {
stdin?: AsyncIterable;
+ process?: EmbeddedStateSignalProcess;
+ gatewayLockOptions?: GatewayLockOptions;
runAgent?: (
opts: Record,
runtime: RuntimeEnv,
@@ -284,23 +292,25 @@ function normalizeCodeMode(
* outrank whatever the resolved config says.
*/
/**
- * Drops inherited per-agent location overrides, which outrank the facts this
- * invocation owns. `agentDir` beats the state dir for session and transcript
- * storage, so an ephemeral run would write state into the operator's persistent
- * agent directory where deleting the temp state dir cannot reach it; a native
- * harness `runtime.acp.cwd` beats `--cwd`, so the turn could edit the wrong
- * repository. `agents.bindings[].acp.cwd` needs no equivalent because exec runs
- * no channel, so no binding matches.
+ * Drops inherited state and workspace location overrides, which outrank the
+ * facts this invocation owns. `session.store` and `agentDir` can redirect state
+ * outside the invocation root, where its lock or temporary cleanup cannot own
+ * it; a native harness `runtime.acp.cwd` can make the turn edit the wrong repo.
+ * `agents.bindings[].acp.cwd` needs no equivalent because exec runs no channel,
+ * so no binding matches.
*/
function stripInheritedAgentLocations(base: OpenClawConfig): OpenClawConfig {
- const entries = base.agents?.entries;
+ const { session, ...root } = base;
+ const { store: _store, ...sessionWithoutStore } = session ?? {};
+ const withoutSessionStore = session ? { ...root, session: sessionWithoutStore } : base;
+ const entries = withoutSessionStore.agents?.entries;
if (!entries) {
- return base;
+ return withoutSessionStore;
}
return {
- ...base,
+ ...withoutSessionStore,
agents: {
- ...base.agents,
+ ...withoutSessionStore.agents,
entries: Object.fromEntries(
Object.entries(entries).map(([id, entry]) => {
const { agentDir: _agentDir, runtime, ...rest } = entry;
@@ -477,6 +487,10 @@ function setAgentExecEnvironment(params: { stateDir: string; cwd: string }): ()
};
}
+function formatActiveGatewayExecRefusal(identity: GatewayLockIdentity): string {
+ return `A Gateway is running for this state directory (pid ${identity.pid}, port ${identity.port}). Omit --state-dir to use isolated temporary state, or stop the Gateway first (${formatCliCommand("openclaw gateway stop")}).`;
+}
+
function isStructuredTimeoutError(error: unknown): boolean {
if (findAgentRunTerminalOutcome(error)?.status === "timeout") {
return true;
@@ -552,6 +566,12 @@ export async function agentExecCommand(
let runtimePaths: typeof import("../config/paths.js") | undefined;
let configIo: typeof import("../config/io.js") | undefined;
let stopLocalAuditWriter: (() => Promise) | undefined;
+ let stateLock: EmbeddedStateLockHandle | null | undefined;
+ let signalBridge:
+ | ReturnType<
+ (typeof import("../infra/embedded-state-lock.js"))["createEmbeddedStateSignalBridge"]
+ >
+ | undefined;
try {
const prompt = await resolveAgentExecPrompt(
positionalMessage,
@@ -618,6 +638,16 @@ export async function agentExecCommand(
restoreEnvironment = setAgentExecEnvironment({ stateDir, cwd });
runtimePaths = await import("../config/paths.js");
runtimePaths.pinRuntimePaths();
+ if (opts.stateDir) {
+ const { acquireEmbeddedStateLock, createEmbeddedStateSignalBridge } =
+ await import("../infra/embedded-state-lock.js");
+ signalBridge = createEmbeddedStateSignalBridge(deps.process ?? process);
+ stateLock = await acquireEmbeddedStateLock({
+ options: deps.gatewayLockOptions,
+ signal: signalBridge.signal,
+ formatActiveGatewayRefusal: formatActiveGatewayExecRefusal,
+ });
+ }
// The runtime snapshot is the only in-process config cache (`clearConfigCache`
// is a no-op shim), so publishing the composed config here is what makes the
// run use it. Serializing it to a temporary file and repointing
@@ -668,6 +698,7 @@ export async function agentExecCommand(
cleanupBundleMcpOnRunEnd: true,
cleanupCliLiveSessionOnRunEnd: true,
oneShotCliRun: true,
+ abortSignal: signalBridge?.signal,
onModelFallbackExhausted: () => {
fallbackExhausted = true;
},
@@ -707,6 +738,9 @@ export async function agentExecCommand(
let cleanupError: unknown;
await stopLocalAuditWriter?.().catch(() => undefined);
+ await stateLock?.release().catch((error: unknown) => {
+ cleanupError ??= error;
+ });
const runCleanupStep = (step: () => void) => {
try {
step();
@@ -738,6 +772,13 @@ export async function agentExecCommand(
commandResult = { envelope, exitCode: exitCodeForEnvelope(envelope) };
}
+ const receivedSignal = signalBridge?.getReceivedSignal();
+ signalBridge?.dispose();
+ if (receivedSignal) {
+ runtime.exit(receivedSignal === "SIGINT" ? 130 : 143, { resetStream: process.stderr });
+ return commandResult;
+ }
+
writeAgentExecOutput(runtime, commandResult.envelope, opts.json === true);
return commandResult;
}
diff --git a/src/commands/agent-via-gateway.test.ts b/src/commands/agent-via-gateway.test.ts
index 365542477f93..faa71e0abfad 100644
--- a/src/commands/agent-via-gateway.test.ts
+++ b/src/commands/agent-via-gateway.test.ts
@@ -541,7 +541,7 @@ describe("agentCliCommand", () => {
localGatewayLockOptions: { ...lockOptions, pollIntervalMs: 2, timeoutMs: 15 },
}),
).rejects.toThrow(
- `another openclaw agent --local run is active (pid ${process.pid}); lock timeout after 15ms`,
+ `another embedded OpenClaw state writer is active (pid ${process.pid}); lock timeout after 15ms`,
);
expect(agentCommand).toHaveBeenCalledTimes(1);
diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts
index a5bc7bae8bb9..e197e4185cd8 100644
--- a/src/commands/agent-via-gateway.ts
+++ b/src/commands/agent-via-gateway.ts
@@ -30,6 +30,11 @@ import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { ADMIN_SCOPE } from "../gateway/operator-scopes.js";
import { createAbortError } from "../infra/abort-signal.js";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
+import {
+ createEmbeddedStateSignalBridge,
+ type EmbeddedStateSignal,
+ type EmbeddedStateSignalProcess,
+} from "../infra/embedded-state-lock.js";
import type { GatewayLockIdentity, GatewayLockOptions } from "../infra/gateway-lock.js";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
import { routeLogsToStderr } from "../logging/console.js";
@@ -96,11 +101,9 @@ type AgentDispatchOpts = Omit & {
message: string;
};
-type AgentCliSignal = "SIGINT" | "SIGTERM";
-type AgentCliProcessLike = {
+type AgentCliSignal = EmbeddedStateSignal;
+type AgentCliProcessLike = EmbeddedStateSignalProcess & {
exitCode?: NodeJS.Process["exitCode"];
- on(signal: AgentCliSignal, handler: () => void): unknown;
- off(signal: AgentCliSignal, handler: () => void): unknown;
};
type AgentCliDeps = CliDeps & {
process?: AgentCliProcessLike;
@@ -113,7 +116,6 @@ type AgentGatewayCallIdentity = Pick<
type AgentSessionModule = typeof import("./agent/session.runtime.js");
type AgentSessionModuleLoader = () => Promise;
-const AGENT_CLI_SIGNALS: readonly AgentCliSignal[] = ["SIGINT", "SIGTERM"];
const GATEWAY_ABORT_RETRY_DELAYS_MS = [50, 150, 300, 600] as const;
const GATEWAY_ABORT_REQUEST_TIMEOUT_MS = 2_000;
const AGENT_CLI_SIGNAL_EXIT_CODES: Record = {
@@ -138,9 +140,10 @@ const agentSessionModuleCache = createLazyPromiseLoader(() => agentSessionModule
const runtimeConfigModuleLoader = createLazyPromiseLoader(() => import("../config/io.js"), {
cacheRejections: true,
});
-const gatewayLockModuleLoader = createLazyPromiseLoader(() => import("../infra/gateway-lock.js"), {
- cacheRejections: true,
-});
+const embeddedStateLockModuleLoader = createLazyPromiseLoader(
+ () => import("../infra/embedded-state-lock.js"),
+ { cacheRejections: true },
+);
const replyPayloadModuleLoader = createLazyPromiseLoader(
() => import("openclaw/plugin-sdk/reply-payload"),
{ cacheRejections: true },
@@ -226,32 +229,12 @@ async function acquireEmbeddedAgentStateLock(
options: GatewayLockOptions | undefined,
signal: AbortSignal,
) {
- const { acquireGatewayLock, GatewayLockError, readActiveGatewayLockIdentity } =
- await gatewayLockModuleLoader.load();
- const env = options?.env ?? process.env;
- if (options?.allowInTests !== true && (env.VITEST || env.NODE_ENV === "test")) {
- return null;
- }
- const activeGateway = await readActiveGatewayLockIdentity(options);
- if (activeGateway) {
- throw new GatewayLockError(formatActiveGatewayLocalRefusal(activeGateway));
- }
- try {
- return await acquireGatewayLock({
- ...options,
- role: "agent-embedded",
- sleep: options?.sleep ?? (async (ms) => await delayMs(ms, signal)),
- });
- } catch (error) {
- if (!(error instanceof GatewayLockError)) {
- throw error;
- }
- const racedGateway = await readActiveGatewayLockIdentity(options);
- if (racedGateway) {
- throw new GatewayLockError(formatActiveGatewayLocalRefusal(racedGateway), error);
- }
- throw error;
- }
+ const { acquireEmbeddedStateLock } = await embeddedStateLockModuleLoader.load();
+ return await acquireEmbeddedStateLock({
+ options,
+ signal,
+ formatActiveGatewayRefusal: formatActiveGatewayLocalRefusal,
+ });
}
const loadReplyPayloadModule = replyPayloadModuleLoader.load;
@@ -263,7 +246,7 @@ export const agentViaGatewayTesting = {
localAuditModuleLoader.clear();
agentSessionModuleCache.clear();
runtimeConfigModuleLoader.clear();
- gatewayLockModuleLoader.clear();
+ embeddedStateLockModuleLoader.clear();
replyPayloadModuleLoader.clear();
agentSessionModuleLoader = defaultAgentSessionModuleLoader;
},
@@ -530,34 +513,12 @@ function readAcceptedRunContext(payload: unknown): {
}
function createAgentCliSignalBridge(processLike: AgentCliProcessLike = process) {
- const controller = new AbortController();
- let receivedSignal: AgentCliSignal | undefined;
- const handlers = new Map void>();
- const detachHandlers = () => {
- for (const [signal, handler] of handlers) {
- processLike.off(signal, handler);
- }
- handlers.clear();
- };
- for (const signal of AGENT_CLI_SIGNALS) {
- const handler = () => {
- receivedSignal = signal;
- if (!controller.signal.aborted) {
- // runtime.exit may bypass finally cleanup, so first-signal self-detach is load-bearing.
- controller.abort();
- detachHandlers();
- }
- };
- handlers.set(signal, handler);
- processLike.on(signal, handler);
- }
+ const bridge = createEmbeddedStateSignalBridge(processLike);
return {
- signal: controller.signal,
- getReceivedSignal: () => receivedSignal,
+ ...bridge,
setExitCode: (code: number) => {
processLike.exitCode = code;
},
- dispose: detachHandlers,
};
}
diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts
index 505e995dd1cc..39daa9922130 100644
--- a/src/commands/models/list.probe.test.ts
+++ b/src/commands/models/list.probe.test.ts
@@ -1,10 +1,63 @@
// Model list probe tests cover runtime probing while listing configured models.
+import fsSync from "node:fs";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
+import { acquireGatewayLock, type GatewayLockOptions } from "../../infra/gateway-lock.js";
let probeModule: typeof import("./list.probe.js");
+function createGatewayLockOptions(stateDir: string): GatewayLockOptions {
+ return {
+ allowInTests: true,
+ env: {
+ ...process.env,
+ OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
+ OPENCLAW_STATE_DIR: stateDir,
+ },
+ lockDir: path.join(stateDir, "gateway-locks"),
+ readProcessStartTime: () => 123_456,
+ timeoutMs: 100,
+ };
+}
+
+function createSignalProcess() {
+ type SignalName = "SIGINT" | "SIGTERM";
+ const listeners = new Map void>>();
+ const processLike = {
+ on(signal: SignalName, handler: () => void) {
+ const current = listeners.get(signal) ?? new Set<() => void>();
+ current.add(handler);
+ listeners.set(signal, current);
+ return processLike;
+ },
+ off(signal: SignalName, handler: () => void) {
+ listeners.get(signal)?.delete(handler);
+ return processLike;
+ },
+ };
+ return {
+ processLike,
+ emit(signal: SignalName) {
+ for (const handler of listeners.get(signal) ?? []) {
+ handler();
+ }
+ },
+ };
+}
+
+async function withTempState(run: (stateDir: string) => Promise): Promise {
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-model-probe-lock-"));
+ try {
+ return await run(stateDir);
+ } finally {
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+}
+
describe("mapFailoverReasonToProbeStatus", () => {
beforeAll(async () => {
vi.doMock("../../agents/embedded-agent.js", () => {
@@ -42,6 +95,81 @@ describe("mapFailoverReasonToProbeStatus", () => {
});
describe("runAuthProbes", () => {
+ beforeAll(async () => {
+ probeModule ??= await import("./list.probe.js");
+ });
+
+ it("refuses direct CLI probes while a live Gateway owns canonical state", async () => {
+ await withTempState(async (stateDir) => {
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const gatewayLock = await acquireGatewayLock({ ...lockOptions, port: 28789 });
+ expect(gatewayLock).not.toBeNull();
+ if (!gatewayLock) {
+ throw new Error("Expected live Gateway fixture lock");
+ }
+ try {
+ await expect(
+ probeModule.withAuthProbeStateOwnership(
+ { mode: "exclusive", gatewayLockOptions: lockOptions },
+ async () => undefined,
+ ),
+ ).rejects.toThrow(
+ `A Gateway is running for this state directory (pid ${process.pid}, port 28789). Stop the Gateway first (openclaw gateway stop), then rerun models status --probe.`,
+ );
+ } finally {
+ await gatewayLock.release();
+ }
+ });
+ });
+
+ it("holds and releases canonical state ownership around direct CLI probes", async () => {
+ await withTempState(async (stateDir) => {
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const stateLockPath = path.join(lockOptions.lockDir!, "gateway.state.lock");
+ let observedPayload: { pid?: number; role?: string } | undefined;
+
+ await probeModule.withAuthProbeStateOwnership(
+ { mode: "exclusive", gatewayLockOptions: lockOptions },
+ async () => {
+ observedPayload = JSON.parse(fsSync.readFileSync(stateLockPath, "utf8")) as {
+ pid?: number;
+ role?: string;
+ };
+ },
+ );
+
+ expect(observedPayload).toMatchObject({ pid: process.pid, role: "agent-embedded" });
+ await expect(fs.stat(stateLockPath)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+ });
+
+ it("releases canonical state ownership when a direct CLI probe receives SIGTERM", async () => {
+ await withTempState(async (stateDir) => {
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const stateLockPath = path.join(lockOptions.lockDir!, "gateway.state.lock");
+ const signals = createSignalProcess();
+
+ await probeModule.withAuthProbeStateOwnership(
+ {
+ mode: "exclusive",
+ gatewayLockOptions: lockOptions,
+ process: signals.processLike,
+ },
+ async (signal) => {
+ let markInterrupted!: () => void;
+ const interrupted = new Promise((resolve) => {
+ markInterrupted = resolve;
+ });
+ signal?.addEventListener("abort", markInterrupted, { once: true });
+ signals.emit("SIGTERM");
+ await interrupted;
+ },
+ );
+
+ await expect(fs.stat(stateLockPath)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+ });
+
it("runs Codex auth probes through raw OpenClaw model-run mode", async () => {
const runEmbeddedAgent = vi.fn(
async (_params: {
diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts
index aa4e7267b411..59a3cb87fe0c 100644
--- a/src/commands/models/list.probe.ts
+++ b/src/commands/models/list.probe.ts
@@ -39,6 +39,7 @@ import { findNormalizedProviderValue, normalizeProviderId } from "../../agents/m
import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
import { resolveProviderIdForAuth } from "../../agents/provider-auth-aliases.js";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
+import { formatCliCommand } from "../../cli/command-format.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
@@ -46,6 +47,11 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../../config/types.secrets.js";
+import type {
+ EmbeddedStateLockHandle,
+ EmbeddedStateSignalProcess,
+} from "../../infra/embedded-state-lock.js";
+import type { GatewayLockIdentity, GatewayLockOptions } from "../../infra/gateway-lock.js";
import { type SecretRefResolveCache, resolveSecretRefString } from "../../secrets/resolve.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { disposeOpenClawAgentDatabaseByPath } from "../../state/openclaw-agent-db.js";
@@ -723,6 +729,7 @@ async function probeTarget(params: {
target: AuthProbeTarget;
timeoutMs: number;
maxTokens: number;
+ abortSignal?: AbortSignal;
}): Promise {
const { cfg, agentId, agentDir, workspaceDir, storePath, target, timeoutMs, maxTokens } = params;
// Marker credentials must be resolved by the runtime from config, but the
@@ -834,6 +841,7 @@ async function probeTarget(params: {
disableTools: true,
modelRun: true,
cleanupBundleMcpOnRunEnd: true,
+ abortSignal: params.abortSignal,
});
return buildResult("ok");
} catch (err) {
@@ -862,6 +870,7 @@ async function runTargetsWithConcurrency(params: {
maxTokens: number;
concurrency: number;
onProgress?: (update: { completed: number; total: number; label?: string }) => void;
+ abortSignal?: AbortSignal;
}): Promise {
const { cfg, targets, timeoutMs, maxTokens, onProgress } = params;
const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency));
@@ -894,15 +903,55 @@ async function runTargetsWithConcurrency(params: {
target,
timeoutMs,
maxTokens,
+ abortSignal: params.abortSignal,
});
completed += 1;
onProgress?.({ completed, total: targets.length });
return result;
},
- { concurrency, stopOnError: true },
+ {
+ concurrency,
+ stopOnError: true,
+ ...(params.abortSignal ? { signal: params.abortSignal } : {}),
+ },
);
}
+function formatActiveGatewayModelsProbeRefusal(identity: GatewayLockIdentity): string {
+ return `A Gateway is running for this state directory (pid ${identity.pid}, port ${identity.port}). Stop the Gateway first (${formatCliCommand("openclaw gateway stop")}), then rerun models status --probe.`;
+}
+
+type AuthProbeStateOwnership = {
+ mode: "exclusive";
+ gatewayLockOptions?: GatewayLockOptions;
+ process?: EmbeddedStateSignalProcess;
+};
+
+/** Own canonical state only for direct CLI probes; Gateway RPC probes already run under its lock. */
+export async function withAuthProbeStateOwnership(
+ ownership: AuthProbeStateOwnership | undefined,
+ run: (signal?: AbortSignal) => Promise,
+): Promise {
+ if (!ownership) {
+ return await run();
+ }
+ const { acquireEmbeddedStateLock, createEmbeddedStateSignalBridge } =
+ await import("../../infra/embedded-state-lock.js");
+ const signalBridge = createEmbeddedStateSignalBridge(ownership.process ?? process);
+ let stateLock: EmbeddedStateLockHandle | null | undefined;
+ try {
+ stateLock = await acquireEmbeddedStateLock({
+ options: ownership.gatewayLockOptions,
+ signal: signalBridge.signal,
+ formatActiveGatewayRefusal: formatActiveGatewayModelsProbeRefusal,
+ });
+ return await run(signalBridge.signal);
+ } finally {
+ await stateLock?.release();
+ signalBridge.dispose();
+ }
+}
+
/** Runs all auth probes with bounded concurrency and returns a summary. */
export async function runAuthProbes(params: {
cfg: OpenClawConfig;
@@ -913,45 +962,49 @@ export async function runAuthProbes(params: {
modelCandidates: string[];
options: AuthProbeOptions;
onProgress?: (update: { completed: number; total: number; label?: string }) => void;
+ stateOwnership?: AuthProbeStateOwnership;
}): Promise {
- const startedAt = Date.now();
- const plan = await buildProbeTargets({
- cfg: params.cfg,
- ...(params.agentId ? { agentId: params.agentId } : {}),
- agentDir: params.agentDir,
- workspaceDir: params.workspaceDir,
- providers: params.providers,
- modelCandidates: params.modelCandidates,
- options: params.options,
+ return await withAuthProbeStateOwnership(params.stateOwnership, async (abortSignal) => {
+ const startedAt = Date.now();
+ const plan = await buildProbeTargets({
+ cfg: params.cfg,
+ ...(params.agentId ? { agentId: params.agentId } : {}),
+ agentDir: params.agentDir,
+ workspaceDir: params.workspaceDir,
+ providers: params.providers,
+ modelCandidates: params.modelCandidates,
+ options: params.options,
+ });
+
+ const totalTargets = plan.targets.length;
+ params.onProgress?.({ completed: 0, total: totalTargets });
+
+ const results = totalTargets
+ ? await runTargetsWithConcurrency({
+ cfg: params.cfg,
+ agentId: params.agentId,
+ agentDir: params.agentDir,
+ workspaceDir: params.workspaceDir,
+ targets: plan.targets,
+ timeoutMs: params.options.timeoutMs,
+ maxTokens: params.options.maxTokens,
+ concurrency: params.options.concurrency,
+ onProgress: params.onProgress,
+ abortSignal,
+ })
+ : [];
+
+ const finishedAt = Date.now();
+
+ return {
+ startedAt,
+ finishedAt,
+ durationMs: finishedAt - startedAt,
+ totalTargets,
+ options: params.options,
+ results: [...plan.results, ...results],
+ };
});
-
- const totalTargets = plan.targets.length;
- params.onProgress?.({ completed: 0, total: totalTargets });
-
- const results = totalTargets
- ? await runTargetsWithConcurrency({
- cfg: params.cfg,
- agentId: params.agentId,
- agentDir: params.agentDir,
- workspaceDir: params.workspaceDir,
- targets: plan.targets,
- timeoutMs: params.options.timeoutMs,
- maxTokens: params.options.maxTokens,
- concurrency: params.options.concurrency,
- onProgress: params.onProgress,
- })
- : [];
-
- const finishedAt = Date.now();
-
- return {
- startedAt,
- finishedAt,
- durationMs: finishedAt - startedAt,
- totalTargets,
- options: params.options,
- results: [...plan.results, ...results],
- };
}
/** Formats probe latency for table output. */
diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts
index eb7f6720448d..9d1fa4e3536b 100644
--- a/src/commands/models/list.status-command.ts
+++ b/src/commands/models/list.status-command.ts
@@ -1223,6 +1223,9 @@ export async function modelsStatusCommand(
concurrency: probeConcurrency,
maxTokens: probeMaxTokens,
},
+ // Direct CLI probes create hidden sessions in the canonical agent DB.
+ // Gateway RPC probes omit this because the Gateway already owns the lock.
+ stateOwnership: { mode: "exclusive" },
onProgress: update,
});
},
diff --git a/src/infra/embedded-state-lock.ts b/src/infra/embedded-state-lock.ts
new file mode 100644
index 000000000000..c3e1a6a08a39
--- /dev/null
+++ b/src/infra/embedded-state-lock.ts
@@ -0,0 +1,100 @@
+// Coordinates direct embedded state writers with the Gateway state-directory owner.
+import { createAbortError } from "./abort-signal.js";
+import type { GatewayLockIdentity, GatewayLockOptions } from "./gateway-lock.js";
+
+export type EmbeddedStateSignal = "SIGINT" | "SIGTERM";
+
+export type EmbeddedStateSignalProcess = {
+ on(signal: EmbeddedStateSignal, handler: () => void): unknown;
+ off(signal: EmbeddedStateSignal, handler: () => void): unknown;
+};
+
+export type EmbeddedStateLockHandle = {
+ release: () => Promise;
+};
+
+const EMBEDDED_STATE_SIGNALS: readonly EmbeddedStateSignal[] = ["SIGINT", "SIGTERM"];
+
+function abortableDelay(ms: number, signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ return Promise.reject(createAbortError("embedded state lock acquisition aborted"));
+ }
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ signal?.removeEventListener("abort", onAbort);
+ resolve();
+ }, ms);
+ const onAbort = () => {
+ clearTimeout(timer);
+ signal?.removeEventListener("abort", onAbort);
+ reject(createAbortError("embedded state lock acquisition aborted"));
+ };
+ signal?.addEventListener("abort", onAbort, { once: true });
+ });
+}
+
+/** Bridges process signals into embedded-run cancellation so lock cleanup can unwind. */
+export function createEmbeddedStateSignalBridge(processLike: EmbeddedStateSignalProcess = process) {
+ const controller = new AbortController();
+ let receivedSignal: EmbeddedStateSignal | undefined;
+ const handlers = new Map void>();
+ const dispose = () => {
+ for (const [signal, handler] of handlers) {
+ processLike.off(signal, handler);
+ }
+ handlers.clear();
+ };
+ for (const signal of EMBEDDED_STATE_SIGNALS) {
+ const handler = () => {
+ receivedSignal = signal;
+ if (!controller.signal.aborted) {
+ controller.abort();
+ dispose();
+ }
+ };
+ handlers.set(signal, handler);
+ processLike.on(signal, handler);
+ }
+ return {
+ signal: controller.signal,
+ getReceivedSignal: () => receivedSignal,
+ dispose,
+ };
+}
+
+/** Probe the Gateway owner first, then acquire the shared embedded-writer role. */
+export async function acquireEmbeddedStateLock(params: {
+ options?: GatewayLockOptions;
+ signal?: AbortSignal;
+ formatActiveGatewayRefusal: (identity: GatewayLockIdentity) => string;
+}): Promise {
+ const { acquireGatewayLock, GatewayLockError, readActiveGatewayLockIdentity } =
+ await import("./gateway-lock.js");
+ const env = params.options?.env ?? process.env;
+ if (
+ params.options?.allowInTests !== true &&
+ (env.VITEST !== undefined || env.NODE_ENV === "test")
+ ) {
+ return null;
+ }
+ const activeGateway = await readActiveGatewayLockIdentity(params.options);
+ if (activeGateway) {
+ throw new GatewayLockError(params.formatActiveGatewayRefusal(activeGateway));
+ }
+ try {
+ return await acquireGatewayLock({
+ ...params.options,
+ role: "agent-embedded",
+ sleep: params.options?.sleep ?? (async (ms) => await abortableDelay(ms, params.signal)),
+ });
+ } catch (error) {
+ if (!(error instanceof GatewayLockError)) {
+ throw error;
+ }
+ const racedGateway = await readActiveGatewayLockIdentity(params.options);
+ if (racedGateway) {
+ throw new GatewayLockError(params.formatActiveGatewayRefusal(racedGateway), error);
+ }
+ throw error;
+ }
+}
diff --git a/src/infra/gateway-lock.roles.test.ts b/src/infra/gateway-lock.roles.test.ts
index 9f1401d16ae1..2ad4978b8cb6 100644
--- a/src/infra/gateway-lock.roles.test.ts
+++ b/src/infra/gateway-lock.roles.test.ts
@@ -174,7 +174,7 @@ describe("Gateway lock roles", () => {
timeoutMs: 15,
}),
).rejects.toThrow(
- `another openclaw agent --local run is active (pid ${process.pid}); lock timeout after 15ms`,
+ `another embedded OpenClaw state writer is active (pid ${process.pid}); lock timeout after 15ms`,
);
} finally {
await lock.release();
diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts
index 6a72aa6a8097..3a06cf2943c0 100644
--- a/src/infra/gateway-lock.ts
+++ b/src/infra/gateway-lock.ts
@@ -15,7 +15,12 @@ import { getFileLockProcessStartTime, isPidAlive } from "../shared/pid-alive.js"
import { safeParseJsonWithSchema } from "../utils/zod-parse.js";
import { sha256HexPrefix } from "./crypto-digest.js";
import { createFileLockManager } from "./file-lock-manager.js";
-import { isGatewayArgv, isOpenClawCommandArgv, parseProcCmdline } from "./gateway-process-argv.js";
+import {
+ isGatewayArgv,
+ isOpenClawArgv,
+ isOpenClawCommandArgv,
+ parseProcCmdline,
+} from "./gateway-process-argv.js";
import { tryAcquireExclusiveSqliteCoordinator } from "./node-sqlite.js";
import {
readWindowsProcessArgsSync,
@@ -210,7 +215,10 @@ async function resolveGatewayOwnerStatus(
return "unknown";
}
if (role === "agent-embedded") {
- return isOpenClawCommandArgv(args, "agent") && args.includes("--local") ? "alive" : "dead";
+ // The role covers every direct embedded surface (agent --local, agent exec,
+ // local TUI, and CLI model probes), so validate the owning OpenClaw process
+ // instead of baking one command spelling into stale-lock recovery.
+ return isOpenClawArgv(args) ? "alive" : "dead";
}
const command = role === "sqlite-maintenance" ? "doctor" : "skills";
return isOpenClawCommandArgv(args, command) ? "alive" : "dead";
@@ -557,7 +565,7 @@ async function acquireLockFile(
const ownerPid = lastPayload?.pid ? ` (pid ${lastPayload.pid})` : "";
const owner =
lastPayload?.role === "agent-embedded"
- ? `another openclaw agent --local run is active${ownerPid}`
+ ? `another embedded OpenClaw state writer is active${ownerPid}`
: lastPayload?.role && lastPayload.role !== "gateway"
? `state directory is locked by ${lastPayload.role}${ownerPid}`
: `gateway already running${ownerPid}`;
diff --git a/src/infra/gateway-process-argv.test.ts b/src/infra/gateway-process-argv.test.ts
index e4170a19c5bb..4e44d88cae0d 100644
--- a/src/infra/gateway-process-argv.test.ts
+++ b/src/infra/gateway-process-argv.test.ts
@@ -1,6 +1,11 @@
// Tests gateway process argv parsing for diagnostics.
import { describe, expect, it } from "vitest";
-import { isGatewayArgv, isOpenClawCommandArgv, parseProcCmdline } from "./gateway-process-argv.js";
+import {
+ isGatewayArgv,
+ isOpenClawArgv,
+ isOpenClawCommandArgv,
+ parseProcCmdline,
+} from "./gateway-process-argv.js";
describe("parseProcCmdline", () => {
it("splits null-delimited argv and trims empty entries", () => {
@@ -72,3 +77,18 @@ describe("isOpenClawCommandArgv", () => {
expect(isOpenClawCommandArgv(["python", "doctor", "worker.py"], "doctor")).toBe(false);
});
});
+
+describe("isOpenClawArgv", () => {
+ it.each([
+ ["agent exec", ["openclaw", "agent", "exec", "task"]],
+ ["local TUI", ["node", "/srv/openclaw/openclaw.mjs", "tui", "--local"]],
+ ["models probe", ["openclaw", "models", "status", "--probe"]],
+ ["bare local TUI", ["openclaw"]],
+ ])("recognizes the %s embedded owner", (_label, argv) => {
+ expect(isOpenClawArgv(argv)).toBe(true);
+ });
+
+ it("rejects an unrelated process", () => {
+ expect(isOpenClawArgv(["python", "worker.py"])).toBe(false);
+ });
+});
diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts
index d155f8c842dc..5150c6d725bf 100644
--- a/src/infra/gateway-process-argv.ts
+++ b/src/infra/gateway-process-argv.ts
@@ -19,18 +19,20 @@ export function parseProcCmdline(raw: string): string[] {
return normalizeStringEntries(raw.split("\0"));
}
-export function isOpenClawCommandArgv(args: string[], command: string): boolean {
+export function isOpenClawArgv(args: string[]): boolean {
const normalized = args.map(normalizeProcArg);
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
- if (!normalized.includes(normalizeProcArg(command))) {
- return false;
- }
if (normalized.some((arg) => ENTRY_CANDIDATES.some((entry) => arg.endsWith(entry)))) {
return true;
}
return exe.endsWith("/openclaw") || exe === "openclaw";
}
+export function isOpenClawCommandArgv(args: string[], command: string): boolean {
+ const normalizedCommand = normalizeProcArg(command);
+ return args.some((arg) => normalizeProcArg(arg) === normalizedCommand) && isOpenClawArgv(args);
+}
+
export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean {
const normalized = args.map(normalizeProcArg);
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
diff --git a/src/tui/tui.state-lock.test.ts b/src/tui/tui.state-lock.test.ts
new file mode 100644
index 000000000000..f429c2d43fa7
--- /dev/null
+++ b/src/tui/tui.state-lock.test.ts
@@ -0,0 +1,126 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it, vi } from "vitest";
+import { acquireGatewayLock, type GatewayLockOptions } from "../infra/gateway-lock.js";
+import { withEmbeddedTuiStateLock } from "./tui.js";
+
+function createGatewayLockOptions(stateDir: string): GatewayLockOptions {
+ return {
+ allowInTests: true,
+ env: {
+ ...process.env,
+ OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
+ OPENCLAW_STATE_DIR: stateDir,
+ },
+ lockDir: path.join(stateDir, "gateway-locks"),
+ readProcessStartTime: () => 123_456,
+ timeoutMs: 100,
+ };
+}
+
+function createSignalProcess() {
+ type SignalName = "SIGINT" | "SIGTERM";
+ const listeners = new Map void>>();
+ const processLike = {
+ on(signal: SignalName, handler: () => void) {
+ const current = listeners.get(signal) ?? new Set<() => void>();
+ current.add(handler);
+ listeners.set(signal, current);
+ return processLike;
+ },
+ off(signal: SignalName, handler: () => void) {
+ listeners.get(signal)?.delete(handler);
+ return processLike;
+ },
+ };
+ return {
+ processLike,
+ emit(signal: SignalName) {
+ for (const handler of listeners.get(signal) ?? []) {
+ handler();
+ }
+ },
+ };
+}
+
+async function withTempState(run: (stateDir: string) => Promise): Promise {
+ const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tui-state-lock-"));
+ try {
+ return await run(stateDir);
+ } finally {
+ await fs.rm(stateDir, { recursive: true, force: true });
+ }
+}
+
+describe("embedded TUI state ownership", () => {
+ it("refuses local startup while a live Gateway owns the state directory", async () => {
+ await withTempState(async (stateDir) => {
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const gatewayLock = await acquireGatewayLock({ ...lockOptions, port: 28789 });
+ expect(gatewayLock).not.toBeNull();
+ if (!gatewayLock) {
+ throw new Error("Expected live Gateway fixture lock");
+ }
+ const run = vi.fn(async () => undefined);
+ try {
+ await expect(
+ withEmbeddedTuiStateLock(run, { gatewayLockOptions: lockOptions }),
+ ).rejects.toThrow(
+ `A Gateway is running for this state directory (pid ${process.pid}, port 28789). Run without --local to use it, or stop the Gateway first (openclaw gateway stop).`,
+ );
+ expect(run).not.toHaveBeenCalled();
+ } finally {
+ await gatewayLock.release();
+ }
+ });
+ });
+
+ it("holds and releases embedded state ownership for the local TUI lifetime", async () => {
+ await withTempState(async (stateDir) => {
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const stateLockPath = path.join(lockOptions.lockDir!, "gateway.state.lock");
+
+ await withEmbeddedTuiStateLock(
+ async () => {
+ const payload = JSON.parse(await fs.readFile(stateLockPath, "utf8")) as {
+ pid?: number;
+ role?: string;
+ };
+ expect(payload).toMatchObject({ pid: process.pid, role: "agent-embedded" });
+ },
+ { gatewayLockOptions: lockOptions },
+ );
+
+ await expect(fs.stat(stateLockPath)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+ });
+
+ it("releases embedded state ownership when the local TUI receives SIGTERM", async () => {
+ await withTempState(async (stateDir) => {
+ const lockOptions = createGatewayLockOptions(stateDir);
+ const stateLockPath = path.join(lockOptions.lockDir!, "gateway.state.lock");
+ const signals = createSignalProcess();
+ let markStarted!: () => void;
+ const started = new Promise((resolve) => {
+ markStarted = resolve;
+ });
+ const run = withEmbeddedTuiStateLock(
+ async (signal) => {
+ markStarted();
+ return await new Promise((_, reject) => {
+ signal.addEventListener("abort", () => reject(new Error("local TUI interrupted")), {
+ once: true,
+ });
+ });
+ },
+ { gatewayLockOptions: lockOptions, process: signals.processLike },
+ );
+ await started;
+ signals.emit("SIGTERM");
+
+ await expect(run).rejects.toThrow("local TUI interrupted");
+ await expect(fs.stat(stateLockPath)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+ });
+});
diff --git a/src/tui/tui.ts b/src/tui/tui.ts
index d02e7f637a61..c7d2e0fb880e 100644
--- a/src/tui/tui.ts
+++ b/src/tui/tui.ts
@@ -14,8 +14,11 @@ import { classifyGatewayConnectFailure } from "../../packages/gateway-protocol/s
import type { CommandEntry } from "../../packages/gateway-protocol/src/index.js";
import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { normalizeThinkLevel } from "../auto-reply/thinking.shared.js";
+import { formatCliCommand } from "../cli/command-format.js";
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js";
+import type { EmbeddedStateSignalProcess } from "../infra/embedded-state-lock.js";
+import type { GatewayLockIdentity, GatewayLockOptions } from "../infra/gateway-lock.js";
import { resolveCurrentOpenClawCliInvocation } from "../infra/openclaw-cli-invocation.js";
import { tryProcessCwd } from "../infra/safe-cwd.js";
import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js";
@@ -598,7 +601,43 @@ function resolveEmptySessionInfoDefaults(config: OpenClawConfig): SessionInfo {
};
}
+function formatActiveGatewayTuiRefusal(identity: GatewayLockIdentity): string {
+ return `A Gateway is running for this state directory (pid ${identity.pid}, port ${identity.port}). Run without --local to use it, or stop the Gateway first (${formatCliCommand("openclaw gateway stop")}).`;
+}
+
+/** Hold canonical state ownership for the complete lifetime of a local TUI. */
+export async function withEmbeddedTuiStateLock(
+ run: (signal: AbortSignal) => Promise,
+ deps: {
+ gatewayLockOptions?: GatewayLockOptions;
+ process?: EmbeddedStateSignalProcess;
+ } = {},
+): Promise {
+ const { acquireEmbeddedStateLock, createEmbeddedStateSignalBridge } =
+ await import("../infra/embedded-state-lock.js");
+ const signalBridge = createEmbeddedStateSignalBridge(deps.process ?? process);
+ let stateLock: Awaited> | undefined;
+ try {
+ stateLock = await acquireEmbeddedStateLock({
+ options: deps.gatewayLockOptions,
+ signal: signalBridge.signal,
+ formatActiveGatewayRefusal: formatActiveGatewayTuiRefusal,
+ });
+ return await run(signalBridge.signal);
+ } finally {
+ await stateLock?.release();
+ signalBridge.dispose();
+ }
+}
+
export async function runTui(opts: RunTuiOptions): Promise {
+ if (opts.local === true && opts.backend === undefined) {
+ return await withEmbeddedTuiStateLock(async () => await runTuiUnlocked(opts));
+ }
+ return await runTuiUnlocked(opts);
+}
+
+async function runTuiUnlocked(opts: RunTuiOptions): Promise {
const isLocalMode = opts.local === true || opts.backend !== undefined;
const config = opts.config ?? getRuntimeConfig({ skipPluginValidation: !isLocalMode });
const cliInvocation = resolveCurrentOpenClawCliInvocation([]);