207 lines
5.3 KiB
TypeScript
207 lines
5.3 KiB
TypeScript
import type { ChildProcess } from 'child_process';
|
|
|
|
import { logger } from './logger.js';
|
|
|
|
export interface QueuedTask {
|
|
id: string;
|
|
groupJid: string;
|
|
fn: () => Promise<void>;
|
|
}
|
|
|
|
export interface GroupRunContext {
|
|
runId: string;
|
|
reason: 'messages' | 'drain';
|
|
}
|
|
|
|
export type RunPhase =
|
|
| 'idle'
|
|
| 'running_messages'
|
|
| 'running_task'
|
|
| 'closing_messages';
|
|
|
|
const VALID_TRANSITIONS: Record<RunPhase, readonly RunPhase[]> = {
|
|
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<string, string>;
|
|
recentDirectTerminalDeliveries: Map<string, Map<string, string>>;
|
|
pendingMessages: boolean;
|
|
pendingTasks: QueuedTask[];
|
|
process: ChildProcess | null;
|
|
processName: string | null;
|
|
ipcDir: string | null;
|
|
retryCount: number;
|
|
retryTimer: ReturnType<typeof setTimeout> | null;
|
|
retryScheduledAt: number | null;
|
|
postCloseTermTimer: ReturnType<typeof setTimeout> | null;
|
|
postCloseKillTimer: ReturnType<typeof setTimeout> | 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;
|
|
}
|
|
}
|