From 77c2d031bcf6c6f477eb0b83a20bbda3c18f5dbb Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Thu, 21 May 2026 04:54:00 +0900 Subject: [PATCH] Recover only active groups after restart Limit restart interruption snapshots, announcements, and recovery queueing to groups that were actively processing. Waiting groups remain compatible in stored context but no longer receive restart recovery signals. --- src/index.ts | 9 ++++-- src/restart-context.test.ts | 57 +++++++++++++++++++++++++++++-------- src/restart-context.ts | 30 +++++++++++++++---- 3 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1a74e42..a0ae2bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { buildRestartAnnouncement, buildInterruptedRestartAnnouncement, consumeRestartContext, + getRecoverableInterruptedGroups, getInterruptedRecoveryCandidates, inferRecentRestartContext, type RestartContext, @@ -196,7 +197,9 @@ async function announceRestartRecovery( 'Sent explicit restart recovery announcement', ); - for (const interrupted of explicitContext.interruptedGroups ?? []) { + for (const interrupted of getRecoverableInterruptedGroups( + explicitContext, + )) { if (interrupted.chatJid === explicitContext.chatJid) continue; if (hasRecentRestartAnnouncement(interrupted.chatJid, dedupeSince)) { continue; @@ -265,8 +268,8 @@ async function main(): Promise { ( status, ): status is typeof status & { - status: 'processing' | 'waiting'; - } => status.status !== 'inactive', + status: 'processing'; + } => status.status === 'processing', ) .map((status) => ({ chatJid: status.jid, diff --git a/src/restart-context.test.ts b/src/restart-context.test.ts index 2337f44..cfbde03 100644 --- a/src/restart-context.test.ts +++ b/src/restart-context.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + buildRestartAnnouncement, + getRecoverableInterruptedGroups, getInterruptedRecoveryCandidates, type RestartContext, } from './restart-context.js'; @@ -16,8 +18,8 @@ function makeGroup(folder: string): RegisteredGroup { }; } -describe('getInterruptedRecoveryCandidates', () => { - it('returns only registered interrupted groups and deduplicates by chatJid', () => { +describe('restart recovery context', () => { + it('returns only registered processing groups and deduplicates by chatJid', () => { const roomBindings: Record = { 'dc:1': makeGroup('group-one'), 'dc:2': makeGroup('group-two'), @@ -38,8 +40,8 @@ describe('getInterruptedRecoveryCandidates', () => { pendingTasks: 0, }, { - chatJid: 'dc:1', - groupName: 'one-duplicate', + chatJid: 'dc:2', + groupName: 'two-waiting', status: 'waiting', elapsedMs: null, pendingMessages: false, @@ -47,7 +49,7 @@ describe('getInterruptedRecoveryCandidates', () => { }, { chatJid: 'dc:2', - groupName: 'two', + groupName: 'two-idle', status: 'idle', elapsedMs: null, pendingMessages: false, @@ -61,6 +63,14 @@ describe('getInterruptedRecoveryCandidates', () => { pendingMessages: true, pendingTasks: 0, }, + { + chatJid: 'dc:1', + groupName: 'one-duplicate', + status: 'processing', + elapsedMs: 2000, + pendingMessages: false, + pendingTasks: 1, + }, ], }; @@ -72,17 +82,40 @@ describe('getInterruptedRecoveryCandidates', () => { pendingMessages: true, pendingTasks: 0, }, - { - chatJid: 'dc:2', - groupFolder: 'group-two', - status: 'idle', - pendingMessages: false, - pendingTasks: 0, - }, ]); }); it('returns empty when there is no explicit restart context', () => { expect(getInterruptedRecoveryCandidates(null, {})).toEqual([]); }); + + it('uses only processing groups for restart announcements', () => { + const context: RestartContext = { + chatJid: 'dc:main', + summary: 'restart', + verify: [], + writtenAt: '2026-05-21T00:00:00.000Z', + interruptedGroups: [ + { + chatJid: 'dc:processing', + groupName: 'processing', + status: 'processing', + elapsedMs: 1000, + pendingMessages: false, + pendingTasks: 0, + }, + { + chatJid: 'dc:waiting', + groupName: 'waiting', + status: 'waiting', + elapsedMs: null, + pendingMessages: true, + pendingTasks: 1, + }, + ], + }; + + expect(getRecoverableInterruptedGroups(context)).toHaveLength(1); + expect(buildRestartAnnouncement(context)).toContain('중단 작업 감지: 1개'); + }); }); diff --git a/src/restart-context.ts b/src/restart-context.ts index f2aaedc..b008d2c 100644 --- a/src/restart-context.ts +++ b/src/restart-context.ts @@ -72,6 +72,20 @@ function readRestartContextFile(filePath: string): RestartContext | null { return data; } +function isRecoverableInterruptedGroup( + group: RestartInterruptedGroup, +): boolean { + return group.status === 'processing'; +} + +export function getRecoverableInterruptedGroups( + context: Pick | null, +): RestartInterruptedGroup[] { + return (context?.interruptedGroups ?? []).filter( + isRecoverableInterruptedGroup, + ); +} + function getLatestDistBuildTime(): number | null { const distDir = path.join(process.cwd(), 'dist'); if (!fs.existsSync(distDir)) return null; @@ -145,7 +159,10 @@ export function writeShutdownRestartContext( signal: string, serviceIds: string[] = [SERVICE_ID], ): string[] { - if (interruptedGroups.length === 0) return []; + const recoverableInterruptedGroups = interruptedGroups.filter( + isRecoverableInterruptedGroup, + ); + if (recoverableInterruptedGroups.length === 0) return []; fs.mkdirSync(DATA_DIR, { recursive: true }); const mainChatJid = @@ -156,8 +173,8 @@ export function writeShutdownRestartContext( const filePath = getRestartContextPath(serviceId); const existing = readRestartContextFile(filePath); const mergedInterrupted = [ - ...(existing?.interruptedGroups ?? []), - ...interruptedGroups, + ...getRecoverableInterruptedGroups(existing), + ...recoverableInterruptedGroups, ].filter( (group, index, all) => all.findIndex((candidate) => candidate.chatJid === group.chatJid) === @@ -221,8 +238,9 @@ export function consumeRestartContext(): RestartContext | null { export function buildRestartAnnouncement(context: RestartContext): string { const lines = ['재시작 완료.', `- 변경: ${context.summary}`]; - if (context.interruptedGroups && context.interruptedGroups.length > 0) { - lines.push(`- 중단 작업 감지: ${context.interruptedGroups.length}개`); + const recoverableInterruptedGroups = getRecoverableInterruptedGroups(context); + if (recoverableInterruptedGroups.length > 0) { + lines.push(`- 중단 작업 감지: ${recoverableInterruptedGroups.length}개`); } if (context.verify.length > 0) { lines.push(`- 검증: ${context.verify.join(', ')}`); @@ -240,7 +258,7 @@ export function getInterruptedRecoveryCandidates( const seen = new Set(); const candidates: RestartRecoveryCandidate[] = []; - for (const interrupted of context.interruptedGroups) { + for (const interrupted of getRecoverableInterruptedGroups(context)) { if (seen.has(interrupted.chatJid)) continue; const group = roomBindings[interrupted.chatJid]; if (!group) continue;