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:
committed by
gsxdsm
parent
32291be20a
commit
3b6c6b9df5
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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() },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user