Merge 519cf8d5d1cbc6db3c1e995e1b1a477359b9bf47 into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
hellowsz 2026-03-20 22:56:26 -07:00 committed by GitHub
commit 2fd60d9373
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 314 additions and 80 deletions

View File

@ -162,7 +162,7 @@ async function previewStoreCleanup(params: {
activeKey?: string;
fixMissing?: boolean;
}) {
const maintenance = resolveMaintenanceConfig();
const maintenance = resolveMaintenanceConfig(params.target.agentId);
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
const previewStore = structuredClone(beforeStore);
const staleKeys = new Set<string>();
@ -377,6 +377,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
});
},
{
agentId: target.agentId,
activeSessionKey: opts.activeKey,
maintenanceOverride: {
mode,

View File

@ -0,0 +1,176 @@
import { describe, expect, it, vi } from "vitest";
// Mock config loading to control test inputs.
vi.mock("../config.js", () => ({
loadConfig: vi.fn().mockReturnValue({}),
}));
import { loadConfig } from "../config.js";
import { resolveMaintenanceConfig } from "./store-maintenance.js";
const mockLoadConfig = vi.mocked(loadConfig);
const DAY_MS = 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// Per-agent session maintenance config resolution.
// ---------------------------------------------------------------------------
describe("resolveMaintenanceConfig with agentId", () => {
it("returns global config when no agentId is provided", () => {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "7d",
maxEntries: 200,
},
},
agents: {
list: [
{
id: "worker",
maintenance: { mode: "warn", maxEntries: 50 },
},
],
},
} as ReturnType<typeof loadConfig>);
const result = resolveMaintenanceConfig();
expect(result.mode).toBe("enforce");
expect(result.maxEntries).toBe(200);
expect(result.pruneAfterMs).toBe(7 * DAY_MS);
});
it("merges per-agent overrides over global config", () => {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "30d",
maxEntries: 500,
rotateBytes: "10mb",
},
},
agents: {
list: [
{
id: "approval",
maintenance: {
mode: "warn",
maxEntries: 50,
pruneAfter: "1d",
},
},
],
},
} as ReturnType<typeof loadConfig>);
const result = resolveMaintenanceConfig("approval");
expect(result.mode).toBe("warn");
expect(result.maxEntries).toBe(50);
expect(result.pruneAfterMs).toBe(1 * DAY_MS);
// rotateBytes falls through from global.
expect(result.rotateBytes).toBe(10_485_760);
});
it("falls through to global when agent has no maintenance config", () => {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
maxEntries: 300,
},
},
agents: {
list: [{ id: "worker" }],
},
} as ReturnType<typeof loadConfig>);
const result = resolveMaintenanceConfig("worker");
expect(result.mode).toBe("enforce");
expect(result.maxEntries).toBe(300);
});
it("falls through to global when agent is not found in list", () => {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
maxEntries: 100,
},
},
agents: {
list: [{ id: "other" }],
},
} as ReturnType<typeof loadConfig>);
const result = resolveMaintenanceConfig("nonexistent");
expect(result.mode).toBe("enforce");
expect(result.maxEntries).toBe(100);
});
it("uses built-in defaults when no global or per-agent config exists", () => {
mockLoadConfig.mockReturnValue({} as ReturnType<typeof loadConfig>);
const result = resolveMaintenanceConfig("any-agent");
expect(result.mode).toBe("warn");
expect(result.maxEntries).toBe(500);
expect(result.pruneAfterMs).toBe(30 * DAY_MS);
expect(result.rotateBytes).toBe(10_485_760);
});
it("per-agent maxDiskBytes/highWaterBytes override global", () => {
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
maxDiskBytes: "200mb",
},
},
agents: {
list: [
{
id: "heavy",
maintenance: {
maxDiskBytes: "500mb",
highWaterBytes: "400mb",
},
},
],
},
} as ReturnType<typeof loadConfig>);
const result = resolveMaintenanceConfig("heavy");
expect(result.maxDiskBytes).toBe(500 * 1024 * 1024);
expect(result.highWaterBytes).toBe(400 * 1024 * 1024);
});
it("per-agent config without global maintenance still works", () => {
mockLoadConfig.mockReturnValue({
agents: {
list: [
{
id: "standalone",
maintenance: {
mode: "enforce",
pruneAfter: "2d",
maxEntries: 10,
},
},
],
},
} as ReturnType<typeof loadConfig>);
const result = resolveMaintenanceConfig("standalone");
expect(result.mode).toBe("enforce");
expect(result.maxEntries).toBe(10);
expect(result.pruneAfterMs).toBe(2 * DAY_MS);
});
});

View File

