feat(executor): retention sweep and per-conversation purge for tool-result spill files

This commit is contained in:
matevip 2026-05-13 08:50:22 +08:00
parent 7015d1513c
commit 86a6829102
6 changed files with 438 additions and 5 deletions

View File

@ -97,6 +97,23 @@ public class ToolResultProperties {
*/
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
/**
* Days to retain spill files before the scheduled cleanup deletes them.
* Spill files exist to let the model recover full tool output via
* {@code read_file} during the active conversation; once the conversation
* is dormant for this many days the agent is extremely unlikely to ever
* read the file again, and disk pressure starts to matter.
*/
private int retentionDays = 7;
/**
* Cron expression for the spill-cleanup task. Defaults to once a day at
* 03:00 server-local time so cleanup runs during quiet hours. Set this
* to a Spring-recognised value (six-field cron) or change the bean
* wiring to disable it entirely.
*/
private String cleanupCron = "0 0 3 * * ?";
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@ -130,6 +147,14 @@ public class ToolResultProperties {
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
}
public int getRetentionDays() { return retentionDays; }
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
public String getCleanupCron() { return cleanupCron; }
public void setCleanupCron(String cleanupCron) {
this.cleanupCron = cleanupCron == null ? "" : cleanupCron;
}
/** O(1) membership test for the exclusion list, used on every tool result. */
public Set<String> excludedToolsSet() {
return Set.copyOf(excludedTools);

View File

@ -0,0 +1,54 @@
package vip.mate.agent.graph.executor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Drives {@link ToolResultStorage#cleanupExpired()} on a cron schedule so
* spill files don't accumulate forever. Kept in its own class instead of
* inlined into {@link ToolResultStorage} for two reasons:
*
* <ul>
* <li>Tests can exercise {@code cleanupExpired()} directly without
* fighting the Spring scheduler.</li>
* <li>Deployments that want to disable the schedule entirely can simply
* leave this component out of the autoconfigure path.</li>
* </ul>
*
* <p>The cron expression comes from
* {@link ToolResultProperties#getCleanupCron()} (default {@code 0 0 3 * * ?},
* i.e. once a day at 03:00 server-local time). The retention horizon comes
* from {@link ToolResultProperties#getRetentionDays()}.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ToolResultRetentionScheduler {
private final ToolResultStorage storage;
private final ToolResultProperties props;
/**
* Cron-fired hook. Failures are logged at WARN so they show up in
* standard log scrapes without aborting the scheduler thread losing
* a single sweep is fine, the next one will catch the same files.
*/
@Scheduled(cron = "${mate.agent.tool-result.cleanup-cron:0 0 3 * * ?}")
public void cleanup() {
if (props.getRetentionDays() <= 0) {
log.debug("[ToolResultRetentionScheduler] retentionDays<=0, skipping sweep");
return;
}
try {
int deleted = storage.cleanupExpired();
if (deleted > 0) {
log.info("[ToolResultRetentionScheduler] sweep deleted {} spill file(s) older than {} days",
deleted, props.getRetentionDays());
}
} catch (Exception e) {
log.warn("[ToolResultRetentionScheduler] sweep failed: {}", e.getMessage(), e);
}
}
}

View File

@ -58,6 +58,15 @@ public class ToolResultStorage {
/** D-6: monotonically increasing spill counter for observability. */
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
/**
* Workspace roots observed during this JVM's lifetime. Populated every
* time a successful spill resolves a base directory; consulted by the
* scheduled retention sweep and by {@link #purgeConversation} so we
* don't have to query the database for every workspace path. Cross-JVM
* orphans are not covered that is documented in the cleanup javadoc.
*/
private final java.util.Set<Path> observedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
public ToolResultStorage(ToolResultProperties props) {
this.props = props;
this.excludedToolsSnapshot = props.excludedToolsSet();
@ -270,15 +279,162 @@ public class ToolResultStorage {
}
private Path resolveBaseDir(String workspaceBasePath) {
Path base;
if (!props.getStorageBaseDir().isEmpty()) {
return Paths.get(props.getStorageBaseDir());
base = Paths.get(props.getStorageBaseDir());
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
} else {
String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null;
base = Paths.get(tmp, "mateclaw", "tool-results");
}
if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
return Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
// Register so the retention sweep and conversation-delete hook can
// reach this root even when the workspace path is no longer in scope.
observedRoots.add(base);
return base;
}
/**
* Roots currently known to this instance. Exposed package-private so the
* scheduled retention sweep and unit tests can enumerate them without
* touching the underlying set directly.
*/
java.util.Set<Path> getObservedRoots() {
return java.util.Collections.unmodifiableSet(observedRoots);
}
/**
* Best-effort: delete every spill file and per-conversation directory
* older than {@link ToolResultProperties#getRetentionDays()} across all
* roots this storage has seen, plus the configured base dir and the
* tmpdir fallback. Returns the number of files deleted.
*
* <p>Workspaces that never received a spill in this JVM's lifetime are
* not covered. Persisting an observed-roots registry across restarts
* could fix that, but is intentionally out of scope the operator-side
* remedy is to run a one-off cleanup with {@code storage-base-dir}
* pointed at the historical workspace.
*/
public int cleanupExpired() {
if (props.getRetentionDays() <= 0) {
return 0;
}
long cutoffEpochMillis = System.currentTimeMillis()
- (long) props.getRetentionDays() * 24L * 60L * 60L * 1000L;
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
if (!props.getStorageBaseDir().isEmpty()) {
roots.add(Paths.get(props.getStorageBaseDir()));
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null;
return Paths.get(tmp, "mateclaw", "tool-results");
if (tmp != null && !tmp.isEmpty()) {
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
}
int deleted = 0;
for (Path root : roots) {
deleted += deleteExpiredUnder(root, cutoffEpochMillis);
}
if (deleted > 0) {
log.info("[ToolResultStorage] cleanup: {} spill files removed across {} root(s)",
deleted, roots.size());
}
return deleted;
}
private int deleteExpiredUnder(Path root, long cutoffEpochMillis) {
if (root == null || !java.nio.file.Files.isDirectory(root)) {
return 0;
}
int deleted = 0;
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.walk(root, 2)) {
for (Path p : (Iterable<Path>) stream::iterator) {
if (p.equals(root)) continue;
if (!java.nio.file.Files.isRegularFile(p)) continue;
try {
long mtime = java.nio.file.Files.getLastModifiedTime(p).toMillis();
if (mtime < cutoffEpochMillis) {
java.nio.file.Files.deleteIfExists(p);
deleted++;
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] failed to inspect spill file {}: {}", p, ioe.getMessage());
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] cleanup walk failed under {}: {}", root, ioe.getMessage());
return deleted;
}
// Best-effort: remove emptied per-conversation directories.
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(root)) {
for (Path child : (Iterable<Path>) stream::iterator) {
if (!java.nio.file.Files.isDirectory(child)) continue;
try (java.util.stream.Stream<Path> kids = java.nio.file.Files.list(child)) {
if (kids.findAny().isEmpty()) {
java.nio.file.Files.deleteIfExists(child);
}
} catch (java.io.IOException ignored) {
// empty-check failure is not fatal leave the directory alone
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] empty-dir sweep failed under {}: {}", root, ioe.getMessage());
}
return deleted;
}
/**
* Delete every spill file produced for {@code conversationId} across
* all observed roots, plus the configured base and tmpdir fallback.
* Called by {@code ConversationService.deleteConversation} so spill
* directories don't outlive the conversation that owns them.
*
* <p>Silently no-ops when nothing matches a conversation that never
* spilled, or one whose workspace root was never observed in this JVM,
* is simply left alone. Returns the number of files deleted.
*/
public int purgeConversation(String conversationId) {
if (conversationId == null || conversationId.isEmpty()) {
return 0;
}
String safeConv = sanitize(conversationId);
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
if (!props.getStorageBaseDir().isEmpty()) {
roots.add(Paths.get(props.getStorageBaseDir()));
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp != null && !tmp.isEmpty()) {
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
}
int deleted = 0;
for (Path root : roots) {
Path convDir = root.resolve(safeConv);
if (!java.nio.file.Files.isDirectory(convDir)) continue;
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(convDir)) {
for (Path p : (Iterable<Path>) stream::iterator) {
try {
if (java.nio.file.Files.isRegularFile(p)) {
java.nio.file.Files.deleteIfExists(p);
deleted++;
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] failed to delete spill file {}: {}", p, ioe.getMessage());
}
}
} catch (java.io.IOException ioe) {
log.warn("[ToolResultStorage] purge walk failed under {}: {}", convDir, ioe.getMessage());
}
try {
java.nio.file.Files.deleteIfExists(convDir);
} catch (java.io.IOException ignored) {
// non-empty after deletes (another writer raced us) fine, leave it
}
}
if (deleted > 0) {
log.info("[ToolResultStorage] purged {} spill file(s) for conversation {}", deleted, conversationId);
}
return deleted;
}
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */

View File

@ -65,6 +65,19 @@ public class ConversationService {
private final ChannelSessionMapper channelSessionMapper;
private final ApplicationEventPublisher eventPublisher;
/**
* Optional spill store. Injected via a setter so the existing @RequiredArgsConstructor
* stays stable and tests that build the service directly don't need to wire
* tool-result storage. When present, deleteConversation also purges any spill
* files this conversation produced so they don't outlive the row that owned them.
*/
private vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setToolResultStorage(vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage) {
this.toolResultStorage = toolResultStorage;
}
/**
* 获取用户的会话列表返回 VO包含 agentName/agentIcon/status
*/
@ -501,15 +514,34 @@ public class ConversationService {
@Override
public void afterCommit() {
cleanAttachmentFiles(conversationId);
purgeToolResultSpill(conversationId);
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
}
});
} else {
cleanAttachmentFiles(conversationId);
purgeToolResultSpill(conversationId);
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
}
}
/**
* Best-effort: ask the spill store to delete every tool-result file this
* conversation produced. No-op when no spill store is wired in (legacy
* deployments or tests that don't need spill). Failures are logged but
* never propagated leaving an extra file on disk is a small price
* compared to surfacing IO errors as a 500 on the delete endpoint.
*/
private void purgeToolResultSpill(String conversationId) {
if (toolResultStorage == null) return;
try {
toolResultStorage.purgeConversation(conversationId);
} catch (Exception e) {
log.warn("[Conversation] tool-result spill purge failed for {}: {}",
conversationId, e.getMessage());
}
}
/**
* 清空会话消息同时清理附件文件
*/

View File

@ -229,6 +229,11 @@ mate:
excluded-tools:
- read_file
- read_workspace_memory_file
# Spill files are deleted after this many days. Set to 0 to disable the
# scheduled sweep entirely (files still get purged when the conversation
# is deleted explicitly via ConversationService.deleteConversation).
retention-days: 7
cleanup-cron: "0 0 3 * * ?"
conversation:
window:
# 测试时临时调低2000 token ≈ 2000 中文字3 轮对话即可触发压缩

View File

@ -0,0 +1,161 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Retention sweep + per-conversation purge for {@link ToolResultStorage}.
*
* <p>The store keeps a per-JVM "observed roots" registry every time a
* spill resolves a directory, that directory is remembered so the
* retention sweep can reach it even after the workspace path has gone
* out of scope. These tests verify the registry behaviour, the
* mtime-based deletion contract, and the targeted per-conversation purge
* called from {@code ConversationService.deleteConversation}.
*/
class ToolResultStorageRetentionTest {
@Test
void successfulSpillRegistersTheRoot(@TempDir Path tempDir) {
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
// Trigger a spill so resolveBaseDir() is invoked.
String out = storage.persistIfOversized(
"x".repeat(500), "web_search", "call-1", "conv-A", tempDir.toString());
assertTrue(out.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
assertTrue(storage.getObservedRoots().contains(tempDir),
"successful spill must register its resolved root for later cleanup");
}
@Test
void cleanupDeletesFilesOlderThanRetention(@TempDir Path tempDir) throws Exception {
// retention = 1 day
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 1);
// Drop a "fresh" spill via the public API.
String fresh = storage.persistIfOversized(
"fresh".repeat(200), "web_search", "call-fresh", "conv-A", tempDir.toString());
assertTrue(fresh.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
Path freshFile = pathFromPreview(fresh);
// Drop a "stale" spill and back-date its mtime by 8 days.
String stale = storage.persistIfOversized(
"stale".repeat(200), "web_search", "call-stale", "conv-B", tempDir.toString());
Path staleFile = pathFromPreview(stale);
Files.setLastModifiedTime(staleFile,
FileTime.from(Instant.now().minus(8, ChronoUnit.DAYS)));
int deleted = storage.cleanupExpired();
assertEquals(1, deleted, "only the stale file should be removed");
assertTrue(Files.exists(freshFile), "fresh file must survive");
assertFalse(Files.exists(staleFile), "stale file must be deleted");
}
@Test
void cleanupIsNoOpWhenRetentionDisabled(@TempDir Path tempDir) throws Exception {
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 0);
String stale = storage.persistIfOversized(
"stale".repeat(200), "web_search", "call-1", "conv-A", tempDir.toString());
Path staleFile = pathFromPreview(stale);
Files.setLastModifiedTime(staleFile,
FileTime.from(Instant.now().minus(100, ChronoUnit.DAYS)));
int deleted = storage.cleanupExpired();
assertEquals(0, deleted, "retentionDays<=0 must disable the sweep entirely");
assertTrue(Files.exists(staleFile), "stale file must remain when sweep is disabled");
}
@Test
void cleanupRemovesEmptiedConversationDirectories(@TempDir Path tempDir) throws Exception {
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 1);
String stale = storage.persistIfOversized(
"stale".repeat(200), "web_search", "call-stale", "conv-old", tempDir.toString());
Path staleFile = pathFromPreview(stale);
Path staleDir = staleFile.getParent();
Files.setLastModifiedTime(staleFile,
FileTime.from(Instant.now().minus(8, ChronoUnit.DAYS)));
storage.cleanupExpired();
assertFalse(Files.exists(staleFile));
assertFalse(Files.exists(staleDir),
"the empty conv-old/ directory should be cleaned up too");
}
@Test
void purgeConversationDeletesAllFilesForOneConversation(@TempDir Path tempDir) throws Exception {
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 30);
// Two spills for conv-A, one spill for conv-B.
String a1 = storage.persistIfOversized(
"a1".repeat(200), "web_search", "call-a1", "conv-A", tempDir.toString());
String a2 = storage.persistIfOversized(
"a2".repeat(200), "web_search", "call-a2", "conv-A", tempDir.toString());
String b1 = storage.persistIfOversized(
"b1".repeat(200), "web_search", "call-b1", "conv-B", tempDir.toString());
Path af1 = pathFromPreview(a1);
Path af2 = pathFromPreview(a2);
Path bf1 = pathFromPreview(b1);
int deleted = storage.purgeConversation("conv-A");
assertEquals(2, deleted, "both A files should be deleted");
assertFalse(Files.exists(af1));
assertFalse(Files.exists(af2));
assertTrue(Files.exists(bf1), "conv-B files must not be touched by a conv-A purge");
}
@Test
void purgeConversationIsSilentForUnknownConversation(@TempDir Path tempDir) {
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
// No spill at all nothing to purge 0, no exception.
int deleted = storage.purgeConversation("never-existed");
assertEquals(0, deleted);
}
@Test
void purgeConversationHandlesBlankIdSafely(@TempDir Path tempDir) {
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
assertEquals(0, storage.purgeConversation(null));
assertEquals(0, storage.purgeConversation(""));
}
// ------------------------------------------------------------------ helpers
private static ToolResultStorage newStorage(Path tempDir, int threshold, int retentionDays) {
ToolResultProperties props = new ToolResultProperties();
props.setStorageBaseDir(tempDir.toString());
props.setPerResultThresholdChars(threshold);
props.setPreviewHeadChars(80);
props.setRetentionDays(retentionDays);
props.setExcludedTools(List.of());
return new ToolResultStorage(props);
}
private static Path pathFromPreview(String preview) {
java.util.regex.Matcher m = java.util.regex.Pattern.compile("path=(\\S+)").matcher(preview);
assertTrue(m.find(), "preview must include path=...");
Path p = Path.of(m.group(1));
assertNotNull(p);
return p;
}
}