feat(FN-4733): complete Step 3 — wire chat cleanup into maintenance

Fusion-Task-Id: FN-4733
Fusion-Task-Lineage: 38495ced-c7a0-40aa-be22-7f02fe958806
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 08:39:51 -07:00
committed by gsxdsm
parent 32291be20a
commit 3b6c6b9df5
3 changed files with 114 additions and 2 deletions

View File

@@ -0,0 +1,90 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rm } from "node:fs/promises";
import { ChatStore, Database } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
describe("FN-4733: self-healing chat cleanup maintenance", () => {
let tmpRoot: string;
let db: Database;
let chatStore: ChatStore;
beforeAll(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "fusion-self-healing-chat-cleanup-"));
const fusionDir = join(tmpRoot, ".fusion");
db = new Database(fusionDir, { inMemory: true });
db.init();
chatStore = new ChatStore(fusionDir, db);
});
beforeEach(() => {
db.exec(`
DELETE FROM chat_room_messages;
DELETE FROM chat_room_members;
DELETE FROM chat_rooms;
DELETE FROM chat_messages;
DELETE FROM chat_sessions;
`);
});
afterAll(async () => {
db.close();
await rm(tmpRoot, { recursive: true, force: true });
});
function buildManager(days: number) {
const store = {
getSettings: vi.fn(async () => ({ chatAutoCleanupDays: days, globalPause: true, enginePaused: false })),
} as any;
const manager = new SelfHealingManager(store, { rootDir: tmpRoot, chatStore });
vi.spyOn(manager as any, "pruneWorktrees").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphans").mockResolvedValue(undefined);
vi.spyOn(manager as any, "cleanupOrphanedBranches").mockResolvedValue(undefined);
vi.spyOn(manager as any, "checkpointWal").mockReturnValue(undefined);
vi.spyOn(manager as any, "enforceWorktreeCap").mockResolvedValue(undefined);
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);
return manager;
}
it("removes only stale sessions and rooms when chatAutoCleanupDays is enabled", async () => {
const staleSession = chatStore.createSession({ agentId: "agent-1", title: "stale" });
const freshSession = chatStore.createSession({ agentId: "agent-1", title: "fresh" });
const staleRoom = chatStore.createRoom({ name: "stale-room", projectId: "proj-1" });
const freshRoom = chatStore.createRoom({ name: "fresh-room", projectId: "proj-1" });
const staleTimestamp = new Date(Date.now() - 10 * 86_400_000).toISOString();
const freshTimestamp = new Date(Date.now() - 2 * 86_400_000).toISOString();
db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(staleTimestamp, staleSession.id);
db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(freshTimestamp, freshSession.id);
db.prepare("UPDATE chat_rooms SET updatedAt = ? WHERE id = ?").run(staleTimestamp, staleRoom.id);
db.prepare("UPDATE chat_rooms SET updatedAt = ? WHERE id = ?").run(freshTimestamp, freshRoom.id);
const manager = buildManager(7);
await (manager as any).runMaintenance();
expect(chatStore.getSession(staleSession.id)).toBeUndefined();
expect(chatStore.getRoom(staleRoom.id)).toBeUndefined();
expect(chatStore.getSession(freshSession.id)).toBeDefined();
expect(chatStore.getRoom(freshRoom.id)).toBeDefined();
});
it("is a no-op when chatAutoCleanupDays is off", async () => {
const session = chatStore.createSession({ agentId: "agent-1", title: "keep" });
const room = chatStore.createRoom({ name: "keep-room", projectId: "proj-1" });
db.prepare("UPDATE chat_sessions SET updatedAt = ? WHERE id = ?").run(new Date(Date.now() - 100 * 86_400_000).toISOString(), session.id);
db.prepare("UPDATE chat_rooms SET updatedAt = ? WHERE id = ?").run(new Date(Date.now() - 100 * 86_400_000).toISOString(), room.id);
const manager = buildManager(0);
await (manager as any).runMaintenance();
expect(chatStore.getSession(session.id)).toBeDefined();
expect(chatStore.getRoom(room.id)).toBeDefined();
});
});

View File

@@ -641,6 +641,7 @@ export class InProcessRuntime
}
// 7. Initialize SelfHealingManager
this.chatStore ??= new ChatStore(this.taskStore.getFusionDir(), this.taskStore.getDatabase());
this.selfHealingManager = new SelfHealingManager(this.taskStore, {
rootDir: this.config.workingDirectory,
agentStore: this.agentStore,
@@ -655,6 +656,7 @@ export class InProcessRuntime
getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null,
leaseManager: this.leaseManager,
hasActiveAgentExecution: (agentId: string) => this.heartbeatMonitor?.getTrackedAgents().includes(agentId) ?? false,
chatStore: this.chatStore,
restartDurableAgentHeartbeat: async (agentId: string, context: { reason: string; attempt: number }) => {
if (!this.heartbeatMonitor) {
return false;

View File

@@ -26,7 +26,7 @@ import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority } from "@fusion/core";
import { getInReviewStallReason, getStalePausedReviewSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger } from "./logger.js";
import { getRegisteredWorktreePaths, isUsableTaskWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
@@ -197,6 +197,8 @@ export interface SelfHealingOptions {
hasActiveAgentExecution?: (agentId: string) => boolean;
restartDurableAgentHeartbeat?: (agentId: string, context: { reason: string; attempt: number }) => Promise<boolean>;
autoRecoveryDispatcher?: AutoRecoveryDispatcher;
/** Optional ChatStore for maintenance chat-retention cleanup. */
chatStore?: ChatStore;
}
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
@@ -940,11 +942,29 @@ export class SelfHealingManager {
log.log("Maintenance cycle starting");
try {
// Batch 1 — Git/filesystem cleanup
const settings = await this.store.getSettings();
// Batch 1 — housekeeping (safe under pause: filesystem/db cleanup only)
const batch1Fns: Array<{ name: string; fn: () => Promise<unknown> }> = [
{ name: "prune-worktrees", fn: () => this.pruneWorktrees() },
{ name: "cleanup-orphans", fn: () => this.cleanupOrphans() },
{ name: "cleanup-orphaned-branches", fn: () => this.cleanupOrphanedBranches() },
{
name: "cleanup-old-chats",
fn: async () => {
const days = Number(settings.chatAutoCleanupDays ?? 0);
if (!Number.isFinite(days) || days <= 0) {
log.log("Maintenance batch 1 step \"cleanup-old-chats\" skipped — chatAutoCleanupDays is not enabled");
return;
}
if (!this.options.chatStore) {
log.log("Maintenance batch 1 step \"cleanup-old-chats\" skipped — ChatStore unavailable");
return;
}
const { sessionsDeleted, roomsDeleted } = this.options.chatStore.cleanupOldChats(days * 86_400_000);
log.log(`Maintenance batch 1 step "cleanup-old-chats" succeeded — sessions=${sessionsDeleted} rooms=${roomsDeleted}`);
},
},
{ name: "checkpoint-wal", fn: () => Promise.resolve(this.checkpointWal()) },
{ name: "enforce-worktree-cap", fn: () => this.enforceWorktreeCap() },
];