@ -124,16 +124,58 @@ function resolveHighWaterBytes(
}
/**
* Resolve maintenance settings from openclaw.json (`session.maintenance`).
* Look up per-agent maintenance overrides from `agents.list[].maintenance`.
* Returns `undefined` when the agent has no overrides or is not found.
*/
function resolveAgentMaintenanceOverride(
agentId: string | undefined,
): SessionMaintenanceConfig | undefined {
if (!agentId) {
return undefined;
}
try {
const agents = loadConfig().agents?.list;
if (!agents) {
return undefined;
}
const agent = agents.find((a) => a.id === agentId);
return agent?.maintenance ?? undefined;
} catch {
return undefined;
}
}
/**
* Merge global session.maintenance with per-agent overrides.
* Per-agent values take precedence; unset fields fall through to global.
*/
function mergeMaintenanceConfig(
global: SessionMaintenanceConfig | undefined,
perAgent: SessionMaintenanceConfig | undefined,
): SessionMaintenanceConfig | undefined {
if (!perAgent) {
return global;
}
if (!global) {
return perAgent;
}
return { ...global, ...perAgent };
}
/**
* Resolve maintenance settings from openclaw.json (`session.maintenance`),
* optionally merging per-agent overrides from `agents.list[].maintenance`.
* Falls back to built-in defaults when config is missing or unset.
*/
export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig {
let maintenance: SessionMaintenanceConfig | undefined;
export function resolveMaintenanceConfig(agentId?: string): ResolvedSessionMaintenanceConfig {
let globalMaintenance: SessionMaintenanceConfig | undefined;
try {
maintenance = loadConfig().session?.maintenance;
globalMaintenance = loadConfig().session?.maintenance;
} catch {
// Config may not be available (e.g. in tests). Use defaults.
}
const agentOverride = resolveAgentMaintenanceOverride(agentId);
const maintenance = mergeMaintenanceConfig(globalMaintenance, agentOverride);
const pruneAfterMs = resolvePruneAfterMs(maintenance);
const maxDiskBytes = resolveMaxDiskBytes(maintenance);
return {

View File

@ -307,6 +307,8 @@ export type { ResolvedSessionMaintenanceConfig, SessionMaintenanceWarning };
type SaveSessionStoreOptions = {
/** Skip pruning, capping, and rotation (e.g. during one-time migrations). */
skipMaintenance?: boolean;
/** Agent id for per-agent maintenance config resolution. */
agentId?: string;
/** Active session key for warn-only maintenance. */
activeSessionKey?: string;
/**
@ -410,7 +412,10 @@ async function saveSessionStoreUnlocked(
if (!opts?.skipMaintenance) {
// Resolve maintenance config once (avoids repeated loadConfig() calls).
const maintenance = { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride };
const maintenance = {
...resolveMaintenanceConfig(opts?.agentId),
...opts?.maintenanceOverride,
};
const shouldWarnOnly = maintenance.mode === "warn";
const beforeCount = Object.keys(store).length;

View File

@ -1,7 +1,7 @@
import type { ChatType } from "../channels/chat-type.js";
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
import type { AgentModelConfig, AgentSandboxConfig } from "./types.agents-shared.js";
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
import type { HumanDelayConfig, IdentityConfig, SessionMaintenanceConfig } from "./types.base.js";
import type { GroupChatConfig } from "./types.messages.js";
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
@ -80,6 +80,8 @@ export type AgentConfig = {
/** Per-agent default model for spawned sub-agents (string or {primary,fallbacks}). */
model?: AgentModelConfig;
};
/** Optional per-agent session maintenance overrides. */
maintenance?: SessionMaintenanceConfig;
/** Optional per-agent sandbox overrides. */
sandbox?: AgentSandboxConfig;
/** Optional per-agent stream params (e.g. cacheRetention, temperature). */

View File

@ -10,6 +10,7 @@ import {
ToolsLinksSchema,
ToolsMediaSchema,
} from "./zod-schema.core.js";
import { SessionMaintenanceSchema } from "./zod-schema.maintenance.js";
import { sensitive } from "./zod-schema.sensitive.js";
export const HeartbeatSchema = z
@ -791,6 +792,7 @@ export const AgentEntrySchema = z
})
.strict()
.optional(),
maintenance: SessionMaintenanceSchema,
sandbox: AgentSandboxSchema,
params: z.record(z.string(), z.unknown()).optional(),
tools: AgentToolsSchema,

View File

@ -0,0 +1,75 @@
import { z } from "zod";
import { parseByteSize } from "../cli/parse-bytes.js";
import { parseDurationMs } from "../cli/parse-duration.js";
export const SessionMaintenanceSchema = z
.object({
mode: z.enum(["enforce", "warn"]).optional(),
pruneAfter: z.union([z.string(), z.number()]).optional(),
/** @deprecated Use pruneAfter instead. */
pruneDays: z.number().int().positive().optional(),
maxEntries: z.number().int().positive().optional(),
rotateBytes: z.union([z.string(), z.number()]).optional(),
resetArchiveRetention: z.union([z.string(), z.number(), z.literal(false)]).optional(),
maxDiskBytes: z.union([z.string(), z.number()]).optional(),
highWaterBytes: z.union([z.string(), z.number()]).optional(),
})
.strict()
.superRefine((val, ctx) => {
if (val.pruneAfter !== undefined) {
try {
parseDurationMs(String(val.pruneAfter).trim(), { defaultUnit: "d" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["pruneAfter"],
message: "invalid duration (use ms, s, m, h, d)",
});
}
}
if (val.rotateBytes !== undefined) {
try {
parseByteSize(String(val.rotateBytes).trim(), { defaultUnit: "b" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["rotateBytes"],
message: "invalid size (use b, kb, mb, gb, tb)",
});
}
}
if (val.resetArchiveRetention !== undefined && val.resetArchiveRetention !== false) {
try {
parseDurationMs(String(val.resetArchiveRetention).trim(), { defaultUnit: "d" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["resetArchiveRetention"],
message: "invalid duration (use ms, s, m, h, d)",
});
}
}
if (val.maxDiskBytes !== undefined) {
try {
parseByteSize(String(val.maxDiskBytes).trim(), { defaultUnit: "b" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["maxDiskBytes"],
message: "invalid size (use b, kb, mb, gb, tb)",
});
}
}
if (val.highWaterBytes !== undefined) {
try {
parseByteSize(String(val.highWaterBytes).trim(), { defaultUnit: "b" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["highWaterBytes"],
message: "invalid size (use b, kb, mb, gb, tb)",
});
}
}
})
.optional();

View File

@ -1,6 +1,4 @@
import { z } from "zod";
import { parseByteSize } from "../cli/parse-bytes.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { ElevatedAllowFromSchema } from "./zod-schema.agent-runtime.js";
import { createAllowDenyChannelRulesSchema } from "./zod-schema.allowdeny.js";
import {
@ -11,8 +9,11 @@ import {
TypingModeSchema,
TtsConfigSchema,
} from "./zod-schema.core.js";
import { SessionMaintenanceSchema } from "./zod-schema.maintenance.js";
import { sensitive } from "./zod-schema.sensitive.js";
export { SessionMaintenanceSchema };
const SessionResetConfigSchema = z
.object({
mode: z.union([z.literal("daily"), z.literal("idle")]).optional(),
@ -69,77 +70,7 @@ export const SessionSchema = z
})
.strict()
.optional(),
maintenance: z
.object({
mode: z.enum(["enforce", "warn"]).optional(),
pruneAfter: z.union([z.string(), z.number()]).optional(),
/** @deprecated Use pruneAfter instead. */
pruneDays: z.number().int().positive().optional(),
maxEntries: z.number().int().positive().optional(),
rotateBytes: z.union([z.string(), z.number()]).optional(),
resetArchiveRetention: z.union([z.string(), z.number(), z.literal(false)]).optional(),
maxDiskBytes: z.union([z.string(), z.number()]).optional(),
highWaterBytes: z.union([z.string(), z.number()]).optional(),
})
.strict()
.superRefine((val, ctx) => {
if (val.pruneAfter !== undefined) {
try {
parseDurationMs(String(val.pruneAfter).trim(), { defaultUnit: "d" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["pruneAfter"],
message: "invalid duration (use ms, s, m, h, d)",
});
}
}
if (val.rotateBytes !== undefined) {
try {
parseByteSize(String(val.rotateBytes).trim(), { defaultUnit: "b" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["rotateBytes"],
message: "invalid size (use b, kb, mb, gb, tb)",
});
}
}
if (val.resetArchiveRetention !== undefined && val.resetArchiveRetention !== false) {
try {
parseDurationMs(String(val.resetArchiveRetention).trim(), { defaultUnit: "d" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["resetArchiveRetention"],
message: "invalid duration (use ms, s, m, h, d)",
});
}
}
if (val.maxDiskBytes !== undefined) {
try {
parseByteSize(String(val.maxDiskBytes).trim(), { defaultUnit: "b" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["maxDiskBytes"],
message: "invalid size (use b, kb, mb, gb, tb)",
});
}
}
if (val.highWaterBytes !== undefined) {
try {
parseByteSize(String(val.highWaterBytes).trim(), { defaultUnit: "b" });
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["highWaterBytes"],
message: "invalid size (use b, kb, mb, gb, tb)",
});
}
}
})
.optional(),
maintenance: SessionMaintenanceSchema,
})
.strict()
.optional();