mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 06:10:22 +00:00
fix: enrich session_end lifecycle hooks (#59715)
Merged via squash.
Prepared head SHA: b3ef62b973
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
@@ -434,6 +434,7 @@ export async function runPreflightCompactionIfNeeded(params: {
|
||||
}
|
||||
|
||||
await incrementCompactionCount({
|
||||
cfg: params.cfg,
|
||||
sessionEntry: entry,
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -729,6 +730,7 @@ export async function runMemoryFlushIfNeeded(params: {
|
||||
if (memoryCompactionCompleted) {
|
||||
const previousSessionId = activeSessionEntry?.sessionId ?? params.followupRun.run.sessionId;
|
||||
const nextCount = await incrementCompactionCount({
|
||||
cfg: params.cfg,
|
||||
sessionEntry: activeSessionEntry,
|
||||
sessionStore: activeSessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
|
||||
@@ -731,6 +731,7 @@ export async function runReplyAgent(params: {
|
||||
if (autoCompactionCount > 0) {
|
||||
const previousSessionId = activeSessionEntry?.sessionId ?? followupRun.run.sessionId;
|
||||
const count = await incrementRunCompactionCount({
|
||||
cfg,
|
||||
sessionEntry: activeSessionEntry,
|
||||
sessionStore: activeSessionStore,
|
||||
sessionKey,
|
||||
|
||||
@@ -154,6 +154,7 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
: "Compaction failed";
|
||||
if (result.ok && result.compacted) {
|
||||
await incrementCompactionCount({
|
||||
cfg: params.cfg,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
|
||||
@@ -366,6 +366,7 @@ export function createFollowupRunner(params: {
|
||||
if (autoCompactionCount > 0) {
|
||||
const previousSessionId = queued.run.sessionId;
|
||||
const count = await incrementRunCompactionCount({
|
||||
cfg: queued.run.config,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
|
||||
@@ -27,6 +27,24 @@ async function writeStore(
|
||||
await fs.writeFile(storePath, JSON.stringify(store), "utf-8");
|
||||
}
|
||||
|
||||
async function writeTranscript(
|
||||
storePath: string,
|
||||
sessionId: string,
|
||||
text = "hello",
|
||||
): Promise<string> {
|
||||
const transcriptPath = path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
||||
await fs.writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
id: `${sessionId}-m1`,
|
||||
message: { role: "user", content: text },
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
return transcriptPath;
|
||||
}
|
||||
|
||||
describe("session hook context wiring", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
@@ -75,9 +93,11 @@ describe("session hook context wiring", () => {
|
||||
it("passes sessionKey to session_end hook context on reset", async () => {
|
||||
const sessionKey = "agent:main:telegram:direct:123";
|
||||
const storePath = await createStorePath("openclaw-session-hook-end");
|
||||
const transcriptPath = await writeTranscript(storePath, "old-session");
|
||||
await writeStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: "old-session",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -92,11 +112,179 @@ describe("session hook context wiring", () => {
|
||||
expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1);
|
||||
expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1);
|
||||
const [event, context] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
|
||||
expect(event).toMatchObject({ sessionKey });
|
||||
expect(event).toMatchObject({
|
||||
sessionKey,
|
||||
reason: "new",
|
||||
transcriptArchived: true,
|
||||
});
|
||||
expect(context).toMatchObject({ sessionKey, agentId: "main" });
|
||||
expect(context).toMatchObject({ sessionId: event?.sessionId });
|
||||
expect(event?.sessionFile).toContain(".jsonl.reset.");
|
||||
|
||||
const [startEvent] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
|
||||
const [startEvent, startContext] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
|
||||
expect(startEvent).toMatchObject({ resumedFrom: "old-session" });
|
||||
expect(event?.nextSessionId).toBe(startEvent?.sessionId);
|
||||
expect(startContext).toMatchObject({ sessionId: startEvent?.sessionId });
|
||||
});
|
||||
|
||||
it("marks explicit /reset rollovers with reason reset", async () => {
|
||||
const sessionKey = "agent:main:telegram:direct:456";
|
||||
const storePath = await createStorePath("openclaw-session-hook-explicit-reset");
|
||||
const transcriptPath = await writeTranscript(storePath, "reset-session", "reset me");
|
||||
await writeStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: "reset-session",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
|
||||
await initSessionState({
|
||||
ctx: { Body: "/reset", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
|
||||
expect(event).toMatchObject({ reason: "reset" });
|
||||
});
|
||||
|
||||
it("maps custom reset trigger aliases to the new-session reason", async () => {
|
||||
const sessionKey = "agent:main:telegram:direct:alias";
|
||||
const storePath = await createStorePath("openclaw-session-hook-reset-alias");
|
||||
const transcriptPath = await writeTranscript(storePath, "alias-session", "alias me");
|
||||
await writeStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: "alias-session",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
resetTriggers: ["/fresh"],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await initSessionState({
|
||||
ctx: { Body: "/fresh", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
|
||||
expect(event).toMatchObject({ reason: "new" });
|
||||
});
|
||||
|
||||
it("marks daily stale rollovers and exposes the archived transcript path", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
const sessionKey = "agent:main:telegram:direct:daily";
|
||||
const storePath = await createStorePath("openclaw-session-hook-daily");
|
||||
const transcriptPath = await writeTranscript(storePath, "daily-session", "daily");
|
||||
await writeStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: "daily-session",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
|
||||
await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
|
||||
const [startEvent] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
|
||||
expect(event).toMatchObject({
|
||||
reason: "daily",
|
||||
transcriptArchived: true,
|
||||
});
|
||||
expect(event?.sessionFile).toContain(".jsonl.reset.");
|
||||
expect(event?.nextSessionId).toBe(startEvent?.sessionId);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks idle stale rollovers with reason idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
|
||||
const sessionKey = "agent:main:telegram:direct:idle";
|
||||
const storePath = await createStorePath("openclaw-session-hook-idle");
|
||||
const transcriptPath = await writeTranscript(storePath, "idle-session", "idle");
|
||||
await writeStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: "idle-session",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: new Date(2026, 0, 18, 3, 0, 0).getTime(),
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
reset: {
|
||||
mode: "idle",
|
||||
idleMinutes: 30,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
|
||||
expect(event).toMatchObject({ reason: "idle" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers idle over daily when both rollover conditions are true", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
|
||||
const sessionKey = "agent:main:telegram:direct:overlap";
|
||||
const storePath = await createStorePath("openclaw-session-hook-overlap");
|
||||
const transcriptPath = await writeTranscript(storePath, "overlap-session", "overlap");
|
||||
await writeStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: "overlap-session",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: new Date(2026, 0, 18, 4, 45, 0).getTime(),
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 30,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
await initSessionState({
|
||||
ctx: { Body: "hello", SessionKey: sessionKey },
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
const [event] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
|
||||
expect(event).toMatchObject({ reason: "idle" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
PluginHookSessionEndEvent,
|
||||
PluginHookSessionEndReason,
|
||||
PluginHookSessionStartEvent,
|
||||
} from "../../plugins/types.js";
|
||||
|
||||
export type SessionHookContext = {
|
||||
sessionId: string;
|
||||
@@ -25,7 +30,7 @@ export function buildSessionStartHookPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
resumedFrom?: string;
|
||||
}): {
|
||||
event: { sessionId: string; sessionKey: string; resumedFrom?: string };
|
||||
event: PluginHookSessionStartEvent;
|
||||
context: SessionHookContext;
|
||||
} {
|
||||
return {
|
||||
@@ -47,8 +52,14 @@ export function buildSessionEndHookPayload(params: {
|
||||
sessionKey: string;
|
||||
cfg: OpenClawConfig;
|
||||
messageCount?: number;
|
||||
durationMs?: number;
|
||||
reason?: PluginHookSessionEndReason;
|
||||
sessionFile?: string;
|
||||
transcriptArchived?: boolean;
|
||||
nextSessionId?: string;
|
||||
nextSessionKey?: string;
|
||||
}): {
|
||||
event: { sessionId: string; sessionKey: string; messageCount: number };
|
||||
event: PluginHookSessionEndEvent;
|
||||
context: SessionHookContext;
|
||||
} {
|
||||
return {
|
||||
@@ -56,6 +67,12 @@ export function buildSessionEndHookPayload(params: {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageCount: params.messageCount ?? 0,
|
||||
durationMs: params.durationMs,
|
||||
reason: params.reason,
|
||||
sessionFile: params.sessionFile,
|
||||
transcriptArchived: params.transcriptArchived,
|
||||
nextSessionId: params.nextSessionId,
|
||||
nextSessionKey: params.nextSessionKey,
|
||||
},
|
||||
context: buildSessionHookContext({
|
||||
sessionId: params.sessionId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { deriveSessionTotalTokens, type NormalizedUsage } from "../../agents/usage.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import { persistSessionUsageUpdate } from "./session-usage.js";
|
||||
|
||||
@@ -9,6 +10,7 @@ type IncrementRunCompactionCountParams = Omit<
|
||||
"tokensAfter"
|
||||
> & {
|
||||
amount?: number;
|
||||
cfg?: OpenClawConfig;
|
||||
lastCallUsage?: NormalizedUsage;
|
||||
contextTokensUsed?: number;
|
||||
newSessionId?: string;
|
||||
@@ -32,6 +34,7 @@ export async function incrementRunCompactionCount(
|
||||
sessionStore: params.sessionStore,
|
||||
sessionKey: params.sessionKey,
|
||||
storePath: params.storePath,
|
||||
cfg: params.cfg,
|
||||
amount: params.amount,
|
||||
tokensAfter: tokensAfterCompaction,
|
||||
newSessionId: params.newSessionId,
|
||||
|
||||
110
src/auto-reply/reply/session-updates.lifecycle.test.ts
Normal file
110
src/auto-reply/reply/session-updates.lifecycle.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { HookRunner } from "../../plugins/hooks.js";
|
||||
|
||||
const hookRunnerMocks = vi.hoisted(() => ({
|
||||
hasHooks: vi.fn<HookRunner["hasHooks"]>(),
|
||||
runSessionEnd: vi.fn<HookRunner["runSessionEnd"]>(),
|
||||
runSessionStart: vi.fn<HookRunner["runSessionStart"]>(),
|
||||
}));
|
||||
|
||||
let incrementCompactionCount: typeof import("./session-updates.js").incrementCompactionCount;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createFixture() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-updates-"));
|
||||
tempDirs.push(root);
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:telegram:direct:compaction";
|
||||
const transcriptPath = path.join(root, "s1.jsonl");
|
||||
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf-8");
|
||||
const entry = {
|
||||
sessionId: "s1",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: Date.now(),
|
||||
compactionCount: 0,
|
||||
} as SessionEntry;
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
[sessionKey]: entry,
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
|
||||
return { storePath, sessionKey, sessionStore, entry, transcriptPath };
|
||||
}
|
||||
|
||||
describe("session-updates lifecycle hooks", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () =>
|
||||
({
|
||||
hasHooks: hookRunnerMocks.hasHooks,
|
||||
runSessionEnd: hookRunnerMocks.runSessionEnd,
|
||||
runSessionStart: hookRunnerMocks.runSessionStart,
|
||||
}) as unknown as HookRunner,
|
||||
}));
|
||||
hookRunnerMocks.hasHooks.mockReset();
|
||||
hookRunnerMocks.runSessionEnd.mockReset();
|
||||
hookRunnerMocks.runSessionStart.mockReset();
|
||||
hookRunnerMocks.hasHooks.mockImplementation(
|
||||
(hookName) => hookName === "session_end" || hookName === "session_start",
|
||||
);
|
||||
hookRunnerMocks.runSessionEnd.mockResolvedValue(undefined);
|
||||
hookRunnerMocks.runSessionStart.mockResolvedValue(undefined);
|
||||
({ incrementCompactionCount } = await import("./session-updates.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits compaction lifecycle hooks when newSessionId replaces the session", async () => {
|
||||
const { storePath, sessionKey, sessionStore, entry, transcriptPath } = await createFixture();
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
|
||||
await incrementCompactionCount({
|
||||
cfg,
|
||||
sessionEntry: entry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
newSessionId: "s2",
|
||||
});
|
||||
|
||||
expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1);
|
||||
expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [endEvent, endContext] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? [];
|
||||
const [startEvent, startContext] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? [];
|
||||
|
||||
expect(endEvent).toMatchObject({
|
||||
sessionId: "s1",
|
||||
sessionKey,
|
||||
reason: "compaction",
|
||||
transcriptArchived: false,
|
||||
});
|
||||
expect(endEvent?.sessionFile).toBe(await fs.realpath(transcriptPath));
|
||||
expect(endContext).toMatchObject({
|
||||
sessionId: "s1",
|
||||
sessionKey,
|
||||
agentId: "main",
|
||||
});
|
||||
expect(endEvent?.nextSessionId).toBe(startEvent?.sessionId);
|
||||
expect(startEvent).toMatchObject({
|
||||
sessionId: "s2",
|
||||
sessionKey,
|
||||
resumedFrom: "s1",
|
||||
});
|
||||
expect(startContext).toMatchObject({
|
||||
sessionId: "s2",
|
||||
sessionKey,
|
||||
agentId: "main",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { resolveStableSessionEndTranscript } from "../../gateway/session-transcript-files.fs.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { buildSessionEndHookPayload, buildSessionStartHookPayload } from "./session-hooks.js";
|
||||
export { drainFormattedSystemEvents } from "./session-system-events.js";
|
||||
|
||||
async function persistSessionEntryUpdate(params: {
|
||||
@@ -35,6 +39,52 @@ async function persistSessionEntryUpdate(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function emitCompactionSessionLifecycleHooks(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
previousEntry: SessionEntry;
|
||||
nextEntry: SessionEntry;
|
||||
}) {
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (!hookRunner) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hookRunner.hasHooks("session_end")) {
|
||||
const transcript = resolveStableSessionEndTranscript({
|
||||
sessionId: params.previousEntry.sessionId,
|
||||
storePath: params.storePath,
|
||||
sessionFile: params.previousEntry.sessionFile,
|
||||
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
});
|
||||
const payload = buildSessionEndHookPayload({
|
||||
sessionId: params.previousEntry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
cfg: params.cfg,
|
||||
reason: "compaction",
|
||||
sessionFile: transcript.sessionFile,
|
||||
transcriptArchived: transcript.transcriptArchived,
|
||||
nextSessionId: params.nextEntry.sessionId,
|
||||
});
|
||||
void hookRunner.runSessionEnd(payload.event, payload.context).catch((err) => {
|
||||
logVerbose(`session_end hook failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (hookRunner.hasHooks("session_start")) {
|
||||
const payload = buildSessionStartHookPayload({
|
||||
sessionId: params.nextEntry.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
cfg: params.cfg,
|
||||
resumedFrom: params.previousEntry.sessionId,
|
||||
});
|
||||
void hookRunner.runSessionStart(payload.event, payload.context).catch((err) => {
|
||||
logVerbose(`session_start hook failed: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSkillSnapshot(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
@@ -151,6 +201,7 @@ export async function incrementCompactionCount(params: {
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
cfg?: OpenClawConfig;
|
||||
now?: number;
|
||||
amount?: number;
|
||||
/** Token count after compaction - if provided, updates session token counts */
|
||||
@@ -163,6 +214,7 @@ export async function incrementCompactionCount(params: {
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
cfg,
|
||||
now = Date.now(),
|
||||
amount = 1,
|
||||
tokensAfter,
|
||||
@@ -213,6 +265,15 @@ export async function incrementCompactionCount(params: {
|
||||
};
|
||||
});
|
||||
}
|
||||
if (newSessionId && newSessionId !== entry.sessionId && cfg) {
|
||||
emitCompactionSessionLifecycleHooks({
|
||||
cfg,
|
||||
sessionKey,
|
||||
storePath,
|
||||
previousEntry: entry,
|
||||
nextEntry: sessionStore[sessionKey],
|
||||
});
|
||||
}
|
||||
return nextCount;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
resolveSessionResetPolicy,
|
||||
resolveSessionResetType,
|
||||
resolveThreadFlag,
|
||||
type SessionFreshness,
|
||||
} from "../../config/sessions/reset.js";
|
||||
import { resolveAndPersistSessionFile } from "../../config/sessions/session-file.js";
|
||||
import { resolveSessionKey } from "../../config/sessions/session-key.js";
|
||||
@@ -31,6 +32,7 @@ import { getSessionBindingService } from "../../infra/outbound/session-binding-s
|
||||
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import type { PluginHookSessionEndReason } from "../../plugins/types.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
@@ -58,6 +60,33 @@ function loadSessionArchiveRuntime() {
|
||||
return sessionArchiveRuntimePromise;
|
||||
}
|
||||
|
||||
function resolveExplicitSessionEndReason(
|
||||
matchedResetTriggerLower?: string,
|
||||
): PluginHookSessionEndReason {
|
||||
return matchedResetTriggerLower === "/reset" ? "reset" : "new";
|
||||
}
|
||||
|
||||
function resolveStaleSessionEndReason(params: {
|
||||
entry: SessionEntry | undefined;
|
||||
freshness?: SessionFreshness;
|
||||
now: number;
|
||||
}): PluginHookSessionEndReason | undefined {
|
||||
if (!params.entry || !params.freshness) {
|
||||
return undefined;
|
||||
}
|
||||
const staleDaily =
|
||||
params.freshness.dailyResetAt != null && params.entry.updatedAt < params.freshness.dailyResetAt;
|
||||
const staleIdle =
|
||||
params.freshness.idleExpiresAt != null && params.now > params.freshness.idleExpiresAt;
|
||||
if (staleIdle) {
|
||||
return "idle";
|
||||
}
|
||||
if (staleDaily) {
|
||||
return "daily";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type SessionInitResult = {
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry: SessionEntry;
|
||||
@@ -304,6 +333,7 @@ export async function initSessionState(params: {
|
||||
// "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body.
|
||||
const trimmedBodyLower = trimmedBody.toLowerCase();
|
||||
const strippedForResetLower = strippedForReset.toLowerCase();
|
||||
let matchedResetTriggerLower: string | undefined;
|
||||
|
||||
for (const trigger of resetTriggers) {
|
||||
if (!trigger) {
|
||||
@@ -323,6 +353,7 @@ export async function initSessionState(params: {
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
resetTriggered = true;
|
||||
matchedResetTriggerLower = triggerLower;
|
||||
break;
|
||||
}
|
||||
const triggerPrefixLower = `${triggerLower} `;
|
||||
@@ -336,6 +367,7 @@ export async function initSessionState(params: {
|
||||
isNewSession = true;
|
||||
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
|
||||
resetTriggered = true;
|
||||
matchedResetTriggerLower = triggerLower;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -389,16 +421,24 @@ export async function initSessionState(params: {
|
||||
// See #58409 for details on silent session reset bug.
|
||||
const isSystemEvent =
|
||||
ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event";
|
||||
const freshEntry = entry
|
||||
const entryFreshness = entry
|
||||
? isSystemEvent
|
||||
? true
|
||||
: evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
|
||||
: false;
|
||||
? ({ fresh: true } satisfies SessionFreshness)
|
||||
: evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy })
|
||||
: undefined;
|
||||
const freshEntry = entryFreshness?.fresh ?? false;
|
||||
// Capture the current session entry before any reset so its transcript can be
|
||||
// archived afterward. We need to do this for both explicit resets (/new, /reset)
|
||||
// and for scheduled/daily resets where the session has become stale (!freshEntry).
|
||||
// Without this, daily-reset transcripts are left as orphaned files on disk (#35481).
|
||||
const previousSessionEntry = (resetTriggered || !freshEntry) && entry ? { ...entry } : undefined;
|
||||
const previousSessionEndReason = resetTriggered
|
||||
? resolveExplicitSessionEndReason(matchedResetTriggerLower)
|
||||
: resolveStaleSessionEndReason({
|
||||
entry,
|
||||
freshness: entryFreshness,
|
||||
now,
|
||||
});
|
||||
clearBootstrapSnapshotOnSessionRollover({
|
||||
sessionKey,
|
||||
previousSessionId: previousSessionEntry?.sessionId,
|
||||
@@ -640,15 +680,27 @@ export async function initSessionState(params: {
|
||||
);
|
||||
|
||||
// Archive old transcript so it doesn't accumulate on disk (#14869).
|
||||
let previousSessionTranscript: {
|
||||
sessionFile?: string;
|
||||
transcriptArchived?: boolean;
|
||||
} = {};
|
||||
if (previousSessionEntry?.sessionId) {
|
||||
const { archiveSessionTranscripts } = await loadSessionArchiveRuntime();
|
||||
archiveSessionTranscripts({
|
||||
const { archiveSessionTranscriptsDetailed, resolveStableSessionEndTranscript } =
|
||||
await loadSessionArchiveRuntime();
|
||||
const archivedTranscripts = archiveSessionTranscriptsDetailed({
|
||||
sessionId: previousSessionEntry.sessionId,
|
||||
storePath,
|
||||
sessionFile: previousSessionEntry.sessionFile,
|
||||
agentId,
|
||||
reason: "reset",
|
||||
});
|
||||
previousSessionTranscript = resolveStableSessionEndTranscript({
|
||||
sessionId: previousSessionEntry.sessionId,
|
||||
storePath,
|
||||
sessionFile: previousSessionEntry.sessionFile,
|
||||
agentId,
|
||||
archivedTranscripts,
|
||||
});
|
||||
await disposeSessionMcpRuntime(previousSessionEntry.sessionId).catch((error) => {
|
||||
log.warn(
|
||||
`failed to dispose bundle MCP runtime for session ${previousSessionEntry.sessionId}`,
|
||||
@@ -688,6 +740,10 @@ export async function initSessionState(params: {
|
||||
sessionId: previousSessionEntry.sessionId,
|
||||
sessionKey,
|
||||
cfg,
|
||||
reason: previousSessionEndReason,
|
||||
sessionFile: previousSessionTranscript.sessionFile,
|
||||
transcriptArchived: previousSessionTranscript.transcriptArchived,
|
||||
nextSessionId: effectiveSessionId,
|
||||
});
|
||||
void hookRunner.runSessionEnd(payload.event, payload.context).catch(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user