refactor: replace boolean trio with explicit RunPhase state machine in GroupQueue

Replace `active/closingStdin/isTaskProcess` booleans with a single `runPhase`
enum (idle | running_messages | running_task | closing_messages). Add
`resetRunState()` to eliminate 7-field reset duplication, and
`assertRunPhaseInvariants()` for runtime validation of field consistency
after every phase transition. Add 3 transition tests covering the full
lifecycle, closeStdin during tasks, and sendMessage phase gating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-25 12:55:37 +09:00
parent 61786cacbf
commit f62aa261e6
2 changed files with 225 additions and 49 deletions

View File

@@ -581,4 +581,109 @@ describe('GroupQueue', () => {
resolveProcess!(); resolveProcess!();
await vi.advanceTimersByTimeAsync(10); await vi.advanceTimersByTimeAsync(10);
}); });
// --- Run phase transitions ---
it('transitions running_messages → closing_messages → idle', async () => {
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
let releaseRun!: (value: boolean) => void;
const blocker = new Promise<boolean>((resolve) => {
releaseRun = resolve;
});
const processMessages = vi.fn(async () => await blocker);
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us', ipcDir);
await vi.advanceTimersByTimeAsync(10);
// While running, phase should be running_messages
const duringRun = queue.getStatuses(['group1@g.us']);
expect(duringRun[0].runPhase).toBe('running_messages');
// closeStdin transitions to closing_messages
queue.closeStdin('group1@g.us');
const afterClose = queue.getStatuses(['group1@g.us']);
expect(afterClose[0].runPhase).toBe('closing_messages');
// sendMessage should fail in closing_messages
expect(queue.sendMessage('group1@g.us', 'test')).toBe(false);
// Complete the run — should go to idle
releaseRun(true);
await vi.advanceTimersByTimeAsync(10);
const afterFinish = queue.getStatuses(['group1@g.us']);
expect(afterFinish[0].runPhase).toBe('idle');
expect(afterFinish[0].status).toBe('inactive');
});
it('closeStdin does not change phase during running_task', async () => {
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
let resolveTask!: () => void;
const blocker = new Promise<void>((resolve) => {
resolveTask = resolve;
});
queue.enqueueTask('group1@g.us', 'task-1', async () => {
// Register process so closeStdin has an ipcDir
queue.registerProcess('group1@g.us', {} as any, 'agent-1', ipcDir);
await blocker;
});
await vi.advanceTimersByTimeAsync(10);
const duringTask = queue.getStatuses(['group1@g.us']);
expect(duringTask[0].runPhase).toBe('running_task');
// closeStdin during task — phase should stay running_task
queue.closeStdin('group1@g.us');
const afterClose = queue.getStatuses(['group1@g.us']);
expect(afterClose[0].runPhase).toBe('running_task');
resolveTask();
await vi.advanceTimersByTimeAsync(10);
const afterFinish = queue.getStatuses(['group1@g.us']);
expect(afterFinish[0].runPhase).toBe('idle');
});
it('sendMessage returns true only in running_messages phase', async () => {
const ipcDir = '/tmp/ejclaw-test-data/ipc/group-folder';
// idle → false
expect(queue.sendMessage('group1@g.us', 'test')).toBe(false);
// running_task → false
let resolveTask!: () => void;
queue.enqueueTask('group1@g.us', 'task-1', async () => {
queue.registerProcess('group1@g.us', {} as any, 'agent-1', ipcDir);
await new Promise<void>((resolve) => {
resolveTask = resolve;
});
});
await vi.advanceTimersByTimeAsync(10);
expect(queue.sendMessage('group1@g.us', 'test')).toBe(false);
resolveTask();
await vi.advanceTimersByTimeAsync(10);
// running_messages → true
let releaseRun!: (value: boolean) => void;
const processMessages = vi.fn(async () => {
return await new Promise<boolean>((resolve) => {
releaseRun = resolve;
});
});
queue.setProcessMessagesFn(processMessages);
queue.enqueueMessageCheck('group1@g.us', ipcDir);
await vi.advanceTimersByTimeAsync(10);
expect(queue.sendMessage('group1@g.us', 'msg')).toBe(true);
// closing_messages → false
queue.closeStdin('group1@g.us');
expect(queue.sendMessage('group1@g.us', 'msg')).toBe(false);
releaseRun(true);
await vi.advanceTimersByTimeAsync(10);
});
}); });

View File

