From f59acd27786e52f4f19f8dc62697c86cd1ba730e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 19:32:31 -0400 Subject: [PATCH] Garbage-collect orphan inbox projection ledger rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent_message_projections accumulates one row per (message_id, session_id) pair and previously had no purge path — the table grew monotonically for the life of the process. Once a session is rotated away no agent references its id as the active one, and its ledger rows exist only to prevent replay against a shadow that no future run will touch. Add purgeOrphanProjections (delete rows whose session_id no longer appears in agents.session_id) and wire it into the housekeeping scheduler with a 24h safety margin so rotations in flight during the sweep cannot strip the ledger under an active run. --- src/main/database/inbox-projections.ts | 18 ++++++++++++++++++ .../orchestration/scheduler-housekeeping.ts | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/main/database/inbox-projections.ts b/src/main/database/inbox-projections.ts index 8fde4b46..f6ebdb26 100644 --- a/src/main/database/inbox-projections.ts +++ b/src/main/database/inbox-projections.ts @@ -28,5 +28,23 @@ export function buildInboxProjectionMethods(getDb: () => DatabaseSync) { ).run(messageId, sessionId, nowIso()); return Number(result.changes ?? 0) > 0; }, + + /** + * Purge ledger rows for sessions no agent currently references as + * its active `session_id`. A rotated session is never read again — + * its ledger entries only exist to prevent replays against that + * specific shadow, which no future run will touch. The safety + * margin (rows younger than the cutoff are preserved) covers + * rotations that happen mid-sweep so a race cannot strip the + * ledger under an active run. + */ + purgeOrphanProjections(cutoffIso: string): number { + const result = getDb().prepare( + `DELETE FROM agent_message_projections + WHERE projected_at < ? + AND session_id NOT IN (SELECT session_id FROM agents WHERE session_id IS NOT NULL)`, + ).run(cutoffIso); + return Number(result.changes ?? 0); + }, }; } diff --git a/src/main/orchestration/scheduler-housekeeping.ts b/src/main/orchestration/scheduler-housekeeping.ts index 3023c4d2..d29284a9 100644 --- a/src/main/orchestration/scheduler-housekeeping.ts +++ b/src/main/orchestration/scheduler-housekeeping.ts @@ -88,6 +88,22 @@ export function createHousekeepingScheduler( } catch (error) { logger.error(`[housekeeping] Wakeup purge failed: ${error instanceof Error ? error.message : String(error)}`); } + + try { + // agent_message_projections accumulates one row per (inbox + // message, session) forever. Once a session has rotated away + // — no agent references it as its current session_id — its + // projection rows only consume disk. Keep a 24h safety margin + // so an active rotation window cannot strip the ledger out + // from under a live run. + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const purgedProjections = db.inboxProjections.purgeOrphanProjections(cutoff); + if (purgedProjections > 0) { + logger.info(`[housekeeping] Purged ${purgedProjections} orphan inbox projection ledger row(s)`); + } + } catch (error) { + logger.error(`[housekeeping] Inbox projection purge failed: ${error instanceof Error ? error.message : String(error)}`); + } } catch (error) { logger.error(`[housekeeping] Scheduler error: ${error instanceof Error ? error.message : String(error)}`); }