add one-shot reviewer mode for single rooms
This commit is contained in:
@@ -45,6 +45,10 @@ import {
|
||||
enqueuePairedFollowUpAfterEvent,
|
||||
schedulePairedFollowUpWithMessageCheck,
|
||||
} from './message-runtime-follow-up.js';
|
||||
import {
|
||||
clearOneShotReviewLeaseIfIdle,
|
||||
maybeActivateOneShotReviewLease,
|
||||
} from './one-shot-review.js';
|
||||
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
|
||||
import { transitionPairedTaskStatus } from './paired-execution-context-shared.js';
|
||||
import {
|
||||
@@ -338,6 +342,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
: 'Skipped duplicate paired follow-up after successful reviewer/arbiter delivery while task state was unchanged',
|
||||
);
|
||||
}
|
||||
clearOneShotReviewLeaseIfIdle(chatJid);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -416,6 +421,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
'Paired room reviewer/arbiter channel resolution',
|
||||
);
|
||||
}
|
||||
clearOneShotReviewLeaseIfIdle(chatJid);
|
||||
|
||||
// Delivery retries can come from forced fallback runs whose agent_type
|
||||
// differs from the room owner's registered agent type.
|
||||
@@ -649,6 +655,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
||||
return gateResult.success;
|
||||
}
|
||||
|
||||
maybeActivateOneShotReviewLease({
|
||||
chatJid,
|
||||
group,
|
||||
messages: missedMessages,
|
||||
});
|
||||
|
||||
return runQueuedGroupTurn({
|
||||
chatJid,
|
||||
group,
|
||||
|
||||
113
src/one-shot-review.test.ts
Normal file
113
src/one-shot-review.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
_initTestDatabase,
|
||||
assignRoom,
|
||||
getChannelOwnerLease,
|
||||
getEffectiveRuntimeRoomMode,
|
||||
} from './db.js';
|
||||
import {
|
||||
clearOneShotReviewLeaseIfIdle,
|
||||
isOneShotReviewRequest,
|
||||
maybeActivateOneShotReviewLease,
|
||||
ONE_SHOT_REVIEW_LEASE_REASON,
|
||||
} from './one-shot-review.js';
|
||||
import {
|
||||
hasReviewerLease,
|
||||
refreshChannelOwnerCache,
|
||||
} from './service-routing.js';
|
||||
import type { NewMessage } from './types.js';
|
||||
|
||||
function humanMessage(content: string): NewMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
chat_jid: 'dc:single-room',
|
||||
sender: 'user-1',
|
||||
sender_name: 'user',
|
||||
content,
|
||||
timestamp: '2026-05-27T01:50:00.000Z',
|
||||
seq: 1,
|
||||
is_bot_message: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('one-shot review', () => {
|
||||
beforeEach(() => {
|
||||
_initTestDatabase();
|
||||
refreshChannelOwnerCache(true);
|
||||
});
|
||||
|
||||
it('detects explicit reviewer requests in user text', () => {
|
||||
expect(isOneShotReviewRequest('이번 요청만 리뷰어 사용해서 검증해줘')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isOneShotReviewRequest('리뷰어로 확인해줘')).toBe(true);
|
||||
expect(isOneShotReviewRequest('그냥 테스트만 돌려줘')).toBe(false);
|
||||
});
|
||||
|
||||
it('temporarily enables a reviewer lease without changing single room mode', () => {
|
||||
const group = assignRoom('dc:single-room', {
|
||||
name: 'single room',
|
||||
folder: 'single_room',
|
||||
roomMode: 'single',
|
||||
ownerAgentType: 'claude-code',
|
||||
workDir: '/tmp/ejclaw-single-room',
|
||||
});
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(getEffectiveRuntimeRoomMode('dc:single-room')).toBe('single');
|
||||
expect(hasReviewerLease('dc:single-room')).toBe(false);
|
||||
|
||||
const activated = maybeActivateOneShotReviewLease({
|
||||
chatJid: 'dc:single-room',
|
||||
group: group!,
|
||||
messages: [humanMessage('이번 요청만 리뷰어 사용해서 검증해줘')],
|
||||
});
|
||||
|
||||
expect(activated).toBe(true);
|
||||
expect(getEffectiveRuntimeRoomMode('dc:single-room')).toBe('single');
|
||||
expect(getChannelOwnerLease('dc:single-room')).toMatchObject({
|
||||
reason: ONE_SHOT_REVIEW_LEASE_REASON,
|
||||
owner_agent_type: 'claude-code',
|
||||
});
|
||||
expect(hasReviewerLease('dc:single-room')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not enable review for ordinary single-mode messages', () => {
|
||||
const group = assignRoom('dc:single-room', {
|
||||
name: 'single room',
|
||||
folder: 'single_room',
|
||||
roomMode: 'single',
|
||||
ownerAgentType: 'claude-code',
|
||||
workDir: '/tmp/ejclaw-single-room',
|
||||
});
|
||||
|
||||
expect(
|
||||
maybeActivateOneShotReviewLease({
|
||||
chatJid: 'dc:single-room',
|
||||
group: group!,
|
||||
messages: [humanMessage('그냥 구현해줘')],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(getChannelOwnerLease('dc:single-room')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears an idle one-shot reviewer lease', () => {
|
||||
const group = assignRoom('dc:single-room', {
|
||||
name: 'single room',
|
||||
folder: 'single_room',
|
||||
roomMode: 'single',
|
||||
ownerAgentType: 'claude-code',
|
||||
workDir: '/tmp/ejclaw-single-room',
|
||||
});
|
||||
maybeActivateOneShotReviewLease({
|
||||
chatJid: 'dc:single-room',
|
||||
group: group!,
|
||||
messages: [humanMessage('리뷰어로 확인해줘')],
|
||||
});
|
||||
|
||||
expect(clearOneShotReviewLeaseIfIdle('dc:single-room')).toBe(true);
|
||||
expect(getChannelOwnerLease('dc:single-room')).toBeUndefined();
|
||||
expect(hasReviewerLease('dc:single-room')).toBe(false);
|
||||
});
|
||||
});
|
||||
117
src/one-shot-review.ts
Normal file
117
src/one-shot-review.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
ARBITER_AGENT_TYPE,
|
||||
REVIEWER_AGENT_TYPE,
|
||||
SERVICE_ID,
|
||||
} from './config.js';
|
||||
import {
|
||||
clearChannelOwnerLease,
|
||||
getChannelOwnerLease,
|
||||
getEffectiveRuntimeRoomMode,
|
||||
getLatestOpenPairedTaskForChat,
|
||||
getStoredRoomRoleAgentPlan,
|
||||
setChannelOwnerLease,
|
||||
} from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import { resolveRoleAgentPlan } from './role-agent-plan.js';
|
||||
import { resolveRoleServiceShadow } from './role-service-shadow.js';
|
||||
import {
|
||||
getEffectiveChannelLease,
|
||||
hasReviewerLease,
|
||||
refreshChannelOwnerCache,
|
||||
resolveLeaseServiceId,
|
||||
} from './service-routing.js';
|
||||
import type { NewMessage, RegisteredGroup } from './types.js';
|
||||
|
||||
export const ONE_SHOT_REVIEW_LEASE_REASON = 'one-shot-review';
|
||||
|
||||
const REVIEW_ONCE_PATTERNS = [
|
||||
/(이번\s*요청만|이번만|한\s*번만|임시로|필요할\s*때만).{0,30}(리뷰어|reviewer).{0,30}(사용|켜|돌려|붙여|검증|확인|리뷰)/i,
|
||||
/(리뷰어|reviewer).{0,20}(사용해서|사용해|켜서|돌려서|붙여서).{0,40}(검증|확인|리뷰|체크|봐줘|봐)/i,
|
||||
/(리뷰어|reviewer).{0,30}(검증해줘|확인해줘|리뷰해줘|체크해줘|봐줘|돌려줘)/i,
|
||||
/(검증|확인|리뷰|체크).{0,30}(리뷰어|reviewer).{0,30}(사용|켜|돌려|붙여)/i,
|
||||
];
|
||||
|
||||
export function isOneShotReviewRequest(content: string): boolean {
|
||||
const normalized = content.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) return false;
|
||||
return REVIEW_ONCE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function hasHumanOneShotReviewRequest(messages: NewMessage[]): boolean {
|
||||
return messages.some(
|
||||
(message) =>
|
||||
message.is_from_me !== true &&
|
||||
!message.is_bot_message &&
|
||||
isOneShotReviewRequest(message.content),
|
||||
);
|
||||
}
|
||||
|
||||
export function maybeActivateOneShotReviewLease(args: {
|
||||
chatJid: string;
|
||||
group: RegisteredGroup;
|
||||
messages: NewMessage[];
|
||||
}): boolean {
|
||||
if (!args.group.workDir) return false;
|
||||
if (getEffectiveRuntimeRoomMode(args.chatJid) !== 'single') return false;
|
||||
if (hasReviewerLease(args.chatJid)) return false;
|
||||
if (!hasHumanOneShotReviewRequest(args.messages)) return false;
|
||||
|
||||
const lease = getEffectiveChannelLease(args.chatJid);
|
||||
const storedPlan = getStoredRoomRoleAgentPlan(args.chatJid);
|
||||
const rolePlan = resolveRoleAgentPlan({
|
||||
paired: true,
|
||||
groupAgentType:
|
||||
lease.owner_agent_type ??
|
||||
storedPlan?.ownerAgentType ??
|
||||
args.group.agentType,
|
||||
configuredReviewer: storedPlan?.reviewerAgentType ?? REVIEWER_AGENT_TYPE,
|
||||
configuredArbiter: storedPlan?.arbiterAgentType ?? ARBITER_AGENT_TYPE,
|
||||
});
|
||||
const ownerServiceId =
|
||||
resolveLeaseServiceId(lease, 'owner') ??
|
||||
resolveRoleServiceShadow('owner', rolePlan.ownerAgentType) ??
|
||||
SERVICE_ID;
|
||||
const reviewerServiceId = resolveRoleServiceShadow(
|
||||
'reviewer',
|
||||
rolePlan.reviewerAgentType,
|
||||
);
|
||||
if (!reviewerServiceId || !rolePlan.reviewerAgentType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setChannelOwnerLease({
|
||||
chat_jid: args.chatJid,
|
||||
owner_service_id: ownerServiceId,
|
||||
reviewer_service_id: reviewerServiceId,
|
||||
arbiter_service_id: resolveRoleServiceShadow(
|
||||
'arbiter',
|
||||
rolePlan.arbiterAgentType,
|
||||
),
|
||||
owner_agent_type: rolePlan.ownerAgentType,
|
||||
reviewer_agent_type: rolePlan.reviewerAgentType,
|
||||
arbiter_agent_type: rolePlan.arbiterAgentType,
|
||||
reason: ONE_SHOT_REVIEW_LEASE_REASON,
|
||||
});
|
||||
refreshChannelOwnerCache(true);
|
||||
logger.info(
|
||||
{
|
||||
chatJid: args.chatJid,
|
||||
groupFolder: args.group.folder,
|
||||
ownerAgentType: rolePlan.ownerAgentType,
|
||||
reviewerAgentType: rolePlan.reviewerAgentType,
|
||||
},
|
||||
'Activated one-shot reviewer lease for a single-mode room',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearOneShotReviewLeaseIfIdle(chatJid: string): boolean {
|
||||
const lease = getChannelOwnerLease(chatJid);
|
||||
if (lease?.reason !== ONE_SHOT_REVIEW_LEASE_REASON) return false;
|
||||
if (getLatestOpenPairedTaskForChat(chatJid)) return false;
|
||||
|
||||
clearChannelOwnerLease(chatJid);
|
||||
refreshChannelOwnerCache(true);
|
||||
logger.info({ chatJid }, 'Cleared idle one-shot reviewer lease');
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user