mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-15 13:40:46 +00:00
fix(sessions): retire stale direct dm rows after dmscope changes
Summary:
- Add explicit sessions cleanup --fix-dm-scope handling for stale direct-DM rows after session.dmScope returns to main.
- Preserve removed-row transcripts as deleted archives and expose the option through CLI, Gateway RPC, protocol schema, generated Swift mirrors, docs, tests, and changelog.
- Fixes #47561 and #45554.
Verification:
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md docs/cli/sessions.md docs/concepts/session.md src/config/sessions/cleanup-service.ts src/commands/sessions-cleanup.ts src/cli/program/register.status-health-sessions.ts src/gateway/protocol/schema/sessions.ts src/gateway/server-methods/sessions.ts src/config/sessions/store.pruning.integration.test.ts src/commands/sessions-cleanup.test.ts src/cli/program/register.status-health-sessions.test.ts
- git diff --check origin/main...HEAD
- pnpm protocol:check
- pnpm exec oxlint src/config/sessions/cleanup-service.ts src/commands/sessions-cleanup.ts src/cli/program/register.status-health-sessions.ts src/gateway/protocol/schema/sessions.ts src/gateway/server-methods/sessions.ts src/config/sessions/store.pruning.integration.test.ts src/commands/sessions-cleanup.test.ts src/cli/program/register.status-health-sessions.test.ts
- pnpm test src/config/sessions/store.pruning.integration.test.ts src/commands/sessions-cleanup.test.ts src/cli/program/register.status-health-sessions.test.ts src/gateway/server.sessions.store-rpc.test.ts
- pnpm changed:lanes --json
Security:
- No new network, credential, process execution, dependency, or permission surface. Cleanup is explicit operator-invoked local session-store repair.
CI note:
- Exact-head CI failures match current main at 2e78fc57af in unrelated extensions/codex and extensions/microsoft-foundry type checks, outside this PR diff. No required checks are reported for this branch.
This commit is contained in:
@@ -228,6 +228,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WebChat/Codex media: stage Codex app-server generated local images into managed media before Gateway display, so Codex-home image paths no longer hit `LocalMediaAccessError` while keeping Codex home out of the display allowlist. Thanks @frankekn.
|
||||
- Plugins/update: repair plugin-local `openclaw` peer links for all recorded npm plugins after any npm update mutates the shared managed npm tree, so targeted or batch updates cannot leave Codex, Discord, or Brave with pruned SDK imports. (#77787) Thanks @ProspectOre.
|
||||
- Codex harness: honor `models.providers.openai-codex.models[].contextTokens` for native `openai/*` Codex runtime runs and `/status` context reporting, so subscription-backed Codex agents use the configured OAuth context cap without inflating past the runtime model window. Fixes #77858. Thanks @lilesjtu.
|
||||
- Sessions cleanup: add `openclaw sessions cleanup --fix-dm-scope` so operators who return `session.dmScope` to `main` can dry-run and retire stale direct-DM session rows while preserving transcripts as deleted archives. Fixes #47561 and #45554. Thanks @BunsDev.
|
||||
- TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc.
|
||||
- Doctor/gateway: report recent supervisor restart handoffs in `openclaw doctor --deep`, using the installed service environment when available so service-managed clean exits are visible in guided diagnostics. Thanks @shakkernerd.
|
||||
- Gateway/status: show recent supervisor restart handoffs in `openclaw gateway status --deep`, including JSON details, so clean service-managed restarts are reported as restart handoffs instead of opaque stopped-service diagnostics. Thanks @shakkernerd.
|
||||
|
||||
@@ -1568,19 +1568,22 @@ public struct SessionsCleanupParams: Codable, Sendable {
|
||||
public let enforce: Bool?
|
||||
public let activekey: String?
|
||||
public let fixmissing: Bool?
|
||||
public let fixdmscope: Bool?
|
||||
|
||||
public init(
|
||||
agent: String?,
|
||||
allagents: Bool?,
|
||||
enforce: Bool?,
|
||||
activekey: String?,
|
||||
fixmissing: Bool?)
|
||||
fixmissing: Bool?,
|
||||
fixdmscope: Bool?)
|
||||
{
|
||||
self.agent = agent
|
||||
self.allagents = allagents
|
||||
self.enforce = enforce
|
||||
self.activekey = activekey
|
||||
self.fixmissing = fixmissing
|
||||
self.fixdmscope = fixdmscope
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1589,6 +1592,7 @@ public struct SessionsCleanupParams: Codable, Sendable {
|
||||
case enforce
|
||||
case activekey = "activeKey"
|
||||
case fixmissing = "fixMissing"
|
||||
case fixdmscope = "fixDmScope"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1568,19 +1568,22 @@ public struct SessionsCleanupParams: Codable, Sendable {
|
||||
public let enforce: Bool?
|
||||
public let activekey: String?
|
||||
public let fixmissing: Bool?
|
||||
public let fixdmscope: Bool?
|
||||
|
||||
public init(
|
||||
agent: String?,
|
||||
allagents: Bool?,
|
||||
enforce: Bool?,
|
||||
activekey: String?,
|
||||
fixmissing: Bool?)
|
||||
fixmissing: Bool?,
|
||||
fixdmscope: Bool?)
|
||||
{
|
||||
self.agent = agent
|
||||
self.allagents = allagents
|
||||
self.enforce = enforce
|
||||
self.activekey = activekey
|
||||
self.fixmissing = fixmissing
|
||||
self.fixdmscope = fixdmscope
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -1589,6 +1592,7 @@ public struct SessionsCleanupParams: Codable, Sendable {
|
||||
case enforce
|
||||
case activekey = "activeKey"
|
||||
case fixmissing = "fixMissing"
|
||||
case fixdmscope = "fixDmScope"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ openclaw sessions cleanup --agent work --dry-run
|
||||
openclaw sessions cleanup --all-agents --dry-run
|
||||
openclaw sessions cleanup --enforce
|
||||
openclaw sessions cleanup --enforce --active-key "agent:main:telegram:direct:123"
|
||||
openclaw sessions cleanup --dry-run --fix-dm-scope
|
||||
openclaw sessions cleanup --json
|
||||
```
|
||||
|
||||
@@ -105,6 +106,7 @@ openclaw sessions cleanup --json
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
|
||||
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
|
||||
- `--fix-missing`: remove entries whose transcript files are missing, even if they would not normally age/count out yet.
|
||||
- `--fix-dm-scope`: when `session.dmScope` is `main`, retire stale peer-keyed direct-DM rows left behind by earlier `per-peer`, `per-channel-peer`, or `per-account-channel-peer` routing. Use `--dry-run` first; applying the cleanup removes those rows from `sessions.json` and preserves their transcripts as deleted archives.
|
||||
- `--active-key <key>`: protect a specific active key from disk-budget eviction. Durable external conversation pointers, such as group sessions and thread-scoped chat sessions, are also kept by age/count/disk-budget maintenance.
|
||||
- `--agent <id>`: run cleanup for one configured agent store.
|
||||
- `--all-agents`: run cleanup for all configured agent stores.
|
||||
@@ -128,6 +130,8 @@ traffic. Use `--store <path>` for explicit offline repair of a store file.
|
||||
"storePath": "/home/user/.openclaw/agents/main/sessions/sessions.json",
|
||||
"beforeCount": 120,
|
||||
"afterCount": 80,
|
||||
"missing": 0,
|
||||
"dmScopeRetired": 0,
|
||||
"pruned": 40,
|
||||
"capped": 0
|
||||
},
|
||||
@@ -136,6 +140,8 @@ traffic. Use `--store <path>` for explicit offline repair of a store file.
|
||||
"storePath": "/home/user/.openclaw/agents/work/sessions/sessions.json",
|
||||
"beforeCount": 18,
|
||||
"afterCount": 18,
|
||||
"missing": 0,
|
||||
"dmScopeRetired": 0,
|
||||
"pruned": 0,
|
||||
"capped": 0
|
||||
}
|
||||
|
||||
@@ -131,6 +131,12 @@ Maintenance preserves durable external conversation pointers, including group
|
||||
sessions and thread-scoped chat sessions, while still allowing synthetic cron,
|
||||
hook, heartbeat, ACP, and sub-agent entries to age out.
|
||||
|
||||
If you previously used direct-message isolation and later returned
|
||||
`session.dmScope` to `main`, preview stale peer-keyed DM rows with
|
||||
`openclaw sessions cleanup --dry-run --fix-dm-scope`. Applying the same flag
|
||||
retires those old direct-DM rows and keeps their transcripts as deleted
|
||||
archives.
|
||||
|
||||
Preview with `openclaw sessions cleanup --dry-run`.
|
||||
|
||||
## Inspecting sessions
|
||||
|
||||
@@ -239,6 +239,7 @@ describe("registerStatusHealthSessionsCommands", () => {
|
||||
"--dry-run",
|
||||
"--enforce",
|
||||
"--fix-missing",
|
||||
"--fix-dm-scope",
|
||||
"--active-key",
|
||||
"agent:main:main",
|
||||
"--json",
|
||||
@@ -252,6 +253,7 @@ describe("registerStatusHealthSessionsCommands", () => {
|
||||
dryRun: true,
|
||||
enforce: true,
|
||||
fixMissing: true,
|
||||
fixDmScope: true,
|
||||
activeKey: "agent:main:main",
|
||||
json: true,
|
||||
}),
|
||||
|
||||
@@ -182,6 +182,11 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
"Remove store entries whose transcript files are missing (bypasses age/count retention)",
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
"--fix-dm-scope",
|
||||
"Retire stale direct-DM session rows that no longer match session.dmScope=main",
|
||||
false,
|
||||
)
|
||||
.option("--active-key <key>", "Protect this session key from budget-eviction")
|
||||
.option("--json", "Output JSON", false)
|
||||
.addHelpText(
|
||||
@@ -193,6 +198,10 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
"openclaw sessions cleanup --dry-run --fix-missing",
|
||||
"Also preview pruning entries with missing transcript files.",
|
||||
],
|
||||
[
|
||||
"openclaw sessions cleanup --dry-run --fix-dm-scope",
|
||||
"Preview stale direct-DM rows after returning dmScope to main.",
|
||||
],
|
||||
["openclaw sessions cleanup --enforce", "Apply maintenance now."],
|
||||
["openclaw sessions cleanup --agent work --dry-run", "Preview one agent store."],
|
||||
["openclaw sessions cleanup --all-agents --dry-run", "Preview all agent stores."],
|
||||
@@ -220,6 +229,7 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
enforce: Boolean(opts.enforce),
|
||||
fixMissing: Boolean(opts.fixMissing),
|
||||
fixDmScope: Boolean(opts.fixDmScope),
|
||||
activeKey: opts.activeKey as string | undefined,
|
||||
json: Boolean(opts.json || parentOpts?.json),
|
||||
},
|
||||
|
||||
@@ -119,7 +119,11 @@ describe("sessionsCleanupCommand", () => {
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
dmScopeRetiredKeys: Set<string>;
|
||||
}) => {
|
||||
if (params.dmScopeRetiredKeys.has(params.key)) {
|
||||
return "retire-dm-scope";
|
||||
}
|
||||
if (params.missingKeys.has(params.key)) {
|
||||
return "prune-missing";
|
||||
}
|
||||
@@ -181,6 +185,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
beforeCount: 3,
|
||||
afterCount: 1,
|
||||
missing: 0,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 0,
|
||||
capped: 2,
|
||||
diskBudget: {
|
||||
@@ -245,6 +250,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
beforeCount: 3,
|
||||
afterCount: 1,
|
||||
missing: 0,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 2,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
@@ -286,6 +292,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
beforeCount: 2,
|
||||
afterCount: 1,
|
||||
missing: 0,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
diskBudget: {
|
||||
@@ -305,6 +312,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
staleKeys: new Set<string>(),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
dmScopeRetiredKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
@@ -347,6 +355,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
beforeCount: 1,
|
||||
afterCount: 0,
|
||||
missing: 1,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 0,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
@@ -357,6 +366,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
staleKeys: new Set<string>(),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
dmScopeRetiredKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
@@ -393,6 +403,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
beforeCount: 2,
|
||||
afterCount: 1,
|
||||
missing: 0,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
unreferencedArtifacts: {
|
||||
@@ -412,6 +423,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
staleKeys: new Set(["stale"]),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
dmScopeRetiredKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
@@ -450,6 +462,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
beforeCount: 1,
|
||||
afterCount: 0,
|
||||
missing: 0,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
@@ -460,6 +473,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
staleKeys: new Set(["stale"]),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
dmScopeRetiredKeys: new Set<string>(),
|
||||
},
|
||||
{
|
||||
summary: {
|
||||
@@ -470,6 +484,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
beforeCount: 1,
|
||||
afterCount: 0,
|
||||
missing: 0,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 1,
|
||||
capped: 0,
|
||||
diskBudget: null,
|
||||
@@ -480,6 +495,7 @@ describe("sessionsCleanupCommand", () => {
|
||||
staleKeys: new Set(["stale"]),
|
||||
cappedKeys: new Set<string>(),
|
||||
budgetEvictedKeys: new Set<string>(),
|
||||
dmScopeRetiredKeys: new Set<string>(),
|
||||
},
|
||||
],
|
||||
appliedSummaries: [],
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
toSessionDisplayRows,
|
||||
} from "./sessions-table.js";
|
||||
|
||||
const ACTION_PAD = 12;
|
||||
const ACTION_PAD = 16;
|
||||
|
||||
type SessionCleanupActionRow = ReturnType<typeof toSessionDisplayRows>[number] & {
|
||||
action: ReturnType<typeof resolveSessionCleanupAction>;
|
||||
@@ -48,6 +48,9 @@ function formatCleanupActionCell(
|
||||
if (action === "prune-stale") {
|
||||
return theme.warn(label);
|
||||
}
|
||||
if (action === "retire-dm-scope") {
|
||||
return theme.warn(label);
|
||||
}
|
||||
if (action === "cap-overflow") {
|
||||
return theme.accentBright(label);
|
||||
}
|
||||
@@ -60,6 +63,7 @@ function buildActionRows(params: {
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
dmScopeRetiredKeys: Set<string>;
|
||||
}): SessionCleanupActionRow[] {
|
||||
return toSessionDisplayRows(params.beforeStore).map((row) =>
|
||||
Object.assign({}, row, {
|
||||
@@ -69,6 +73,7 @@ function buildActionRows(params: {
|
||||
staleKeys: params.staleKeys,
|
||||
cappedKeys: params.cappedKeys,
|
||||
budgetEvictedKeys: params.budgetEvictedKeys,
|
||||
dmScopeRetiredKeys: params.dmScopeRetiredKeys,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -91,6 +96,7 @@ function renderStoreDryRunPlan(params: {
|
||||
`Entries: ${params.summary.beforeCount} -> ${params.summary.afterCount} (remove ${params.summary.beforeCount - params.summary.afterCount})`,
|
||||
);
|
||||
params.runtime.log(`Would prune missing transcripts: ${params.summary.missing}`);
|
||||
params.runtime.log(`Would retire stale direct DM sessions: ${params.summary.dmScopeRetired}`);
|
||||
params.runtime.log(`Would prune stale: ${params.summary.pruned}`);
|
||||
params.runtime.log(`Would cap overflow: ${params.summary.capped}`);
|
||||
if (params.summary.unreferencedArtifacts?.scannedFiles) {
|
||||
@@ -169,6 +175,7 @@ async function maybeRunGatewayCleanup(
|
||||
enforce: opts.enforce,
|
||||
activeKey: opts.activeKey,
|
||||
fixMissing: opts.fixMissing,
|
||||
fixDmScope: opts.fixDmScope,
|
||||
},
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js";
|
||||
import { getLogger } from "../../logging/logger.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import {
|
||||
enforceSessionDiskBudget,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type ResolvedSessionMaintenanceConfig,
|
||||
} from "./store-maintenance.js";
|
||||
import {
|
||||
archiveRemovedSessionTranscripts,
|
||||
loadSessionStore,
|
||||
updateSessionStore,
|
||||
type SessionMaintenanceApplyReport,
|
||||
@@ -41,6 +42,7 @@ export type SessionsCleanupOptions = SessionStoreSelectionOptions & {
|
||||
activeKey?: string;
|
||||
json?: boolean;
|
||||
fixMissing?: boolean;
|
||||
fixDmScope?: boolean;
|
||||
};
|
||||
|
||||
export type SessionCleanupAction =
|
||||
@@ -48,7 +50,8 @@ export type SessionCleanupAction =
|
||||
| "prune-missing"
|
||||
| "prune-stale"
|
||||
| "cap-overflow"
|
||||
| "evict-budget";
|
||||
| "evict-budget"
|
||||
| "retire-dm-scope";
|
||||
|
||||
export type SessionCleanupSummary = {
|
||||
agentId: string;
|
||||
@@ -58,6 +61,7 @@ export type SessionCleanupSummary = {
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
missing: number;
|
||||
dmScopeRetired: number;
|
||||
pruned: number;
|
||||
capped: number;
|
||||
unreferencedArtifacts: SessionUnreferencedArtifactSweepResult;
|
||||
@@ -85,6 +89,7 @@ export type SessionsCleanupRunResult = {
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
dmScopeRetiredKeys: Set<string>;
|
||||
}>;
|
||||
appliedSummaries: SessionCleanupSummary[];
|
||||
};
|
||||
@@ -95,7 +100,11 @@ export function resolveSessionCleanupAction(params: {
|
||||
staleKeys: Set<string>;
|
||||
cappedKeys: Set<string>;
|
||||
budgetEvictedKeys: Set<string>;
|
||||
dmScopeRetiredKeys: Set<string>;
|
||||
}): SessionCleanupAction {
|
||||
if (params.dmScopeRetiredKeys.has(params.key)) {
|
||||
return "retire-dm-scope";
|
||||
}
|
||||
if (params.missingKeys.has(params.key)) {
|
||||
return "prune-missing";
|
||||
}
|
||||
@@ -111,6 +120,64 @@ export function resolveSessionCleanupAction(params: {
|
||||
return "keep";
|
||||
}
|
||||
|
||||
function isMainScopeStaleDirectSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
targetAgentId: string;
|
||||
key: string;
|
||||
activeKey?: string;
|
||||
}): boolean {
|
||||
if ((params.cfg.session?.dmScope ?? "main") !== "main") {
|
||||
return false;
|
||||
}
|
||||
if (params.activeKey && params.key === params.activeKey) {
|
||||
return false;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(params.key);
|
||||
if (!parsed || normalizeAgentId(parsed.agentId) !== normalizeAgentId(params.targetAgentId)) {
|
||||
return false;
|
||||
}
|
||||
const parts = parsed.rest.split(":").filter(Boolean);
|
||||
return (
|
||||
(parts.length === 2 && parts[0] === "direct") ||
|
||||
(parts.length === 3 && parts[1] === "direct") ||
|
||||
(parts.length === 4 && parts[2] === "direct")
|
||||
);
|
||||
}
|
||||
|
||||
function rememberRemovedSessionFile(
|
||||
removedSessionFiles: Map<string, string | undefined>,
|
||||
entry: SessionEntry | undefined,
|
||||
): void {
|
||||
if (entry?.sessionId) {
|
||||
removedSessionFiles.set(entry.sessionId, entry.sessionFile);
|
||||
}
|
||||
}
|
||||
|
||||
function retireMainScopeDirectSessionEntries(params: {
|
||||
cfg: OpenClawConfig;
|
||||
store: Record<string, SessionEntry>;
|
||||
targetAgentId: string;
|
||||
activeKey?: string;
|
||||
onRetired?: (key: string, entry: SessionEntry) => void;
|
||||
}): number {
|
||||
let retired = 0;
|
||||
for (const [key, entry] of Object.entries(params.store)) {
|
||||
if (
|
||||
isMainScopeStaleDirectSessionKey({
|
||||
cfg: params.cfg,
|
||||
targetAgentId: params.targetAgentId,
|
||||
key,
|
||||
activeKey: params.activeKey,
|
||||
})
|
||||
) {
|
||||
params.onRetired?.(key, entry);
|
||||
delete params.store[key];
|
||||
retired += 1;
|
||||
}
|
||||
}
|
||||
return retired;
|
||||
}
|
||||
|
||||
export function serializeSessionCleanupResult(params: {
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
dryRun: boolean;
|
||||
@@ -172,18 +239,21 @@ function addEntryArtifactPathsToSet(params: {
|
||||
}
|
||||
|
||||
async function previewStoreCleanup(params: {
|
||||
cfg: OpenClawConfig;
|
||||
target: SessionStoreTarget;
|
||||
maintenance: ResolvedSessionMaintenanceConfig;
|
||||
mode: ResolvedSessionMaintenanceConfig["mode"];
|
||||
dryRun: boolean;
|
||||
activeKey?: string;
|
||||
fixMissing?: boolean;
|
||||
fixDmScope?: boolean;
|
||||
}) {
|
||||
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
|
||||
const previewStore = cloneSessionStoreRecord(beforeStore);
|
||||
const staleKeys = new Set<string>();
|
||||
const cappedKeys = new Set<string>();
|
||||
const missingKeys = new Set<string>();
|
||||
const dmScopeRetiredKeys = new Set<string>();
|
||||
const missing =
|
||||
params.fixMissing === true
|
||||
? pruneMissingTranscriptEntries({
|
||||
@@ -194,6 +264,18 @@ async function previewStoreCleanup(params: {
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
const dmScopeRetired =
|
||||
params.fixDmScope === true
|
||||
? retireMainScopeDirectSessionEntries({
|
||||
cfg: params.cfg,
|
||||
store: previewStore,
|
||||
targetAgentId: params.target.agentId,
|
||||
activeKey: params.activeKey,
|
||||
onRetired: (key) => {
|
||||
dmScopeRetiredKeys.add(key);
|
||||
},
|
||||
})
|
||||
: 0;
|
||||
const pruned = pruneStaleEntries(previewStore, params.maintenance.pruneAfterMs, {
|
||||
log: false,
|
||||
onPruned: ({ key }) => {
|
||||
@@ -219,6 +301,12 @@ async function previewStoreCleanup(params: {
|
||||
storePath: params.target.storePath,
|
||||
keys: cappedKeys,
|
||||
});
|
||||
addEntryArtifactPathsToSet({
|
||||
paths: entryCleanupArtifactPaths,
|
||||
store: beforeStore,
|
||||
storePath: params.target.storePath,
|
||||
keys: dmScopeRetiredKeys,
|
||||
});
|
||||
const beforeBudgetStore = cloneSessionStoreRecord(previewStore);
|
||||
const budgetRemovedFilePaths = new Set<string>();
|
||||
const diskBudget = await enforceSessionDiskBudget({
|
||||
@@ -249,6 +337,7 @@ async function previewStoreCleanup(params: {
|
||||
const afterPreviewCount = Object.keys(previewStore).length;
|
||||
const wouldMutate =
|
||||
missing > 0 ||
|
||||
dmScopeRetired > 0 ||
|
||||
pruned > 0 ||
|
||||
capped > 0 ||
|
||||
unreferencedArtifacts.removedFiles > 0 ||
|
||||
@@ -263,6 +352,7 @@ async function previewStoreCleanup(params: {
|
||||
beforeCount,
|
||||
afterCount: afterPreviewCount,
|
||||
missing,
|
||||
dmScopeRetired,
|
||||
pruned,
|
||||
capped,
|
||||
unreferencedArtifacts,
|
||||
@@ -277,6 +367,7 @@ async function previewStoreCleanup(params: {
|
||||
staleKeys,
|
||||
cappedKeys,
|
||||
budgetEvictedKeys,
|
||||
dmScopeRetiredKeys,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -299,12 +390,14 @@ export async function runSessionsCleanup(params: {
|
||||
const previewResults: SessionsCleanupRunResult["previewResults"] = [];
|
||||
for (const target of targets) {
|
||||
const result = await previewStoreCleanup({
|
||||
cfg,
|
||||
target,
|
||||
maintenance,
|
||||
mode,
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
activeKey: opts.activeKey,
|
||||
fixMissing: Boolean(opts.fixMissing),
|
||||
fixDmScope: Boolean(opts.fixDmScope),
|
||||
});
|
||||
previewResults.push(result);
|
||||
}
|
||||
@@ -315,16 +408,33 @@ export async function runSessionsCleanup(params: {
|
||||
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
|
||||
current: null,
|
||||
};
|
||||
const missingApplied = await updateSessionStore(
|
||||
const dmScopeRemovedSessionFiles = new Map<string, string | undefined>();
|
||||
let missingApplied = 0;
|
||||
let dmScopeRetiredApplied = 0;
|
||||
await updateSessionStore(
|
||||
target.storePath,
|
||||
async (store) => {
|
||||
if (!opts.fixMissing) {
|
||||
return 0;
|
||||
let removed = 0;
|
||||
if (opts.fixMissing) {
|
||||
missingApplied = pruneMissingTranscriptEntries({
|
||||
store,
|
||||
storePath: target.storePath,
|
||||
});
|
||||
removed += missingApplied;
|
||||
}
|
||||
return pruneMissingTranscriptEntries({
|
||||
store,
|
||||
storePath: target.storePath,
|
||||
});
|
||||
if (opts.fixDmScope) {
|
||||
dmScopeRetiredApplied = retireMainScopeDirectSessionEntries({
|
||||
cfg,
|
||||
store,
|
||||
targetAgentId: target.agentId,
|
||||
activeKey: opts.activeKey,
|
||||
onRetired: (_key, entry) => {
|
||||
rememberRemovedSessionFile(dmScopeRemovedSessionFiles, entry);
|
||||
},
|
||||
});
|
||||
removed += dmScopeRetiredApplied;
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
{
|
||||
activeSessionKey: opts.activeKey,
|
||||
@@ -336,6 +446,20 @@ export async function runSessionsCleanup(params: {
|
||||
},
|
||||
},
|
||||
);
|
||||
if (dmScopeRemovedSessionFiles.size > 0) {
|
||||
const storeAfterDmScopeRetire = loadSessionStore(target.storePath, { skipCache: true });
|
||||
await archiveRemovedSessionTranscripts({
|
||||
removedSessionFiles: dmScopeRemovedSessionFiles,
|
||||
referencedSessionIds: new Set(
|
||||
Object.values(storeAfterDmScopeRetire)
|
||||
.map((entry) => entry?.sessionId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
storePath: target.storePath,
|
||||
reason: "deleted",
|
||||
restrictToStoreDir: true,
|
||||
});
|
||||
}
|
||||
const afterStore = loadSessionStore(target.storePath, { skipCache: true });
|
||||
const unreferencedArtifacts =
|
||||
mode === "warn"
|
||||
@@ -366,6 +490,7 @@ export async function runSessionsCleanup(params: {
|
||||
beforeCount: 0,
|
||||
afterCount: 0,
|
||||
missing: 0,
|
||||
dmScopeRetired: 0,
|
||||
pruned: 0,
|
||||
capped: 0,
|
||||
unreferencedArtifacts,
|
||||
@@ -387,12 +512,14 @@ export async function runSessionsCleanup(params: {
|
||||
beforeCount: appliedReport.beforeCount,
|
||||
afterCount: appliedReport.afterCount,
|
||||
missing: missingApplied,
|
||||
dmScopeRetired: dmScopeRetiredApplied,
|
||||
pruned: appliedReport.pruned,
|
||||
capped: appliedReport.capped,
|
||||
unreferencedArtifacts,
|
||||
diskBudget: appliedReport.diskBudget,
|
||||
wouldMutate:
|
||||
missingApplied > 0 ||
|
||||
dmScopeRetiredApplied > 0 ||
|
||||
appliedReport.pruned > 0 ||
|
||||
appliedReport.capped > 0 ||
|
||||
unreferencedArtifacts.removedFiles > 0 ||
|
||||
|
||||
@@ -307,6 +307,92 @@ describe("Integration: saveSessionStore with pruning", () => {
|
||||
await expect(fs.stat(freshOrphanTranscript)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("sessions cleanup previews stale direct DM rows after dmScope returns to main", async () => {
|
||||
applyEnforcedMaintenanceConfig(mockLoadConfig);
|
||||
|
||||
const now = Date.now();
|
||||
const directTranscript = path.join(testDir, "direct-session.jsonl");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: now,
|
||||
},
|
||||
"agent:main:telegram:direct:6101296751": {
|
||||
sessionId: "direct-session",
|
||||
updatedAt: now,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "6101296751",
|
||||
},
|
||||
} satisfies Record<string, SessionEntry>,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(path.join(testDir, "main-session.jsonl"), "main", "utf-8");
|
||||
await fs.writeFile(directTranscript, "direct", "utf-8");
|
||||
|
||||
const dryRun = await runSessionsCleanup({
|
||||
cfg: { session: { dmScope: "main" } },
|
||||
opts: { store: storePath, dryRun: true, enforce: true, fixDmScope: true },
|
||||
targets: [{ agentId: "main", storePath }],
|
||||
});
|
||||
|
||||
const preview = dryRun.previewResults[0];
|
||||
expect(preview?.summary.dmScopeRetired).toBe(1);
|
||||
expect(preview?.summary.afterCount).toBe(1);
|
||||
expect(preview?.dmScopeRetiredKeys.has("agent:main:telegram:direct:6101296751")).toBe(true);
|
||||
expect(preview?.summary.unreferencedArtifacts.removedFiles).toBe(0);
|
||||
await expect(fs.stat(directTranscript)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("sessions cleanup retires stale direct DM rows and archives their transcripts", async () => {
|
||||
applyEnforcedMaintenanceConfig(mockLoadConfig);
|
||||
|
||||
const now = Date.now();
|
||||
const directTranscript = path.join(testDir, "direct-session.jsonl");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
"agent:main:main": {
|
||||
sessionId: "main-session",
|
||||
updatedAt: now,
|
||||
},
|
||||
"agent:main:telegram:direct:6101296751": {
|
||||
sessionId: "direct-session",
|
||||
updatedAt: now,
|
||||
sessionFile: directTranscript,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "6101296751",
|
||||
},
|
||||
} satisfies Record<string, SessionEntry>,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(path.join(testDir, "main-session.jsonl"), "main", "utf-8");
|
||||
await fs.writeFile(directTranscript, "direct", "utf-8");
|
||||
|
||||
const applied = await runSessionsCleanup({
|
||||
cfg: { session: { dmScope: "main" } },
|
||||
opts: { store: storePath, enforce: true, fixDmScope: true },
|
||||
targets: [{ agentId: "main", storePath }],
|
||||
});
|
||||
|
||||
expect(applied.appliedSummaries[0]?.dmScopeRetired).toBe(1);
|
||||
const persisted = loadSessionStore(storePath, { skipCache: true });
|
||||
expect(persisted["agent:main:main"]).toBeDefined();
|
||||
expect(persisted["agent:main:telegram:direct:6101296751"]).toBeUndefined();
|
||||
await expect(fs.stat(directTranscript)).rejects.toThrow();
|
||||
const files = await fs.readdir(testDir);
|
||||
expect(files.some((name) => name.startsWith("direct-session.jsonl.deleted."))).toBe(true);
|
||||
});
|
||||
|
||||
it("sessions cleanup dry-run does not double-count artifacts already covered by disk budget", async () => {
|
||||
mockLoadConfig.mockReturnValue({
|
||||
session: {
|
||||
|
||||
@@ -71,6 +71,7 @@ export const SessionsCleanupParamsSchema = Type.Object(
|
||||
enforce: Type.Optional(Type.Boolean()),
|
||||
activeKey: Type.Optional(NonEmptyString),
|
||||
fixMissing: Type.Optional(Type.Boolean()),
|
||||
fixDmScope: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -708,6 +708,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
enforce: params.enforce,
|
||||
activeKey: params.activeKey,
|
||||
fixMissing: params.fixMissing,
|
||||
fixDmScope: params.fixDmScope,
|
||||
},
|
||||
});
|
||||
const result = serializeSessionCleanupResult({
|
||||
|
||||
Reference in New Issue
Block a user