Files
gori-agent/src/core/durable-session-store.ts
T
zenord bd96595ba8 feat: reset idle assistant sessions after one hour
When a conversation's assistant session has been idle longer than
runtime.acp.assistantSessionResetIdleMs (default 1h, 0 disables) and
its owner has no unfinished proposals, the next inbound message starts
a fresh session instead of resuming. Binding lastActiveAt is persisted
per turn so the decision survives restarts.
2026-08-19 16:17:54 +08:00

209 lines
7.8 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
export interface StoreIdentity {
botId: string;
platform: string;
}
export interface AssistantBinding {
chatKey: string;
agentId: string;
nativeSessionId: string;
assistantWorkspace: string;
botFingerprint: string;
createdAt: number;
updatedAt: number;
// Refreshed on every assistant turn; drives the idle reset of long-inactive sessions.
lastActiveAt?: number;
}
interface StoreData {
version: 3;
botId: string;
platform: string;
bindings: Record<string, AssistantBinding>;
}
export class DurableSessionStore {
private data: StoreData;
private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle;
private closed = false;
constructor(readonly file: string, readonly identity: StoreIdentity) {
this.data = emptyData(identity);
}
async open(): Promise<void> {
const directory = path.dirname(this.file);
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
const directoryStat = await fs.promises.lstat(directory);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing unsafe state directory: ${directory}`);
const directoryMode = directoryStat.mode & 0o777;
if (directoryMode !== 0o700) throw new Error(`State directory mode must be 0700, got 0${directoryMode.toString(8)}`);
if (typeof process.getuid === "function" && directoryStat.uid !== process.getuid()) throw new Error("State directory is not owned by the current user");
const lockFile = `${this.file}.lock`;
try {
this.lockFd = await acquireLock(lockFile);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !await removeStaleLock(lockFile)) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
throw error;
}
this.lockFd = await acquireLock(lockFile).catch((retryError) => {
if ((retryError as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Session state is locked by another gori-agent instance: ${lockFile}`);
throw retryError;
});
}
try {
await this.lockFd.writeFile(`${process.pid}\n`);
await this.lockFd.sync();
} catch (error) {
await this.releaseLock();
throw error;
}
try {
const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = parseStoreData(JSON.parse(raw) as unknown);
if (parsed.botId !== this.identity.botId || parsed.platform !== this.identity.platform) {
throw new Error(`state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
}
this.data = parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
await this.releaseLock();
throw new Error(`Cannot read session state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
getBinding(chatKey: string): AssistantBinding | undefined {
const value = this.data.bindings[chatKey];
return value ? structuredClone(value) : undefined;
}
async setBinding(binding: AssistantBinding): Promise<void> {
this.data.bindings[binding.chatKey] = structuredClone(binding);
await this.persist();
}
async touchBinding(chatKey: string): Promise<void> {
const binding = this.data.bindings[chatKey];
if (!binding) return;
binding.updatedAt = Date.now();
binding.lastActiveAt = binding.updatedAt;
await this.persist();
}
async deleteBinding(chatKey: string): Promise<AssistantBinding | undefined> {
const existing = this.data.bindings[chatKey];
delete this.data.bindings[chatKey];
if (existing) await this.persist();
return existing ? structuredClone(existing) : undefined;
}
stats(): { bindings: number } {
return { bindings: Object.keys(this.data.bindings).length };
}
async flush(): Promise<void> { await this.queue; }
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
try { await this.flush(); } finally { await this.releaseLock(); }
}
private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Session store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
const operation = this.queue.then(() => atomicWrite(this.file, snapshot));
this.queue = operation.catch(() => undefined);
return operation;
}
private async releaseLock(): Promise<void> {
if (!this.lockFd) return;
await this.lockFd.close();
this.lockFd = undefined;
await fs.promises.unlink(`${this.file}.lock`).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; }
function emptyData(identity: StoreIdentity): StoreData {
return { version: 3, botId: identity.botId, platform: identity.platform, bindings: {} };
}
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 3 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) {
throw new Error("unsupported state version; Config v3 requires state v3 in a new GORI_AGENT_HOME");
}
for (const [chatKey, binding] of Object.entries(value.bindings)) {
if (!isRecord(binding)
|| binding.chatKey !== chatKey
|| typeof binding.agentId !== "string"
|| typeof binding.nativeSessionId !== "string"
|| typeof binding.assistantWorkspace !== "string"
|| typeof binding.botFingerprint !== "string"
|| typeof binding.createdAt !== "number"
|| typeof binding.updatedAt !== "number"
|| (binding.lastActiveAt !== undefined && typeof binding.lastActiveAt !== "number")) {
throw new Error(`invalid state v3 binding '${chatKey}'`);
}
}
return value as unknown as StoreData;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600);
}
async function removeStaleLock(lockFile: string): Promise<boolean> {
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(lockFile, "r");
const pid = Number((await handle.readFile("utf8")).trim());
if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false;
const opened = await handle.stat();
const current = await fs.promises.lstat(lockFile);
if (opened.dev !== current.dev || opened.ino !== current.ino) return false;
await fs.promises.unlink(lockFile);
return true;
} catch { return false; }
finally { await handle?.close().catch(() => undefined); }
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(temp, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await fs.promises.rename(temp, file);
await fs.promises.chmod(file, 0o600);
const dir = await fs.promises.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); }
} catch (error) {
if (handle) await handle.close().catch(() => undefined);
await fs.promises.unlink(temp).catch(() => undefined);
throw error;
}
}