import type { ChildProcess } from 'child_process'; import { logger } from './logger.js'; export interface QueuedTask { id: string; groupJid: string; fn: () => Promise; } export interface GroupRunContext { runId: string; reason: 'messages' | 'drain'; } export type RunPhase = | 'idle' | 'running_messages' | 'running_task' | 'closing_messages'; const VALID_TRANSITIONS: Record = { idle: ['running_messages', 'running_task'], running_messages: ['closing_messages', 'idle'], closing_messages: ['idle'], running_task: ['idle'], }; export interface GroupState { runPhase: RunPhase; runningTaskId: string | null; currentRunId: string | null; lastCloseRequest: { runId: string | null; reason: string | null } | null; directTerminalDeliveries: Map; recentDirectTerminalDeliveries: Map>; pendingMessages: boolean; pendingTasks: QueuedTask[]; process: ChildProcess | null; processName: string | null; ipcDir: string | null; retryCount: number; retryTimer: ReturnType | null; retryScheduledAt: number | null; postCloseTermTimer: ReturnType | null; postCloseKillTimer: ReturnType | null; startedAt: number | null; } const MAX_RECORDED_DIRECT_TERMINAL_RUNS = 16; export function createGroupState(): GroupState { return { runPhase: 'idle', runningTaskId: null, currentRunId: null, lastCloseRequest: null, directTerminalDeliveries: new Map(), recentDirectTerminalDeliveries: new Map(), pendingMessages: false, pendingTasks: [], process: null, processName: null, ipcDir: null, retryCount: 0, retryTimer: null, retryScheduledAt: null, postCloseTermTimer: null, postCloseKillTimer: null, startedAt: null, }; } export function recordRecentDirectTerminalDelivery( state: GroupState, runId: string, senderRole: string, text: string, ): void { const existing = state.recentDirectTerminalDeliveries.get(runId) ?? new Map(); existing.set(senderRole, text); state.recentDirectTerminalDeliveries.delete(runId); state.recentDirectTerminalDeliveries.set(runId, existing); while ( state.recentDirectTerminalDeliveries.size > MAX_RECORDED_DIRECT_TERMINAL_RUNS ) { const oldestRunId = state.recentDirectTerminalDeliveries.keys().next().value ?? null; if (!oldestRunId) { break; } state.recentDirectTerminalDeliveries.delete(oldestRunId); } } export function transitionRunPhase( state: GroupState, groupJid: string, nextPhase: RunPhase, metadata?: { reason?: string; runId?: string | null; taskId?: string | null; }, ): void { const fromPhase = state.runPhase; if (fromPhase === nextPhase) return; const validNextPhases = VALID_TRANSITIONS[fromPhase]; if (!validNextPhases.includes(nextPhase)) { logger.error( { groupJid, fromPhase, toPhase: nextPhase, validNextPhases, reason: metadata?.reason, runId: metadata?.runId, taskId: metadata?.taskId, }, 'Invalid group run phase transition', ); } state.runPhase = nextPhase; logger.info( { groupJid, fromPhase, toPhase: nextPhase, transition: `${fromPhase} → ${nextPhase}`, reason: metadata?.reason, runId: metadata?.runId, taskId: metadata?.taskId, }, 'Group run phase changed', ); } export function resetRunState(state: GroupState, groupJid: string): void { state.currentRunId = null; state.runningTaskId = null; state.lastCloseRequest = null; state.startedAt = null; state.process = null; state.processName = null; state.ipcDir = null; state.directTerminalDeliveries.clear(); transitionRunPhase(state, groupJid, 'idle'); } export function assertRunPhaseInvariants( state: GroupState, groupJid: string, ): void { switch (state.runPhase) { case 'idle': if ( state.currentRunId != null || state.runningTaskId != null || state.process != null || state.processName != null ) { logger.error( { groupJid, runPhase: state.runPhase, currentRunId: state.currentRunId, runningTaskId: state.runningTaskId, hasProcess: state.process != null, processName: state.processName, }, 'Invariant violation: idle phase has stale run/task ID or process', ); } break; case 'running_messages': case 'closing_messages': if (state.currentRunId == null || state.runningTaskId != null) { logger.error( { groupJid, runPhase: state.runPhase, currentRunId: state.currentRunId, runningTaskId: state.runningTaskId, }, 'Invariant violation: messages phase has missing runId or stale taskId', ); } break; case 'running_task': if (state.runningTaskId == null || state.currentRunId != null) { logger.error( { groupJid, runPhase: state.runPhase, runningTaskId: state.runningTaskId, currentRunId: state.currentRunId, }, 'Invariant violation: task phase has no taskId or has stale currentRunId', ); } break; } }