From d4e04f33a65f555adb7ac41a617b6d9ff8ff1926 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 7 May 2026 02:16:46 -0500 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../OpenClawProtocol/GatewayModels.swift | 6 +- .../OpenClawProtocol/GatewayModels.swift | 6 +- docs/cli/sessions.md | 6 + docs/concepts/session.md | 6 + .../register.status-health-sessions.test.ts | 2 + .../register.status-health-sessions.ts | 10 ++ src/commands/sessions-cleanup.test.ts | 16 ++ src/commands/sessions-cleanup.ts | 9 +- src/config/sessions/cleanup-service.ts | 145 ++++++++++++++++-- .../store.pruning.integration.test.ts | 86 +++++++++++ src/gateway/protocol/schema/sessions.ts | 1 + src/gateway/server-methods/sessions.ts | 1 + 13 files changed, 283 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f12cb90a1c4..c486b9b75dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 806412bce9b..ccef9eb5dd0 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 806412bce9b..ccef9eb5dd0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 6a510b3e129..66dee565935 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -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 `: 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 `: run cleanup for one configured agent store. - `--all-agents`: run cleanup for all configured agent stores. @@ -128,6 +130,8 @@ traffic. Use `--store ` 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 ` 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 } diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 0ac9d1a9a7a..c831d72238c 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -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 diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index a7f0764f8f9..d172074ac3c 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -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, }), diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index 4d39987135e..c4c21e086f7 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -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 ", "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), }, diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 323a23a2f73..64d3a580716 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -119,7 +119,11 @@ describe("sessionsCleanupCommand", () => { staleKeys: Set; cappedKeys: Set; budgetEvictedKeys: Set; + dmScopeRetiredKeys: Set; }) => { + 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(), cappedKeys: new Set(), budgetEvictedKeys: new Set(), + dmScopeRetiredKeys: new Set(), }, ], 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(), cappedKeys: new Set(), budgetEvictedKeys: new Set(), + dmScopeRetiredKeys: new Set(), }, ], 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(), budgetEvictedKeys: new Set(), + dmScopeRetiredKeys: new Set(), }, ], 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(), budgetEvictedKeys: new Set(), + dmScopeRetiredKeys: new Set(), }, { 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(), budgetEvictedKeys: new Set(), + dmScopeRetiredKeys: new Set(), }, ], appliedSummaries: [], diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index 80366e97a9b..b9e9d7b7e39 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -25,7 +25,7 @@ import { toSessionDisplayRows, } from "./sessions-table.js"; -const ACTION_PAD = 12; +const ACTION_PAD = 16; type SessionCleanupActionRow = ReturnType[number] & { action: ReturnType; @@ -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; cappedKeys: Set; budgetEvictedKeys: Set; + dmScopeRetiredKeys: Set; }): 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, diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 1da353be7c1..75d46a93f71 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -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; cappedKeys: Set; budgetEvictedKeys: Set; + dmScopeRetiredKeys: Set; }>; appliedSummaries: SessionCleanupSummary[]; }; @@ -95,7 +100,11 @@ export function resolveSessionCleanupAction(params: { staleKeys: Set; cappedKeys: Set; budgetEvictedKeys: Set; + dmScopeRetiredKeys: Set; }): 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, + entry: SessionEntry | undefined, +): void { + if (entry?.sessionId) { + removedSessionFiles.set(entry.sessionId, entry.sessionFile); + } +} + +function retireMainScopeDirectSessionEntries(params: { + cfg: OpenClawConfig; + store: Record; + 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(); const cappedKeys = new Set(); const missingKeys = new Set(); + const dmScopeRetiredKeys = new Set(); 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(); 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(); + 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 || diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index e6e2699c34e..3f42e4dddeb 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -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, + 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, + 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: { diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 6fa6de273a0..bb7a690a8b2 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -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 }, ); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 02c5008c48e..ec17d72e0da 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -708,6 +708,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { enforce: params.enforce, activeKey: params.activeKey, fixMissing: params.fixMissing, + fixDmScope: params.fixDmScope, }, }); const result = serializeSessionCleanupResult({