fix(sessions): retire stale direct dm rows after dmscope changes

This commit is contained in:
Val Alexander
2026-05-05 09:20:58 -05:00
parent 58c706451e
commit 9583e2890a
13 changed files with 283 additions and 12 deletions

View File

@@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai
- Gateway/HTTP: avoid loading managed outgoing-image media handlers for unrelated requests, so disabled OpenAI-compatible routes return 404 without waiting on lazy media sidecars. Thanks @vincentkoc.
- Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires.
- Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754)
- 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.

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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,
}),

View File

@@ -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),
},

View File

@@ -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: [],

View File

@@ -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,

View File

@@ -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 ||

View File

@@ -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: {

View File

@@ -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 },
);

View File

@@ -707,6 +707,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
enforce: params.enforce,
activeKey: params.activeKey,
fixMissing: params.fixMissing,
fixDmScope: params.fixDmScope,
},
});
const result = serializeSessionCleanupResult({