mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
fix(sessions): retire stale direct dm rows after dmscope changes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -707,6 +707,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