fix(tui): prevent orphaned terminal sessions (#77662)

* fix(tui): prevent orphaned terminal sessions

* fix(doctor): repair heartbeat-poisoned main sessions

* fix(tui): preserve startup tls respawn

* fix: harden tui and doctor recovery paths
This commit is contained in:
Vincent Koc
2026-05-05 16:34:18 -07:00
committed by GitHub
parent 82fd83418e
commit 5af1fe1bd0
14 changed files with 1090 additions and 38 deletions

View File

@@ -89,6 +89,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc.
- Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc.
- Gateway/shutdown: cancel delayed post-ready maintenance during close and suppress maintenance/cron startup after quick restarts, preventing orphaned background timers. Thanks @vincentkoc.
- Agents/generated media: treat attachment-style message tool actions as completed chat sends, preventing duplicate fallback media posts when generated files were already uploaded.
- Control UI/sessions: show each session's agent runtime in the Sessions table and allow filtering by runtime labels, matching the Agents panel runtime wording. Thanks @vincentkoc.

View File

@@ -4,7 +4,10 @@ import { registerDnsCli } from "./dns-cli.js";
import { parseCanvasSnapshotPayload } from "./nodes-canvas.js";
import { parseByteSize } from "./parse-bytes.js";
import { parseDurationMs } from "./parse-duration.js";
import { shouldSkipRespawnForArgv } from "./respawn-policy.js";
import {
shouldSkipRespawnForArgv,
shouldSkipStartupEnvironmentRespawnForArgv,
} from "./respawn-policy.js";
import { waitForever } from "./wait.js";
describe("waitForever", () => {
@@ -21,6 +24,9 @@ describe("shouldSkipRespawnForArgv", () => {
it.each([
{ argv: ["node", "openclaw", "--help"] },
{ argv: ["node", "openclaw", "-V"] },
{ argv: ["node", "openclaw", "tui"] },
{ argv: ["node", "openclaw", "terminal"] },
{ argv: ["node", "openclaw", "chat"] },
{ argv: ["node", "openclaw", "gateway"] },
{ argv: ["node", "openclaw", "gateway", "--port", "14720", "--bind", "loopback"] },
{ argv: ["node", "openclaw", "gateway", "run", "--port=14720", "--bind", "loopback"] },
@@ -40,6 +46,25 @@ describe("shouldSkipRespawnForArgv", () => {
});
});
describe("shouldSkipStartupEnvironmentRespawnForArgv", () => {
it.each([
{ argv: ["node", "openclaw", "--help"] },
{ argv: ["node", "openclaw", "gateway"] },
{ argv: ["node", "openclaw", "gateway", "run", "--port=14720"] },
] as const)("skips startup env respawn for argv %j", ({ argv }) => {
expect(shouldSkipStartupEnvironmentRespawnForArgv([...argv]), argv.join(" ")).toBe(true);
});
it.each([
{ argv: ["node", "openclaw", "tui"] },
{ argv: ["node", "openclaw", "terminal"] },
{ argv: ["node", "openclaw", "chat"] },
{ argv: ["node", "openclaw", "status"] },
] as const)("allows startup env respawn for argv %j", ({ argv }) => {
expect(shouldSkipStartupEnvironmentRespawnForArgv([...argv]), argv.join(" ")).toBe(false);
});
});
describe("nodes canvas helpers", () => {
it("parses canvas.snapshot payload", () => {
expect(parseCanvasSnapshotPayload({ format: "png", base64: "aGk=" })).toEqual({

View File

@@ -26,6 +26,8 @@ const GATEWAY_RUN_VALUE_FLAGS = [
"--ws-log",
] as const;
const INTERACTIVE_TTY_COMMANDS = new Set(["tui", "terminal", "chat"]);
function isForegroundGatewayRunArgv(argv: string[]): boolean {
const positionals = getCommandPositionalsWithRootOptions(argv, {
commandPath: ["gateway"],
@@ -39,6 +41,15 @@ function isForegroundGatewayRunArgv(argv: string[]): boolean {
}
export function shouldSkipRespawnForArgv(argv: string[]): boolean {
const invocation = resolveCliArgvInvocation(argv);
return (
invocation.hasHelpOrVersion ||
(invocation.primary !== null && INTERACTIVE_TTY_COMMANDS.has(invocation.primary)) ||
(invocation.primary === "gateway" && isForegroundGatewayRunArgv(argv))
);
}
export function shouldSkipStartupEnvironmentRespawnForArgv(argv: string[]): boolean {
const invocation = resolveCliArgvInvocation(argv);
return (
invocation.hasHelpOrVersion ||

View File

@@ -0,0 +1,321 @@
import fs from "node:fs";
import path from "node:path";
import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js";
import { formatSessionArchiveTimestamp } from "../config/sessions/artifacts.js";
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
import {
resolveSessionFilePath,
type resolveSessionFilePathOptions,
} from "../config/sessions/paths.js";
import { updateSessionStore } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { asNullableObjectRecord } from "../shared/record-coerce.js";
import type { note } from "../terminal/note.js";
type DoctorPrompterLike = {
confirmRuntimeRepair: (params: {
message: string;
initialValue?: boolean;
requiresInteractiveConfirmation?: boolean;
}) => Promise<boolean>;
note?: typeof note;
};
type TranscriptHeartbeatSummary = {
inspectedMessages: number;
userMessages: number;
heartbeatUserMessages: number;
nonHeartbeatUserMessages: number;
assistantMessages: number;
heartbeatOkAssistantMessages: number;
};
export type HeartbeatMainSessionRepairCandidate = {
reason: "metadata" | "transcript";
summary?: TranscriptHeartbeatSummary;
};
function countLabel(count: number, singular: string, plural = `${singular}s`): string {
return `${count} ${count === 1 ? singular : plural}`;
}
function existsFile(filePath: string): boolean {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function sessionEntryHasSyntheticHeartbeatOwnership(entry: SessionEntry): boolean {
return (
typeof entry.heartbeatIsolatedBaseSessionKey === "string" &&
entry.heartbeatIsolatedBaseSessionKey.trim().length > 0
);
}
function parseTranscriptMessageLine(line: string): { role: string; content?: unknown } | null {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
return null;
}
const record = asNullableObjectRecord(parsed);
if (!record) {
return null;
}
const nested = asNullableObjectRecord(record.message);
const message = nested ?? record;
const role = message.role;
if (typeof role !== "string") {
return null;
}
return { role, content: message.content };
}
function summarizeTranscriptHeartbeatMessages(
transcriptPath: string,
): TranscriptHeartbeatSummary | null {
let raw: string;
try {
raw = fs.readFileSync(transcriptPath, "utf8");
} catch {
return null;
}
const summary: TranscriptHeartbeatSummary = {
inspectedMessages: 0,
userMessages: 0,
heartbeatUserMessages: 0,
nonHeartbeatUserMessages: 0,
assistantMessages: 0,
heartbeatOkAssistantMessages: 0,
};
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const message = parseTranscriptMessageLine(trimmed);
if (!message) {
continue;
}
summary.inspectedMessages += 1;
if (message.role === "user") {
summary.userMessages += 1;
if (isHeartbeatUserMessage(message)) {
summary.heartbeatUserMessages += 1;
} else {
summary.nonHeartbeatUserMessages += 1;
}
} else if (message.role === "assistant") {
summary.assistantMessages += 1;
if (isHeartbeatOkResponse(message)) {
summary.heartbeatOkAssistantMessages += 1;
}
}
}
return summary.inspectedMessages > 0 ? summary : null;
}
export function resolveHeartbeatMainSessionRepairCandidate(params: {
entry: SessionEntry | undefined;
transcriptPath?: string;
}): HeartbeatMainSessionRepairCandidate | null {
const { entry, transcriptPath } = params;
if (!entry) {
return null;
}
const hasNoRecordedHumanInteraction = entry.lastInteractionAt === undefined;
if (!hasNoRecordedHumanInteraction) {
return null;
}
const hasSyntheticHeartbeatOwnership = sessionEntryHasSyntheticHeartbeatOwnership(entry);
if (hasSyntheticHeartbeatOwnership && !transcriptPath) {
return { reason: "metadata" };
}
if (!transcriptPath) {
return null;
}
const summary = summarizeTranscriptHeartbeatMessages(transcriptPath);
if (!summary) {
return null;
}
if (
summary.heartbeatUserMessages > 0 &&
summary.userMessages === summary.heartbeatUserMessages &&
summary.nonHeartbeatUserMessages === 0
) {
return { reason: hasSyntheticHeartbeatOwnership ? "metadata" : "transcript", summary };
}
return null;
}
function resolveHeartbeatMainRecoveryKey(params: {
mainKey: string;
store: Record<string, SessionEntry>;
nowMs?: number;
}): string | null {
const parsed = parseAgentSessionKey(params.mainKey);
if (!parsed) {
return null;
}
const stamp = formatSessionArchiveTimestamp(params.nowMs).toLowerCase();
const base = `agent:${parsed.agentId}:heartbeat-recovered-${stamp}`;
if (!params.store[base]) {
return base;
}
for (let index = 2; index <= 100; index += 1) {
const candidate = `${base}-${index}`;
if (!params.store[candidate]) {
return candidate;
}
}
return null;
}
export function moveHeartbeatMainSessionEntry(params: {
store: Record<string, SessionEntry>;
mainKey: string;
recoveredKey: string;
}): boolean {
const entry = params.store[params.mainKey];
if (!entry || params.store[params.recoveredKey]) {
return false;
}
params.store[params.recoveredKey] = entry;
delete params.store[params.mainKey];
return true;
}
function resolveTuiLastSessionPath(stateDir: string): string {
return path.join(stateDir, "tui", "last-session.json");
}
export function clearTuiLastSessionPointers(params: {
filePath: string;
sessionKeys: ReadonlySet<string>;
}): number {
if (params.sessionKeys.size === 0 || !existsFile(params.filePath)) {
return 0;
}
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(params.filePath, "utf8"));
} catch {
return 0;
}
const store = asNullableObjectRecord(parsed);
if (!store) {
return 0;
}
let removed = 0;
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(store)) {
const record = asNullableObjectRecord(value);
const sessionKey = record?.sessionKey;
if (typeof sessionKey === "string" && params.sessionKeys.has(sessionKey)) {
removed += 1;
continue;
}
next[key] = value;
}
if (removed === 0) {
return 0;
}
try {
fs.writeFileSync(params.filePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
} catch {
return 0;
}
return removed;
}
export async function repairHeartbeatPoisonedMainSession(params: {
cfg: OpenClawConfig;
store: Record<string, SessionEntry>;
absoluteStorePath: string;
stateDir: string;
sessionPathOpts: ReturnType<typeof resolveSessionFilePathOptions>;
prompter: DoctorPrompterLike;
warnings: string[];
changes: string[];
}) {
const mainKey = resolveMainSessionKey(params.cfg);
const mainEntry = params.store[mainKey];
if (!mainEntry?.sessionId) {
return;
}
let transcriptPath: string | undefined;
try {
transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, params.sessionPathOpts);
} catch {
transcriptPath = undefined;
}
const candidate = resolveHeartbeatMainSessionRepairCandidate({
entry: mainEntry,
transcriptPath,
});
if (!candidate) {
return;
}
const recoveredKey = resolveHeartbeatMainRecoveryKey({
mainKey,
store: params.store,
});
if (!recoveredKey) {
params.warnings.push(
`- Main session ${mainKey} appears heartbeat-owned, but doctor could not choose a safe recovery key.`,
);
return;
}
const reason =
candidate.reason === "metadata"
? "heartbeat metadata"
: `${candidate.summary?.heartbeatUserMessages ?? 0} heartbeat-only user message(s)`;
params.warnings.push(
[
`- Main session ${mainKey} appears to be a heartbeat-owned session (${reason}).`,
` Doctor can move it to ${recoveredKey} and let the next interactive launch create a fresh main session.`,
].join("\n"),
);
const shouldRepair = await params.prompter.confirmRuntimeRepair({
message: `Move heartbeat-owned main session ${mainKey} to ${recoveredKey} and clear stale TUI restore pointers?`,
initialValue: true,
});
if (!shouldRepair) {
return;
}
let movedEntry: SessionEntry | undefined;
await updateSessionStore(params.absoluteStorePath, (currentStore) => {
const currentEntry = currentStore[mainKey];
const currentCandidate = resolveHeartbeatMainSessionRepairCandidate({
entry: currentEntry,
transcriptPath,
});
if (!currentCandidate) {
return;
}
if (moveHeartbeatMainSessionEntry({ store: currentStore, mainKey, recoveredKey })) {
movedEntry = currentEntry;
}
});
if (!movedEntry) {
params.warnings.push(`- Main session ${mainKey} changed before repair could move it.`);
return;
}
params.store[recoveredKey] = movedEntry;
delete params.store[mainKey];
const clearedPointers = clearTuiLastSessionPointers({
filePath: resolveTuiLastSessionPath(params.stateDir),
sessionKeys: new Set([mainKey]),
});
params.changes.push(`- Moved heartbeat-owned main session ${mainKey} to ${recoveredKey}.`);
if (clearedPointers > 0) {
params.changes.push(
`- Cleared ${countLabel(clearedPointers, "stale TUI last-session pointer")} for ${mainKey}.`,
);
}
}

View File

@@ -2,11 +2,18 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../auto-reply/heartbeat.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveStorePath,
resolveSessionTranscriptsDirForAgent,
} from "../config/sessions/paths.js";
import type { SessionEntry } from "../config/sessions/types.js";
import {
clearTuiLastSessionPointers,
moveHeartbeatMainSessionEntry,
resolveHeartbeatMainSessionRepairCandidate,
} from "./doctor-heartbeat-main-session-repair.js";
import { noteStateIntegrity } from "./doctor-state-integrity.js";
vi.mock("../channels/plugins/bundled-ids.js", () => ({
@@ -478,6 +485,267 @@ describe("doctor state integrity oauth dir checks", () => {
expect(text).not.toContain(" ls ");
});
it("moves a heartbeat-poisoned main session and clears stale TUI restore pointers", async () => {
const cfg: OpenClawConfig = {};
setupSessionState(cfg, process.env, tempHome);
const sessionsDir = resolveSessionTranscriptsDirForAgent("main", process.env, () => tempHome);
fs.writeFileSync(
path.join(sessionsDir, "heartbeat-session.jsonl"),
[
JSON.stringify({ message: { role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT } }),
JSON.stringify({ message: { role: "assistant", content: "HEARTBEAT_OK" } }),
"",
].join("\n"),
);
writeSessionStore(cfg, {
"agent:main:main": {
sessionId: "heartbeat-session",
updatedAt: Date.now(),
},
});
const tuiLastSessionPath = path.join(
process.env.OPENCLAW_STATE_DIR ?? "",
"tui",
"last-session.json",
);
fs.mkdirSync(path.dirname(tuiLastSessionPath), { recursive: true });
fs.writeFileSync(
tuiLastSessionPath,
JSON.stringify(
{
default: { sessionKey: "agent:main:main", updatedAt: Date.now() },
telegram: { sessionKey: "agent:main:telegram:thread", updatedAt: Date.now() },
},
null,
2,
),
);
const confirmRuntimeRepair = vi.fn(async (params: { message: string }) =>
params.message.startsWith("Move heartbeat-owned main session"),
);
await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock });
const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" });
const store = JSON.parse(fs.readFileSync(storePath, "utf8")) as Record<string, SessionEntry>;
const recoveredKey = Object.keys(store).find((key) =>
key.startsWith("agent:main:heartbeat-recovered-"),
);
expect(store["agent:main:main"]).toBeUndefined();
expect(recoveredKey).toBeDefined();
expect(store[recoveredKey ?? ""]?.sessionId).toBe("heartbeat-session");
const tuiStore = JSON.parse(fs.readFileSync(tuiLastSessionPath, "utf8")) as Record<
string,
{ sessionKey?: string }
>;
expect(tuiStore.default).toBeUndefined();
expect(tuiStore.telegram?.sessionKey).toBe("agent:main:telegram:thread");
expect(doctorChangesText()).toContain("Moved heartbeat-owned main session agent:main:main");
expect(doctorChangesText()).toContain("Cleared 1 stale TUI last-session pointer");
});
it("does not move a mixed main transcript that has real user activity", async () => {
const cfg: OpenClawConfig = {};
setupSessionState(cfg, process.env, tempHome);
const sessionsDir = resolveSessionTranscriptsDirForAgent("main", process.env, () => tempHome);
fs.writeFileSync(
path.join(sessionsDir, "mixed-session.jsonl"),
[
JSON.stringify({ message: { role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT } }),
JSON.stringify({ message: { role: "assistant", content: "HEARTBEAT_OK" } }),
JSON.stringify({ message: { role: "user", content: "hello from telegram" } }),
"",
].join("\n"),
);
writeSessionStore(cfg, {
"agent:main:main": {
sessionId: "mixed-session",
updatedAt: Date.now(),
},
});
const confirmRuntimeRepair = vi.fn(async () => true);
await noteStateIntegrity(cfg, { confirmRuntimeRepair, note: noteMock });
const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" });
const store = JSON.parse(fs.readFileSync(storePath, "utf8")) as Record<string, SessionEntry>;
expect(store["agent:main:main"]?.sessionId).toBe("mixed-session");
expect(Object.keys(store).some((key) => key.includes("heartbeat-recovered"))).toBe(false);
expect(confirmRuntimeRepair).not.toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("Move heartbeat-owned main session"),
}),
);
});
it("does not treat heartbeat-labeled routing metadata as heartbeat ownership", () => {
const entry: SessionEntry = {
sessionId: "session",
updatedAt: 1,
lastTo: "heartbeat",
origin: { label: "heartbeat" },
};
expect(resolveHeartbeatMainSessionRepairCandidate({ entry })).toBeNull();
});
it("keeps synthetic heartbeat ownership metadata as direct repair proof", () => {
const entry: SessionEntry = {
sessionId: "session",
updatedAt: 1,
heartbeatIsolatedBaseSessionKey: "agent:main:main",
};
expect(resolveHeartbeatMainSessionRepairCandidate({ entry })).toMatchObject({
reason: "metadata",
});
});
it("does not move synthetic heartbeat-owned sessions after recorded human interaction", () => {
const entry: SessionEntry = {
sessionId: "session",
updatedAt: 1,
heartbeatIsolatedBaseSessionKey: "agent:main:main",
lastInteractionAt: 2,
};
expect(resolveHeartbeatMainSessionRepairCandidate({ entry })).toBeNull();
});
it("does not let synthetic heartbeat metadata override mixed transcript history", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-heartbeat-main-mixed-"));
try {
const transcriptPath = path.join(tempDir, "session.jsonl");
fs.writeFileSync(
transcriptPath,
[
JSON.stringify({ message: { role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT } }),
JSON.stringify({ message: { role: "user", content: "real follow-up" } }),
"",
].join("\n"),
);
const entry: SessionEntry = {
sessionId: "session",
updatedAt: 1,
heartbeatIsolatedBaseSessionKey: "agent:main:main",
};
expect(resolveHeartbeatMainSessionRepairCandidate({ entry, transcriptPath })).toBeNull();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("does not let heartbeat-looking routing metadata skip mixed transcript checks", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-heartbeat-main-route-"));
try {
const transcriptPath = path.join(tempDir, "session.jsonl");
fs.writeFileSync(
transcriptPath,
[
JSON.stringify({ message: { role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT } }),
JSON.stringify({ message: { role: "user", content: "real follow-up" } }),
"",
].join("\n"),
);
const entry = {
sessionId: "session",
updatedAt: 1,
lastProvider: "heartbeat",
source: "heartbeat",
origin: { provider: "heartbeat" },
} as SessionEntry & Record<string, unknown>;
expect(resolveHeartbeatMainSessionRepairCandidate({ entry, transcriptPath })).toBeNull();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("does not classify transcripts with real user activity after 400 heartbeat messages", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-heartbeat-main-cap-"));
try {
const transcriptPath = path.join(tempDir, "session.jsonl");
const heartbeatMessages = Array.from({ length: 400 }, () =>
JSON.stringify({ message: { role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT } }),
);
fs.writeFileSync(
transcriptPath,
[
...heartbeatMessages,
JSON.stringify({ message: { role: "user", content: "real follow-up" } }),
"",
].join("\n"),
);
const entry: SessionEntry = { sessionId: "session", updatedAt: 1 };
expect(resolveHeartbeatMainSessionRepairCandidate({ entry, transcriptPath })).toBeNull();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("keeps the heartbeat main-session helper conservative", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-heartbeat-main-helper-"));
try {
const transcriptPath = path.join(tempDir, "session.jsonl");
fs.writeFileSync(
transcriptPath,
[
JSON.stringify({ message: { role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT } }),
JSON.stringify({ message: { role: "assistant", content: "HEARTBEAT_OK" } }),
"",
].join("\n"),
);
const entry: SessionEntry = { sessionId: "session", updatedAt: 1 };
expect(resolveHeartbeatMainSessionRepairCandidate({ entry, transcriptPath })).toMatchObject({
reason: "transcript",
});
entry.lastInteractionAt = 2;
expect(resolveHeartbeatMainSessionRepairCandidate({ entry, transcriptPath })).toBeNull();
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("moves store entries and clears matching TUI pointers without touching others", () => {
const store: Record<string, SessionEntry> = {
"agent:main:main": { sessionId: "main-session", updatedAt: 1 },
};
expect(
moveHeartbeatMainSessionEntry({
store,
mainKey: "agent:main:main",
recoveredKey: "agent:main:heartbeat-recovered-2026-05-04t00-00-00.000z",
}),
).toBe(true);
expect(store["agent:main:main"]).toBeUndefined();
expect(store["agent:main:heartbeat-recovered-2026-05-04t00-00-00.000z"]?.sessionId).toBe(
"main-session",
);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-tui-pointer-clear-"));
try {
const filePath = path.join(tempDir, "last-session.json");
fs.writeFileSync(
filePath,
JSON.stringify({
terminal: { sessionKey: "agent:main:main" },
telegram: { sessionKey: "agent:main:telegram:thread" },
}),
);
expect(
clearTuiLastSessionPointers({
filePath,
sessionKeys: new Set(["agent:main:main"]),
}),
).toBe(1);
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Record<
string,
{ sessionKey?: string }
>;
expect(parsed.terminal).toBeUndefined();
expect(parsed.telegram?.sessionKey).toBe("agent:main:telegram:thread");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("ignores slash-routing sessions for recent missing transcript warnings", async () => {
const cfg: OpenClawConfig = {};
writeSessionStore(cfg, {

View File

@@ -32,6 +32,7 @@ import { asNullableObjectRecord } from "../shared/record-coerce.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import { repairHeartbeatPoisonedMainSession } from "./doctor-heartbeat-main-session-repair.js";
import { runPluginSessionStateDoctorRepairs } from "./doctor-session-state-providers.js";
type DoctorPrompterLike = {
@@ -931,6 +932,17 @@ export async function noteStateIntegrity(
changes,
});
await repairHeartbeatPoisonedMainSession({
cfg,
store,
absoluteStorePath,
stateDir,
sessionPathOpts,
prompter,
warnings,
changes,
});
const mainKey = resolveMainSessionKey(cfg);
const mainEntry = store[mainKey];
if (mainEntry?.sessionId) {

View File

@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
buildCliRespawnPlan,
EXPERIMENTAL_WARNING_FLAG,
OPENCLAW_NODE_EXTRA_CA_CERTS_READY,
OPENCLAW_NODE_OPTIONS_READY,
resolveCliRespawnCommand,
runCliRespawnPlan,
} from "./entry.respawn.js";
describe("buildCliRespawnPlan", () => {
@@ -35,6 +38,35 @@ describe("buildCliRespawnPlan", () => {
expect(plan?.env[OPENCLAW_NODE_OPTIONS_READY]).toBe("1");
});
it.each(["tui", "terminal", "chat"] as const)(
"preserves NODE_EXTRA_CA_CERTS respawn for interactive %s",
(command) => {
const plan = buildCliRespawnPlan({
argv: ["node", "openclaw", command],
env: {},
execArgv: [],
autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt",
});
expect(plan).not.toBeNull();
expect(plan?.argv).toEqual(["openclaw", command]);
expect(plan?.env.NODE_EXTRA_CA_CERTS).toBe("/etc/ssl/certs/ca-certificates.crt");
expect(plan?.env[OPENCLAW_NODE_EXTRA_CA_CERTS_READY]).toBe("1");
expect(plan?.env[OPENCLAW_NODE_OPTIONS_READY]).toBeUndefined();
},
);
it("does not respawn interactive commands for warning suppression only", () => {
expect(
buildCliRespawnPlan({
argv: ["node", "openclaw", "tui"],
env: {},
execArgv: [],
autoNodeExtraCaCerts: undefined,
}),
).toBeNull();
});
it("does not overwrite an existing NODE_EXTRA_CA_CERTS value", () => {
const plan = buildCliRespawnPlan({
argv: ["node", "openclaw", "status"],
@@ -107,3 +139,86 @@ describe("resolveCliRespawnCommand", () => {
).toBe("node");
});
});
describe("runCliRespawnPlan", () => {
it("spawns and bridges the respawn child", () => {
const child = new EventEmitter() as ChildProcess;
const spawn = vi.fn(() => child);
const attachChildProcessBridge = vi.fn();
const exit = vi.fn();
const writeError = vi.fn();
runCliRespawnPlan(
{
command: "/usr/bin/node",
argv: ["/repo/openclaw/dist/entry.js", "status"],
env: { OPENCLAW_NODE_OPTIONS_READY: "1" },
},
{
spawn: spawn as unknown as typeof import("node:child_process").spawn,
attachChildProcessBridge,
exit: exit as unknown as (code?: number) => never,
writeError,
},
);
expect(spawn).toHaveBeenCalledWith(
"/usr/bin/node",
["/repo/openclaw/dist/entry.js", "status"],
{
stdio: "inherit",
env: { OPENCLAW_NODE_OPTIONS_READY: "1" },
},
);
expect(attachChildProcessBridge).toHaveBeenCalledWith(child, {
onSignal: expect.any(Function),
});
child.emit("exit", 0, null);
expect(exit).toHaveBeenCalledWith(0);
expect(writeError).not.toHaveBeenCalled();
});
it("force-kills a signaled respawn child that does not exit", () => {
vi.useFakeTimers();
const child = new EventEmitter() as ChildProcess;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true);
child.kill = kill as ChildProcess["kill"];
const spawn = vi.fn(() => child);
const exit = vi.fn();
let onSignal: ((signal: NodeJS.Signals) => void) | undefined;
try {
runCliRespawnPlan(
{
command: "/usr/bin/node",
argv: ["/repo/openclaw/dist/entry.js", "tui"],
env: {},
},
{
spawn: spawn as unknown as typeof import("node:child_process").spawn,
attachChildProcessBridge: vi.fn((_child, options) => {
onSignal = options?.onSignal;
return { detach: vi.fn() };
}),
exit: exit as unknown as (code?: number) => never,
writeError: vi.fn(),
},
);
onSignal?.("SIGTERM");
vi.advanceTimersByTime(1_000);
expect(kill).toHaveBeenCalledWith("SIGTERM");
expect(exit).not.toHaveBeenCalled();
vi.advanceTimersByTime(1_000);
expect(kill).toHaveBeenCalledWith(process.platform === "win32" ? "SIGTERM" : "SIGKILL");
expect(exit).toHaveBeenCalledWith(1);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -1,11 +1,18 @@
import { spawn, type ChildProcess } from "node:child_process";
import path from "node:path";
import { resolveNodeStartupTlsEnvironment } from "./bootstrap/node-startup-env.js";
import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js";
import {
shouldSkipRespawnForArgv,
shouldSkipStartupEnvironmentRespawnForArgv,
} from "./cli/respawn-policy.js";
import { isTruthyEnvValue } from "./infra/env.js";
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
export const EXPERIMENTAL_WARNING_FLAG = "--disable-warning=ExperimentalWarning";
export const OPENCLAW_NODE_OPTIONS_READY = "OPENCLAW_NODE_OPTIONS_READY";
export const OPENCLAW_NODE_EXTRA_CA_CERTS_READY = "OPENCLAW_NODE_EXTRA_CA_CERTS_READY";
const CLI_RESPAWN_SIGNAL_EXIT_GRACE_MS = 1_000;
const CLI_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS = 1_000;
type CliRespawnPlan = {
command: string;
@@ -13,6 +20,13 @@ type CliRespawnPlan = {
env: NodeJS.ProcessEnv;
};
type CliRespawnRuntime = {
spawn: typeof spawn;
attachChildProcessBridge: typeof attachChildProcessBridge;
exit: (code?: number) => never;
writeError: (message: string, error?: unknown) => void;
};
function pathModuleForPlatform(platform: NodeJS.Platform): typeof path.posix {
return platform === "win32" ? path.win32 : path.posix;
}
@@ -60,7 +74,10 @@ export function buildCliRespawnPlan(
const execPath = params.execPath ?? process.execPath;
const platform = params.platform ?? process.platform;
if (shouldSkipRespawnForArgv(argv) || isTruthyEnvValue(env.OPENCLAW_NO_RESPAWN)) {
if (
shouldSkipStartupEnvironmentRespawnForArgv(argv) ||
isTruthyEnvValue(env.OPENCLAW_NO_RESPAWN)
) {
return null;
}
@@ -90,6 +107,7 @@ export function buildCliRespawnPlan(
}
if (
!shouldSkipRespawnForArgv(argv) &&
!isTruthyEnvValue(env[OPENCLAW_NODE_OPTIONS_READY]) &&
!hasExperimentalWarningSuppressed({ env, execArgv })
) {
@@ -108,3 +126,82 @@ export function buildCliRespawnPlan(
env: childEnv,
};
}
export function runCliRespawnPlan(
plan: CliRespawnPlan,
runtime: CliRespawnRuntime = {
spawn,
attachChildProcessBridge,
exit: process.exit.bind(process) as (code?: number) => never,
writeError: (message, error) => console.error(message, error),
},
): ChildProcess {
const child = runtime.spawn(plan.command, plan.argv, {
stdio: "inherit",
env: plan.env,
});
let signalExitTimer: NodeJS.Timeout | undefined;
let signalForceKillTimer: NodeJS.Timeout | undefined;
const clearSignalTimers = (): void => {
if (signalExitTimer) {
clearTimeout(signalExitTimer);
signalExitTimer = undefined;
}
if (signalForceKillTimer) {
clearTimeout(signalForceKillTimer);
signalForceKillTimer = undefined;
}
};
const forceKillChild = (): void => {
try {
child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL");
} catch {
// Best-effort shutdown fallback.
}
};
const requestChildTermination = (): void => {
try {
child.kill("SIGTERM");
} catch {
// Best-effort shutdown fallback.
}
signalForceKillTimer = setTimeout(() => {
forceKillChild();
runtime.exit(1);
}, CLI_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS);
signalForceKillTimer.unref?.();
};
const scheduleParentExit = (): void => {
if (signalExitTimer) {
return;
}
signalExitTimer = setTimeout(() => {
requestChildTermination();
}, CLI_RESPAWN_SIGNAL_EXIT_GRACE_MS);
signalExitTimer.unref?.();
};
runtime.attachChildProcessBridge(child, {
onSignal: scheduleParentExit,
});
child.once("exit", (code, signal) => {
clearSignalTimers();
if (signal) {
runtime.exit(1);
return;
}
runtime.exit(code ?? 1);
});
child.once("error", (error) => {
clearSignalTimers();
runtime.writeError(
"[openclaw] Failed to respawn CLI:",
error instanceof Error ? (error.stack ?? error.message) : error,
);
runtime.exit(1);
});
return child;
}

View File

@@ -1,5 +1,4 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { isRootHelpInvocation } from "./cli/argv.js";
@@ -11,13 +10,12 @@ import {
resolveEntryInstallRoot,
respawnWithoutOpenClawCompileCacheIfNeeded,
} from "./entry.compile-cache.js";
import { buildCliRespawnPlan } from "./entry.respawn.js";
import { buildCliRespawnPlan, runCliRespawnPlan } from "./entry.respawn.js";
import { tryHandleRootVersionFastPath } from "./entry.version-fast-path.js";
import { isTruthyEnvValue, normalizeEnv } from "./infra/env.js";
import { isMainModule } from "./infra/is-main.js";
import { ensureOpenClawExecMarkerOnProcess } from "./infra/openclaw-exec-env.js";
import { installProcessWarningFilter } from "./infra/warning-filter.js";
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
const ENTRY_WRAPPER_PAIRS = [
{ wrapperBasename: "openclaw.mjs", entryBasename: "entry.js" },
@@ -113,29 +111,7 @@ if (
return false;
}
const child = spawn(plan.command, plan.argv, {
stdio: "inherit",
env: plan.env,
});
attachChildProcessBridge(child);
child.once("exit", (code, signal) => {
if (signal) {
process.exitCode = 1;
return;
}
process.exit(code ?? 1);
});
child.once("error", (error) => {
console.error(
"[openclaw] Failed to respawn CLI:",
error instanceof Error ? (error.stack ?? error.message) : error,
);
process.exit(1);
});
runCliRespawnPlan(plan);
// Parent must not continue running the CLI.
return true;
}

View File

@@ -62,6 +62,12 @@ export type TuiSessionList = {
space?: string;
subject?: string;
chatType?: string;
origin?: {
label?: string;
provider?: string;
surface?: string;
};
lastChannel?: string;
lastProvider?: string;
lastTo?: string;
lastAccountId?: string;

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildTuiLastSessionScopeKey,
isHeartbeatLikeTuiSession,
readTuiLastSessionKey,
resolveRememberedTuiSessionKey,
resolveTuiLastSessionStatePath,
@@ -71,4 +72,47 @@ describe("tui last session state", () => {
}),
).toBeNull();
});
it("does not persist or restore heartbeat sessions", async () => {
const stateDir = await makeTempStateDir();
const scopeKey = buildTuiLastSessionScopeKey({
connectionUrl: "ws://127.0.0.1:18789",
agentId: "main",
sessionScope: "per-sender",
});
await writeTuiLastSessionKey({
scopeKey,
sessionKey: "agent:main:telegram:direct:123:heartbeat",
stateDir,
});
await expect(readTuiLastSessionKey({ scopeKey, stateDir })).resolves.toBeNull();
expect(
resolveRememberedTuiSessionKey({
rememberedKey: "agent:main:telegram:direct:123:heartbeat",
currentAgentId: "main",
sessions: [{ key: "agent:main:telegram:direct:123:heartbeat" }],
}),
).toBeNull();
});
it("does not restore heartbeat-origin sessions when resolving a remembered key", () => {
const sessions = [
{
key: "agent:main:main",
origin: { provider: "heartbeat", surface: "heartbeat" },
},
{ key: "agent:main:tui-123" },
];
expect(isHeartbeatLikeTuiSession(sessions[0])).toBe(true);
expect(
resolveRememberedTuiSessionKey({
rememberedKey: "agent:main:main",
currentAgentId: "main",
sessions,
}),
).toBeNull();
});
});

View File

@@ -42,6 +42,30 @@ async function readStore(filePath: string): Promise<LastSessionStore> {
}
}
function normalizeMarker(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function isHeartbeatSessionKey(sessionKey: string): boolean {
return normalizeMarker(sessionKey).endsWith(":heartbeat");
}
export function isHeartbeatLikeTuiSession(session: TuiSessionList["sessions"][number]): boolean {
if (isHeartbeatSessionKey(session.key)) {
return true;
}
const markers = [
session.provider,
session.lastProvider,
session.lastChannel,
session.lastTo,
session.origin?.provider,
session.origin?.surface,
session.origin?.label,
];
return markers.some((marker) => normalizeMarker(marker) === "heartbeat");
}
export async function readTuiLastSessionKey(params: {
scopeKey: string;
stateDir?: string;
@@ -57,7 +81,7 @@ export async function writeTuiLastSessionKey(params: {
stateDir?: string;
}): Promise<void> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey || sessionKey === "unknown") {
if (!sessionKey || sessionKey === "unknown" || isHeartbeatSessionKey(sessionKey)) {
return;
}
const filePath = resolveTuiLastSessionStatePath(params.stateDir);
@@ -82,6 +106,9 @@ export function resolveRememberedTuiSessionKey(params: {
if (!rememberedKey) {
return null;
}
if (isHeartbeatSessionKey(rememberedKey)) {
return null;
}
const currentAgentId = normalizeAgentId(params.currentAgentId);
const parsed = parseAgentSessionKey(rememberedKey);
if (parsed && normalizeAgentId(parsed.agentId) !== currentAgentId) {
@@ -89,6 +116,9 @@ export function resolveRememberedTuiSessionKey(params: {
}
const rememberedRest = parsed?.rest ?? rememberedKey;
const match = params.sessions.find((session) => {
if (isHeartbeatLikeTuiSession(session)) {
return false;
}
if (session.key === rememberedKey) {
return true;
}

View File

@@ -1,11 +1,15 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js";
import { getSlashCommands, parseCommand } from "./commands.js";
import {
createBackspaceDeduper,
createDeferredTuiFinish,
drainAndStopTuiSafely,
installTuiTerminalLossExitHandler,
isIgnorableTuiStopError,
isTuiTerminalLossError,
resolveCodexCliBin,
resolveCtrlCAction,
resolveFinalAssistantText,
@@ -337,6 +341,44 @@ describe("TUI shutdown safety", () => {
});
}).toThrow("boom");
});
it("classifies terminal-loss IO errors", () => {
expect(isTuiTerminalLossError({ code: "EIO", syscall: "read" })).toBe(true);
expect(isTuiTerminalLossError({ code: "EPIPE", syscall: "write" })).toBe(true);
expect(isTuiTerminalLossError(new Error("read EIO at TTY.onStreamRead"))).toBe(true);
expect(isTuiTerminalLossError(new Error("ordinary failure"))).toBe(false);
});
it("requests exit once when the TUI terminal closes", () => {
const stdin = new EventEmitter() as EventEmitter & {
on(event: "close" | "end", listener: () => void): unknown;
off(event: "close" | "end", listener: () => void): unknown;
};
const stdout = new EventEmitter() as EventEmitter & {
on(event: "close" | "end", listener: () => void): unknown;
off(event: "close" | "end", listener: () => void): unknown;
};
const requestExit = vi.fn();
const cleanup = installTuiTerminalLossExitHandler(requestExit, { stdin, stdout });
stdin.emit("end");
stdout.emit("close");
cleanup();
stdin.emit("close");
expect(requestExit).toHaveBeenCalledTimes(1);
});
it("resolves terminal-loss exits requested before the TUI finish handler is installed", () => {
const deferredFinish = createDeferredTuiFinish();
const finish = vi.fn();
deferredFinish.requestFinish();
expect(finish).not.toHaveBeenCalled();
deferredFinish.setFinish(finish);
expect(finish).toHaveBeenCalledTimes(1);
});
});
describe("resolveCodexCliBin", () => {

View File

@@ -14,6 +14,7 @@ import {
} from "@mariozechner/pi-tui";
import { resolveAgentIdByWorkspacePath, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js";
import { registerUncaughtExceptionHandler } from "../infra/unhandled-rejections.js";
import { setConsoleSubsystemFilter } from "../logging/console.js";
import { loggingState } from "../logging/state.js";
import {
@@ -252,6 +253,89 @@ export function stopTuiSafely(stop: () => void): void {
}
}
type TerminalLossEmitter = {
on(event: "close" | "end", listener: () => void): unknown;
off(event: "close" | "end", listener: () => void): unknown;
};
export function isTuiTerminalLossError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const err = error as { code?: unknown; message?: unknown; syscall?: unknown };
const code = typeof err.code === "string" ? err.code : "";
const message = typeof err.message === "string" ? err.message : "";
const syscall = typeof err.syscall === "string" ? err.syscall : "";
if (code === "EIO" || code === "EPIPE") {
return true;
}
return (
/\b(EIO|EPIPE)\b/i.test(message) && /\b(read|write|TTY|stdin|stdout)\b/i.test(message + syscall)
);
}
export function installTuiTerminalLossExitHandler(
requestExit: () => void,
targets: { stdin?: TerminalLossEmitter; stdout?: TerminalLossEmitter } = {
stdin: process.stdin,
stdout: process.stdout,
},
): () => void {
let requested = false;
const requestOnce = (): void => {
if (requested) {
return;
}
requested = true;
requestExit();
};
const removeUncaughtExceptionHandler = registerUncaughtExceptionHandler((error) => {
if (!isTuiTerminalLossError(error)) {
return false;
}
requestOnce();
return true;
});
const onClose = (): void => requestOnce();
targets.stdin?.on("end", onClose);
targets.stdin?.on("close", onClose);
targets.stdout?.on("close", onClose);
return () => {
removeUncaughtExceptionHandler();
targets.stdin?.off("end", onClose);
targets.stdin?.off("close", onClose);
targets.stdout?.off("close", onClose);
};
}
export function createDeferredTuiFinish(): {
requestFinish: () => void;
setFinish: (finish: () => void) => void;
clearFinish: () => void;
} {
let finishTui: (() => void) | null = null;
let finishRequested = false;
return {
requestFinish: () => {
const finish = finishTui;
if (finish) {
finish();
return;
}
finishRequested = true;
},
setFinish: (finish) => {
finishTui = finish;
if (finishRequested) {
finish();
}
},
clearFinish: () => {
finishTui = null;
},
};
}
type DrainableTui = {
stop: () => void;
terminal?: {
@@ -1001,7 +1085,7 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
clearLocalBtwRunIds,
});
let finishTui: (() => void) | null = null;
const deferredFinish = createDeferredTuiFinish();
const requestExit = (result?: Partial<TuiResult>) => {
if (exitRequested) {
return;
@@ -1012,9 +1096,19 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
...(result?.crestodianMessage ? { crestodianMessage: result.crestodianMessage } : {}),
};
client.stop();
void drainAndStopTuiSafely(tui).then(() => {
finishTui?.();
});
void drainAndStopTuiSafely(tui)
.catch((err) => {
if (!isTuiTerminalLossError(err)) {
try {
process.stderr.write(`openclaw tui shutdown failed: ${String(err)}\n`);
} catch {
// Best effort only; exit must still complete.
}
}
})
.finally(() => {
deferredFinish.requestFinish();
});
};
const exitAwareClient = client as TuiBackend & {
setRequestExitHandler?: (handler: () => void) => void;
@@ -1172,7 +1266,11 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
}
updateFooter();
tui.requestRender();
})();
})().catch((err) => {
chatLog.addSystem(`startup failed: ${String(err)}`);
setConnectionStatus("startup failed", 5000);
tui.requestRender();
});
};
client.onDisconnected = (reason) => {
@@ -1213,6 +1311,9 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
};
process.on("SIGINT", sigintHandler);
process.on("SIGTERM", sigtermHandler);
let cleanupTerminalLossHandler: (() => void) | null = installTuiTerminalLossExitHandler(() =>
requestExit(),
);
tui.start();
client.start();
await new Promise<void>((resolve) => {
@@ -1220,14 +1321,16 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
if (isLocalMode) {
setConsoleSubsystemFilter(previousConsoleSubsystemFilter);
}
cleanupTerminalLossHandler?.();
cleanupTerminalLossHandler = null;
process.removeListener("SIGINT", sigintHandler);
process.removeListener("SIGTERM", sigtermHandler);
process.removeListener("exit", finish);
finishTui = null;
deferredFinish.clearFinish();
resolve();
};
finishTui = finish;
process.once("exit", finish);
deferredFinish.setFinish(finish);
});
return exitResult;
}