@@ -26,10 +26,19 @@ const MAX_CONCURRENT_TASKS =
const POST_CLOSE_SIGTERM_DELAY_MS = 60_000; const POST_CLOSE_SIGTERM_DELAY_MS = 60_000;
const POST_CLOSE_SIGKILL_DELAY_MS = 75_000; const POST_CLOSE_SIGKILL_DELAY_MS = 75_000;
/**
* Run lifecycle phase — single axis for what the group is currently executing.
* Message retry backoff is tracked separately (retryCount / retryScheduledAt)
* because tasks can run independently of message retry state.
*/
type RunPhase =
| 'idle'
| 'running_messages'
| 'running_task'
| 'closing_messages';
interface GroupState { interface GroupState {
active: boolean; runPhase: RunPhase;
closingStdin: boolean;
isTaskProcess: boolean;
runningTaskId: string | null; runningTaskId: string | null;
currentRunId: string | null; currentRunId: string | null;
pendingMessages: boolean; pendingMessages: boolean;
@@ -45,9 +54,74 @@ interface GroupState {
startedAt: number | null; startedAt: number | null;
} }
/** Reset all run-related fields to idle. Shared by runForGroup and runTask finally blocks. */
function resetRunState(state: GroupState): void {
state.runPhase = 'idle';
state.currentRunId = null;
state.runningTaskId = null;
state.startedAt = null;
state.process = null;
state.processName = null;
state.ipcDir = null;
}
/** Validate that flat fields are consistent with runPhase. Called after every transition. */
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;
}
}
export interface GroupStatus { export interface GroupStatus {
jid: string; jid: string;
status: 'processing' | 'waiting' | 'inactive'; status: 'processing' | 'waiting' | 'inactive';
runPhase: string;
elapsedMs: number | null; elapsedMs: number | null;
pendingMessages: boolean; pendingMessages: boolean;
pendingTasks: number; pendingTasks: number;
@@ -69,9 +143,7 @@ export class GroupQueue {
let state = this.groups.get(groupJid); let state = this.groups.get(groupJid);
if (!state) { if (!state) {
state = { state = {
active: false, runPhase: 'idle',
closingStdin: false,
isTaskProcess: false,
runningTaskId: null, runningTaskId: null,
currentRunId: null, currentRunId: null,
pendingMessages: false, pendingMessages: false,
@@ -138,9 +210,12 @@ export class GroupQueue {
state.ipcDir = ipcDir; state.ipcDir = ipcDir;
} }
if (state.active) { if (state.runPhase !== 'idle') {
state.pendingMessages = true; state.pendingMessages = true;
logger.debug({ groupJid }, 'Agent active, message queued'); logger.debug(
{ groupJid, runPhase: state.runPhase },
'Agent active, message queued',
);
return; return;
} }
@@ -196,9 +271,12 @@ export class GroupQueue {
return; return;
} }
if (state.active) { if (state.runPhase !== 'idle') {
state.pendingTasks.push({ id: taskId, groupJid, fn }); state.pendingTasks.push({ id: taskId, groupJid, fn });
logger.debug({ groupJid, taskId }, 'Agent active, task queued'); logger.debug(
{ groupJid, taskId, runPhase: state.runPhase },
'Agent active, task queued',
);
return; return;
} }
@@ -244,7 +322,7 @@ export class GroupQueue {
runId: state.currentRunId, runId: state.currentRunId,
processName, processName,
ipcDir: state.ipcDir, ipcDir: state.ipcDir,
isTaskProcess: state.isTaskProcess, runPhase: state.runPhase,
}, },
'Registered active process for group', 'Registered active process for group',
); );
@@ -252,20 +330,13 @@ export class GroupQueue {
sendMessage(groupJid: string, text: string): boolean { sendMessage(groupJid: string, text: string): boolean {
const state = this.getGroup(groupJid); const state = this.getGroup(groupJid);
if ( if (state.runPhase !== 'running_messages' || !state.ipcDir) {
!state.active ||
!state.ipcDir ||
state.isTaskProcess ||
state.closingStdin
) {
logger.debug( logger.debug(
{ {
groupJid, groupJid,
runId: state.currentRunId, runId: state.currentRunId,
active: state.active, runPhase: state.runPhase,
ipcDir: state.ipcDir, ipcDir: state.ipcDir,
isTaskProcess: state.isTaskProcess,
closingStdin: state.closingStdin,
}, },
'Cannot pipe follow-up message to active agent', 'Cannot pipe follow-up message to active agent',
); );
@@ -317,7 +388,7 @@ export class GroupQueue {
reason: string, reason: string,
): void { ): void {
const proc = state.process; const proc = state.process;
if (!proc || !runId || state.isTaskProcess) { if (!proc || !runId || state.runPhase === 'running_task') {
return; return;
} }
@@ -392,8 +463,11 @@ export class GroupQueue {
metadata?: { runId?: string; reason?: string }, metadata?: { runId?: string; reason?: string },
): void { ): void {
const state = this.getGroup(groupJid); const state = this.getGroup(groupJid);
if (!state.active || !state.ipcDir) return; if (state.runPhase === 'idle' || !state.ipcDir) return;
state.closingStdin = true; if (state.runPhase === 'running_messages') {
state.runPhase = 'closing_messages';
assertRunPhaseInvariants(state, groupJid);
}
try { try {
writeCloseSentinel(state.ipcDir); writeCloseSentinel(state.ipcDir);
@@ -435,16 +509,21 @@ export class GroupQueue {
): Promise<void> { ): Promise<void> {
const state = this.getGroup(groupJid); const state = this.getGroup(groupJid);
const runId = this.createRunId(); const runId = this.createRunId();
state.active = true; state.runPhase = 'running_messages';
state.closingStdin = false;
state.isTaskProcess = false;
state.currentRunId = runId; state.currentRunId = runId;
state.pendingMessages = false; state.pendingMessages = false;
state.startedAt = Date.now(); state.startedAt = Date.now();
assertRunPhaseInvariants(state, groupJid);
this.activeCount++; this.activeCount++;
logger.info( logger.info(
{ groupJid, runId, reason, activeCount: this.activeCount }, {
groupJid,
runId,
reason,
runPhase: state.runPhase,
activeCount: this.activeCount,
},
'Starting group message run', 'Starting group message run',
); );
@@ -473,6 +552,7 @@ export class GroupQueue {
} finally { } finally {
this.clearPostCloseTimers(state); this.clearPostCloseTimers(state);
const durationMs = state.startedAt ? Date.now() - state.startedAt : null; const durationMs = state.startedAt ? Date.now() - state.startedAt : null;
const fromPhase = state.runPhase;
logger.info( logger.info(
{ {
groupJid, groupJid,
@@ -480,18 +560,14 @@ export class GroupQueue {
reason, reason,
outcome, outcome,
durationMs, durationMs,
transition: `${fromPhase} → idle`,
pendingMessages: state.pendingMessages, pendingMessages: state.pendingMessages,
pendingTasks: state.pendingTasks.length, pendingTasks: state.pendingTasks.length,
}, },
'Finished group message run', 'Finished group message run',
); );
state.active = false; resetRunState(state);
state.startedAt = null; assertRunPhaseInvariants(state, groupJid);
state.closingStdin = false;
state.process = null;
state.processName = null;
state.ipcDir = null;
state.currentRunId = null;
this.activeCount--; this.activeCount--;
this.drainGroup(groupJid); this.drainGroup(groupJid);
} }
@@ -499,19 +575,18 @@ export class GroupQueue {
private async runTask(groupJid: string, task: QueuedTask): Promise<void> { private async runTask(groupJid: string, task: QueuedTask): Promise<void> {
const state = this.getGroup(groupJid); const state = this.getGroup(groupJid);
state.active = true; state.runPhase = 'running_task';
state.closingStdin = false;
state.isTaskProcess = true;
state.runningTaskId = task.id; state.runningTaskId = task.id;
state.startedAt = Date.now(); state.startedAt = Date.now();
assertRunPhaseInvariants(state, groupJid);
this.activeCount++; this.activeCount++;
this.activeTaskCount++; this.activeTaskCount++;
logger.info( logger.info(
{ {
transition: 'queue:task:start',
groupJid, groupJid,
taskId: task.id, taskId: task.id,
runPhase: state.runPhase,
activeCount: this.activeCount, activeCount: this.activeCount,
activeTaskCount: this.activeTaskCount, activeTaskCount: this.activeTaskCount,
}, },
@@ -529,7 +604,7 @@ export class GroupQueue {
const durationMs = state.startedAt ? Date.now() - state.startedAt : null; const durationMs = state.startedAt ? Date.now() - state.startedAt : null;
logger.info( logger.info(
{ {
transition: 'queue:task:finish', transition: 'running_task → idle',
groupJid, groupJid,
taskId: task.id, taskId: task.id,
outcome, outcome,
@@ -539,14 +614,8 @@ export class GroupQueue {
}, },
'Finished queued task', 'Finished queued task',
); );
state.active = false; resetRunState(state);
state.isTaskProcess = false; assertRunPhaseInvariants(state, groupJid);
state.runningTaskId = null;
state.startedAt = null;
state.closingStdin = false;
state.process = null;
state.processName = null;
state.ipcDir = null;
this.activeCount--; this.activeCount--;
this.activeTaskCount--; this.activeTaskCount--;
this.drainGroup(groupJid); this.drainGroup(groupJid);
@@ -686,13 +755,14 @@ export class GroupQueue {
return { return {
jid, jid,
status: 'inactive' as const, status: 'inactive' as const,
runPhase: 'idle',
elapsedMs: null, elapsedMs: null,
pendingMessages: false, pendingMessages: false,
pendingTasks: 0, pendingTasks: 0,
}; };
} }
let status: GroupStatus['status']; let status: GroupStatus['status'];
if (state.active) { if (state.runPhase !== 'idle') {
status = 'processing'; status = 'processing';
} else if ( } else if (
state.pendingMessages || state.pendingMessages ||
@@ -706,6 +776,7 @@ export class GroupQueue {
return { return {
jid, jid,
status, status,
runPhase: state.runPhase,
elapsedMs: state.startedAt ? now - state.startedAt : null, elapsedMs: state.startedAt ? now - state.startedAt : null,
pendingMessages: state.pendingMessages, pendingMessages: state.pendingMessages,
pendingTasks: state.pendingTasks.length, pendingTasks: state.pendingTasks.length,
@@ -758,7 +829,7 @@ export class GroupQueue {
processName: state.processName, processName: state.processName,
}); });
if (state.active && state.ipcDir && !state.closingStdin) { if (state.runPhase === 'running_messages' && state.ipcDir) {
this.closeStdin(groupJid, { reason: 'shutdown' }); this.closeStdin(groupJid, { reason: 'shutdown' });
} }
} }