Files
EJClaw/src/message-runtime-follow-up.test.ts
Eyejoker fbaca3ab68 Retry silent owner capacity failures
Classify Codex selected-model capacity as overloaded and requeue silent owner failures that leave paired tasks active.
2026-05-21 03:46:59 +09:00

274 lines
7.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => ({
getPairedTurnOutputs: vi.fn(() => []),
reservePairedTurnReservation: vi.fn(() => true),
claimPairedTurnReservation: vi.fn(() => true),
_clearPairedTurnReservationsForTests: vi.fn(),
}));
import { getPairedTurnOutputs } from './db.js';
import {
enqueuePairedFollowUpAfterEvent,
dispatchPairedFollowUpForEvent,
requeuePendingPairedTurn,
schedulePairedFollowUpIntent,
schedulePairedFollowUpWithMessageCheck,
} from './message-runtime-follow-up.js';
import { resetPairedFollowUpScheduleState } from './paired-follow-up-scheduler.js';
describe('message-runtime-follow-up', () => {
beforeEach(() => {
vi.resetAllMocks();
resetPairedFollowUpScheduleState();
vi.mocked(getPairedTurnOutputs).mockReturnValue([]);
});
it('suppresses stale reviewer follow-ups when the latest persisted turn already belongs to the reviewer', () => {
const enqueue = vi.fn();
const task = {
id: 'task-stale-reviewer',
status: 'review_ready',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
} as const;
vi.mocked(getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: task.id,
turn_number: 1,
role: 'reviewer',
output_text: 'reviewer 승인',
created_at: '2026-03-30T00:00:01.000Z',
},
] as any);
const scheduled = schedulePairedFollowUpIntent({
chatJid: 'group@test',
runId: 'run-stale-reviewer',
task,
intentKind: 'reviewer-turn',
enqueue,
});
expect(scheduled).toBe(false);
expect(enqueue).not.toHaveBeenCalled();
});
it('uses the fallback delivery role to schedule owner follow-ups when no persisted turn output exists yet', () => {
const enqueue = vi.fn();
const result = dispatchPairedFollowUpForEvent({
chatJid: 'group@test',
runId: 'run-reviewer-delivery',
task: {
id: 'task-owner-follow-up',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
source: 'delivery-success',
completedRole: 'reviewer',
fallbackLastTurnOutputRole: 'reviewer',
enqueue,
});
expect(result).toMatchObject({
kind: 'paired-follow-up',
intentKind: 'owner-follow-up',
scheduled: true,
taskStatus: 'active',
lastTurnOutputRole: 'reviewer',
});
expect(enqueue).toHaveBeenCalledTimes(1);
});
it('does not use fallback owner STEP_DONE verdict to schedule owner follow-ups while active', () => {
const enqueue = vi.fn();
const result = dispatchPairedFollowUpForEvent({
chatJid: 'group@test',
runId: 'run-owner-step-done',
task: {
id: 'task-owner-step-done',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
source: 'owner-delivery-success',
completedRole: 'owner',
fallbackLastTurnOutputRole: 'owner',
fallbackLastTurnOutputVerdict: 'step_done',
enqueue,
});
expect(result).toMatchObject({
kind: 'none',
taskStatus: 'active',
lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done',
});
expect(enqueue).not.toHaveBeenCalled();
});
it('prefers the latest persisted turn output over the fallback delivery role when both are present', () => {
const enqueue = vi.fn();
vi.mocked(getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: 'task-owner-follow-up-persisted',
turn_number: 1,
role: 'owner',
output_text: 'owner 응답',
created_at: '2026-03-30T00:00:01.000Z',
},
] as any);
const result = dispatchPairedFollowUpForEvent({
chatJid: 'group@test',
runId: 'run-reviewer-delivery-persisted',
task: {
id: 'task-owner-follow-up-persisted',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
source: 'delivery-success',
completedRole: 'reviewer',
fallbackLastTurnOutputRole: 'reviewer',
enqueue,
});
expect(result).toMatchObject({
kind: 'none',
taskStatus: 'active',
lastTurnOutputRole: 'owner',
nextTurnAction: { kind: 'none' },
});
expect(enqueue).not.toHaveBeenCalled();
});
it('routes delivery retry with no remaining paired handoff to a message check', () => {
const enqueue = vi.fn();
const enqueueMessageCheck = vi.fn();
const result = dispatchPairedFollowUpForEvent({
chatJid: 'group@test',
runId: 'run-delivery-retry',
task: {
id: 'task-retry-message-check',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
source: 'delivery-retry',
completedRole: 'owner',
fallbackLastTurnOutputRole: 'owner',
enqueue,
enqueueMessageCheck,
});
expect(result).toMatchObject({
kind: 'message-check',
taskStatus: 'active',
nextTurnAction: { kind: 'none' },
});
expect(enqueue).not.toHaveBeenCalled();
expect(enqueueMessageCheck).toHaveBeenCalledTimes(1);
});
it('requeues a silent failed owner execution that leaves the task active', () => {
const enqueue = vi.fn();
const result = dispatchPairedFollowUpForEvent({
chatJid: 'group@test',
runId: 'run-silent-owner-retry',
task: {
id: 'task-silent-owner-retry',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
source: 'executor-recovery',
completedRole: 'owner',
executionStatus: 'failed',
sawOutput: false,
enqueue,
});
expect(result).toMatchObject({
kind: 'paired-follow-up',
intentKind: 'owner-follow-up',
scheduled: true,
taskStatus: 'active',
lastTurnOutputRole: null,
});
expect(enqueue).toHaveBeenCalledTimes(1);
});
it('uses the scoped message-check enqueuer when scheduling a paired follow-up intent', () => {
const enqueueMessageCheck = vi.fn();
const scheduled = schedulePairedFollowUpWithMessageCheck({
chatJid: 'group@test',
runId: 'run-scoped-enqueue',
task: {
id: 'task-scoped-enqueue',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
intentKind: 'owner-follow-up',
enqueueMessageCheck,
fallbackLastTurnOutputRole: 'reviewer',
});
expect(scheduled).toBe(true);
expect(enqueueMessageCheck).toHaveBeenCalledTimes(1);
});
it('closes stdin only when a pending paired turn requeue was actually scheduled', () => {
const schedulePairedFollowUp = vi
.fn<() => boolean>()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false);
const closeStdin = vi.fn();
const first = requeuePendingPairedTurn({
schedulePairedFollowUp,
closeStdin,
});
const second = requeuePendingPairedTurn({
schedulePairedFollowUp,
closeStdin,
});
expect(first).toBe(true);
expect(second).toBe(false);
expect(schedulePairedFollowUp).toHaveBeenCalledTimes(2);
expect(closeStdin).toHaveBeenCalledTimes(1);
});
it('uses the same message-check side effect for delivery follow-up dispatch', () => {
const enqueueMessageCheck = vi.fn();
const result = enqueuePairedFollowUpAfterEvent({
chatJid: 'group@test',
runId: 'run-delivery-follow-up-helper',
task: {
id: 'task-delivery-follow-up-helper',
status: 'active',
round_trip_count: 1,
updated_at: '2026-03-30T00:00:00.000Z',
},
source: 'delivery-success',
completedRole: 'reviewer',
fallbackLastTurnOutputRole: 'reviewer',
enqueueMessageCheck,
});
expect(result).toMatchObject({
kind: 'paired-follow-up',
intentKind: 'owner-follow-up',
scheduled: true,
});
expect(enqueueMessageCheck).toHaveBeenCalledTimes(1);
});
});