diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index fec8acbb654..018eb19f70c 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -380,8 +380,8 @@ The remaining cleanup is mostly consolidation and deletion: - Gateway singleton locks now use shared SQLite KV instead of temp-dir lock files. Done. - Gateway restart sentinel state now uses shared SQLite KV instead of - `restart-sentinel.json`; the old path resolver remains only for legacy - cleanup/status compatibility. + `restart-sentinel.json`; runtime code clears the SQLite row directly and no + longer carries file cleanup plumbing. - Gateway restart intent and supervisor handoff state now use shared SQLite KV instead of `gateway-restart-intent.json` and `gateway-supervisor-restart-handoff.json` sidecars. diff --git a/src/agents/tools/gateway-tool.test.ts b/src/agents/tools/gateway-tool.test.ts index 2868385798f..d4cb2fa3bbf 100644 --- a/src/agents/tools/gateway-tool.test.ts +++ b/src/agents/tools/gateway-tool.test.ts @@ -6,10 +6,10 @@ import { createGatewayTool } from "./gateway-tool.js"; type ScheduleGatewayRestartArgs = Parameters[0]; const { + clearRestartSentinelMock, extractDeliveryInfoMock, formatDoctorNonInteractiveHintMock, isRestartEnabledMock, - removeRestartSentinelFileMock, scheduleGatewaySigusr1RestartMock, writeRestartSentinelMock, } = vi.hoisted(() => ({ @@ -23,8 +23,8 @@ const { threadId: "thread-42", })), formatDoctorNonInteractiveHintMock: vi.fn(() => "Run: openclaw doctor --non-interactive"), - writeRestartSentinelMock: vi.fn(async (_payload: RestartSentinelPayload) => "/tmp/restart"), - removeRestartSentinelFileMock: vi.fn(async (_path: string | null | undefined) => undefined), + writeRestartSentinelMock: vi.fn(async (_payload: RestartSentinelPayload) => undefined), + clearRestartSentinelMock: vi.fn(async () => undefined), scheduleGatewaySigusr1RestartMock: vi.fn((_opts?: ScheduleGatewayRestartArgs) => ({ scheduled: true, delayMs: 250, @@ -46,7 +46,7 @@ vi.mock("../../infra/restart-sentinel.js", async () => { return { ...actual, formatDoctorNonInteractiveHint: formatDoctorNonInteractiveHintMock, - removeRestartSentinelFile: removeRestartSentinelFileMock, + clearRestartSentinel: clearRestartSentinelMock, writeRestartSentinel: writeRestartSentinelMock, }; }); @@ -98,8 +98,8 @@ describe("gateway tool restart continuation", () => { formatDoctorNonInteractiveHintMock.mockReset(); formatDoctorNonInteractiveHintMock.mockReturnValue("Run: openclaw doctor --non-interactive"); writeRestartSentinelMock.mockReset(); - writeRestartSentinelMock.mockResolvedValue("/tmp/restart"); - removeRestartSentinelFileMock.mockClear(); + writeRestartSentinelMock.mockResolvedValue(undefined); + clearRestartSentinelMock.mockClear(); scheduleGatewaySigusr1RestartMock.mockReset(); scheduleGatewaySigusr1RestartMock.mockReturnValue({ scheduled: true, delayMs: 250 }); }); @@ -224,6 +224,6 @@ describe("gateway tool restart continuation", () => { await scheduledArgs?.emitHooks?.beforeEmit?.(); await scheduledArgs?.emitHooks?.afterEmitRejected?.(); - expect(removeRestartSentinelFileMock).toHaveBeenCalledWith("/tmp/restart"); + expect(clearRestartSentinelMock).toHaveBeenCalledOnce(); }); }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index fb2c1d6e8b6..2b68967188d 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -7,8 +7,8 @@ import { extractDeliveryInfo } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { buildRestartSuccessContinuation, + clearRestartSentinel, formatDoctorNonInteractiveHint, - removeRestartSentinelFile, type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; @@ -414,16 +414,15 @@ export function createGatewayTool(opts?: { log.info( `gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`, ); - let sentinelPath: string | null = null; const scheduled = scheduleGatewaySigusr1Restart({ delayMs, reason, emitHooks: { beforeEmit: async () => { - sentinelPath = await writeRestartSentinel(payload); + await writeRestartSentinel(payload); }, afterEmitRejected: async () => { - await removeRestartSentinelFile(sentinelPath); + await clearRestartSentinel(); }, }, }); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 403e86577bc..1cd03e880fb 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -14,8 +14,8 @@ import { getSessionBindingService } from "../../infra/outbound/session-binding-s import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import { buildRestartSuccessContinuation, + clearRestartSentinel, formatDoctorNonInteractiveHint, - removeRestartSentinelFile, type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; @@ -695,16 +695,15 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm const hasSigusr1Listener = process.listenerCount("SIGUSR1") > 0; const sentinelPayload = buildRestartCommandSentinel(params); if (hasSigusr1Listener) { - let sentinelPath: string | null = null; scheduleGatewaySigusr1Restart({ reason: "/restart", emitHooks: sentinelPayload ? { beforeEmit: async () => { - sentinelPath = await writeRestartSentinel(sentinelPayload); + await writeRestartSentinel(sentinelPayload); }, afterEmitRejected: async () => { - await removeRestartSentinelFile(sentinelPath); + await clearRestartSentinel(); }, } : undefined, @@ -716,10 +715,9 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm }, }; } - let sentinelPath: string | null = null; try { if (sentinelPayload) { - sentinelPath = await writeRestartSentinel(sentinelPayload); + await writeRestartSentinel(sentinelPayload); } } catch (err) { logVerbose(`failed to write /restart sentinel: ${String(err)}`); @@ -732,7 +730,7 @@ export const handleRestartCommand: CommandHandler = async (params, allowTextComm } const restartMethod = triggerOpenClawRestart(); if (!restartMethod.ok) { - await removeRestartSentinelFile(sentinelPath); + await clearRestartSentinel(); const detail = restartMethod.detail ? ` Details: ${restartMethod.detail}` : ""; return { shouldContinue: false, diff --git a/src/gateway/server-methods/config.shared-auth.test.ts b/src/gateway/server-methods/config.shared-auth.test.ts index 78f5e6437a5..39855343ffb 100644 --- a/src/gateway/server-methods/config.shared-auth.test.ts +++ b/src/gateway/server-methods/config.shared-auth.test.ts @@ -17,9 +17,7 @@ const scheduleGatewaySigusr1RestartMock = vi.fn(() => ({ coalesced: false, })); const restartSentinelMocks = vi.hoisted(() => ({ - writeRestartSentinel: vi.fn(async (_payload: RestartSentinelPayload) => { - return "/tmp/restart-sentinel.json"; - }), + writeRestartSentinel: vi.fn(async (_payload: RestartSentinelPayload) => undefined), })); vi.mock("../../config/config.js", async () => { diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index 6265aa364c7..d6e8df1a6c7 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -29,8 +29,7 @@ const mocks = vi.hoisted(() => { }, }, })), - removeRestartSentinelFile: vi.fn(async () => undefined), - resolveRestartSentinelPath: vi.fn(() => "/tmp/restart-sentinel.json"), + clearRestartSentinel: vi.fn(async () => undefined), formatRestartSentinelMessage: vi.fn(() => "restart message"), summarizeRestartSentinel: vi.fn(() => "restart summary"), resolveMainSessionKeyFromConfig: vi.fn(() => "agent:main:main"), @@ -169,8 +168,7 @@ vi.mock("../agents/agent-scope.js", async () => { vi.mock("../infra/restart-sentinel.js", () => ({ readRestartSentinel: mocks.readRestartSentinel, - removeRestartSentinelFile: mocks.removeRestartSentinelFile, - resolveRestartSentinelPath: mocks.resolveRestartSentinelPath, + clearRestartSentinel: mocks.clearRestartSentinel, formatRestartSentinelMessage: mocks.formatRestartSentinelMessage, summarizeRestartSentinel: mocks.summarizeRestartSentinel, })); @@ -344,7 +342,7 @@ describe("scheduleRestartSentinelWake", () => { mocks.loadPendingSessionDelivery.mockClear(); mocks.drainPendingSessionDeliveries.mockClear(); mocks.recoverPendingSessionDeliveries.mockClear(); - mocks.removeRestartSentinelFile.mockClear(); + mocks.clearRestartSentinel.mockClear(); mocks.injectTimestamp.mockClear(); mocks.timestampOptsFromConfig.mockClear(); mocks.recordInboundSessionAndDispatchReply.mockReset(); @@ -1131,7 +1129,7 @@ describe("scheduleRestartSentinelWake", () => { await scheduleRestartSentinelWake({ deps: {} as never }); - expect(mocks.removeRestartSentinelFile).not.toHaveBeenCalled(); + expect(mocks.clearRestartSentinel).not.toHaveBeenCalled(); expect(mocks.drainPendingSessionDeliveries).not.toHaveBeenCalled(); expect(mocks.logWarn).toHaveBeenCalledWith( "startup task failed", diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index abc91fba94f..e344d74ece7 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -15,13 +15,12 @@ import { ackDelivery, enqueueDelivery, failDelivery } from "../infra/outbound/de import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { + clearRestartSentinel, finalizeUpdateRestartSentinelRunningVersion, formatRestartSentinelMessage, readRestartSentinel, - removeRestartSentinelFile, type RestartSentinelContinuation, type RestartSentinelPayload, - resolveRestartSentinelPath, summarizeRestartSentinel, } from "../infra/restart-sentinel.js"; import { @@ -476,7 +475,6 @@ async function loadRestartSentinelStartupTask(params: { if (!sentinel) { return null; } - const sentinelPath = resolveRestartSentinelPath(); const payload = sentinel.payload; const sessionKey = payload.sessionKey?.trim(); const message = formatRestartSentinelMessage(payload); @@ -498,7 +496,7 @@ async function loadRestartSentinelStartupTask(params: { continuationKind: payload.continuation.kind, }); } - await removeRestartSentinelFile(sentinelPath); + await clearRestartSentinel(); return { status: "ran" as const }; } @@ -591,7 +589,7 @@ async function loadRestartSentinelStartupTask(params: { ); } - await removeRestartSentinelFile(sentinelPath); + await clearRestartSentinel(); const routedAgentTurnContinuation = payload.continuation?.kind === "agentTurn" && continuationRoute !== undefined; if (!routedAgentTurnContinuation) { diff --git a/src/infra/restart-intent.test.ts b/src/infra/restart-intent.test.ts index 622a1f04385..9617b96bd7f 100644 --- a/src/infra/restart-intent.test.ts +++ b/src/infra/restart-intent.test.ts @@ -50,14 +50,6 @@ describe("gateway restart intent", () => { expect(fs.existsSync(intentPath(env))).toBe(false); }); - it("rejects oversized intent files before parsing", () => { - const env = createIntentEnv(); - fs.writeFileSync(intentPath(env), "x".repeat(2048), { encoding: "utf8", mode: 0o600 }); - - expect(consumeGatewayRestartIntentSync(env)).toBe(false); - expect(fs.existsSync(intentPath(env))).toBe(true); - }); - it("stores intents in SQLite instead of a legacy JSON file", () => { const env = createIntentEnv(); @@ -84,21 +76,4 @@ describe("gateway restart intent", () => { }); expect(fs.existsSync(intentPath(env))).toBe(false); }); - - it("does not touch an existing legacy intent-path symlink when writing", () => { - const env = createIntentEnv(); - const targetPath = path.join(env.OPENCLAW_STATE_DIR ?? "", "attacker-target.txt"); - fs.writeFileSync(targetPath, "keep", "utf8"); - try { - fs.symlinkSync(targetPath, intentPath(env)); - } catch { - return; - } - - expect(writeGatewayRestartIntentSync({ env, targetPid: process.pid })).toBe(true); - - expect(fs.readFileSync(targetPath, "utf8")).toBe("keep"); - expect(fs.lstatSync(intentPath(env)).isSymbolicLink()).toBe(true); - expect(consumeGatewayRestartIntentSync(env)).toBe(true); - }); }); diff --git a/src/infra/restart-sentinel.test.ts b/src/infra/restart-sentinel.test.ts index 1e8bf42e659..315280abc54 100644 --- a/src/infra/restart-sentinel.test.ts +++ b/src/infra/restart-sentinel.test.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import { describe, expect, it } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { @@ -17,7 +16,6 @@ import { formatRestartSentinelMessage, markUpdateRestartSentinelFailure, readRestartSentinel, - resolveRestartSentinelPath, summarizeRestartSentinel, trimLogTail, writeRestartSentinel, @@ -53,9 +51,7 @@ describe("restart sentinel", () => { }, stats: { mode: "git" }, }; - const filePath = await writeRestartSentinel(payload); - expect(filePath).toBe(resolveRestartSentinelPath()); - await expect(fs.stat(filePath)).rejects.toThrow(); + await writeRestartSentinel(payload); const read = await readRestartSentinel(); expect(read?.payload.kind).toBe("update"); @@ -70,17 +66,6 @@ describe("restart sentinel", () => { }); }); - it("ignores legacy sentinel files at runtime", async () => { - await withRestartSentinelStateDir(async () => { - const filePath = resolveRestartSentinelPath(); - await fs.writeFile(filePath, "not-json", "utf-8"); - - const read = await readRestartSentinel(); - expect(read).toBeNull(); - await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("not-json"); - }); - }); - it("drops structurally invalid SQLite sentinel payloads", async () => { await withRestartSentinelStateDir(async () => { writeOpenClawStateKvJson( diff --git a/src/infra/restart-sentinel.ts b/src/infra/restart-sentinel.ts index dc5a9314b49..fe105053ce1 100644 --- a/src/infra/restart-sentinel.ts +++ b/src/infra/restart-sentinel.ts @@ -1,7 +1,4 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; -import { resolveStateDir } from "../config/paths.js"; import { deleteOpenClawStateKvJson, readOpenClawStateKvJson, @@ -71,7 +68,6 @@ export type RestartSentinel = { export const DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE = "The gateway restart completed successfully. Tell the user OpenClaw restarted successfully and continue any pending work."; -const SENTINEL_FILENAME = "restart-sentinel.json"; const RESTART_SENTINEL_KV_SCOPE = "gateway.restart-sentinel"; const RESTART_SENTINEL_KV_KEY = "current"; @@ -81,10 +77,6 @@ export function formatDoctorNonInteractiveHint( return `Run: ${formatCliCommand("openclaw doctor --non-interactive", env)}`; } -export function resolveRestartSentinelPath(env: NodeJS.ProcessEnv = process.env): string { - return path.join(resolveStateDir(env), SENTINEL_FILENAME); -} - export async function writeRestartSentinel( payload: RestartSentinelPayload, env: NodeJS.ProcessEnv = process.env, @@ -96,7 +88,6 @@ export async function writeRestartSentinel( data as unknown as OpenClawStateJsonValue, { env }, ); - return resolveRestartSentinelPath(env); } function isPlainRecord(value: unknown): value is Record { @@ -163,15 +154,8 @@ export async function markUpdateRestartSentinelFailure( }, env); } -export async function removeRestartSentinelFile( - filePath: string | null | undefined, - env: NodeJS.ProcessEnv = process.env, -) { +export async function clearRestartSentinel(env: NodeJS.ProcessEnv = process.env) { deleteOpenClawStateKvJson(RESTART_SENTINEL_KV_SCOPE, RESTART_SENTINEL_KV_KEY, { env }); - if (!filePath) { - return; - } - await fs.unlink(filePath).catch(() => {}); } export function buildRestartSuccessContinuation(params: { @@ -211,12 +195,11 @@ export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env): export async function consumeRestartSentinel( env: NodeJS.ProcessEnv = process.env, ): Promise { - const filePath = resolveRestartSentinelPath(env); const parsed = await readRestartSentinel(env); if (!parsed) { return null; } - await removeRestartSentinelFile(filePath, env); + await clearRestartSentinel(env); return parsed; }