add one-shot reviewer mode for single rooms

This commit is contained in:
Codex
2026-05-27 10:57:57 +09:00
parent 1509108e04
commit 51512c9aeb
4 changed files with 246 additions and 0 deletions

View File

@@ -30,6 +30,10 @@ bun -e "import { initDatabase, setExplicitRoomMode } from './src/db.js'; initDat
The reverse phrase ("paired 켜자", "리뷰어 다시 켜자") flips it back to `'tribunal'`. Acknowledge the change in chat and confirm the new mode. The reverse phrase ("paired 켜자", "리뷰어 다시 켜자") flips it back to `'tribunal'`. Acknowledge the change in chat and confirm the new mode.
## Git backups
- 2026-05-27 10:53 KST: EJClaw 정상 동작 상태를 git commit `1509108` (`backup current stable ejclaw state`)로 백업했다. "이번 요청만 리뷰어 사용" 기능 작업은 이 백업 커밋 이후에 시작한 변경이다.
--- ---
# CLAUDE.md # CLAUDE.md

View File

@@ -45,6 +45,10 @@ import {
enqueuePairedFollowUpAfterEvent, enqueuePairedFollowUpAfterEvent,
schedulePairedFollowUpWithMessageCheck, schedulePairedFollowUpWithMessageCheck,
} from './message-runtime-follow-up.js'; } from './message-runtime-follow-up.js';
import {
clearOneShotReviewLeaseIfIdle,
maybeActivateOneShotReviewLease,
} from './one-shot-review.js';
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js'; import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import { transitionPairedTaskStatus } from './paired-execution-context-shared.js'; import { transitionPairedTaskStatus } from './paired-execution-context-shared.js';
import { 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', : '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', 'Paired room reviewer/arbiter channel resolution',
); );
} }
clearOneShotReviewLeaseIfIdle(chatJid);
// Delivery retries can come from forced fallback runs whose agent_type // Delivery retries can come from forced fallback runs whose agent_type
// differs from the room owner's registered agent type. // differs from the room owner's registered agent type.
@@ -649,6 +655,12 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return gateResult.success; return gateResult.success;
} }
maybeActivateOneShotReviewLease({
chatJid,
group,
messages: missedMessages,
});
return runQueuedGroupTurn({ return runQueuedGroupTurn({
chatJid, chatJid,
group, group,

113
src/one-shot-review.test.ts Normal file
View 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
View 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;
}