From 51512c9aebe9ed761ddb45cf600244f8b43b76cf Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 10:57:57 +0900 Subject: [PATCH] add one-shot reviewer mode for single rooms --- groups/global/CLAUDE.md | 4 ++ src/message-runtime.ts | 12 ++++ src/one-shot-review.test.ts | 113 ++++++++++++++++++++++++++++++++++ src/one-shot-review.ts | 117 ++++++++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 src/one-shot-review.test.ts create mode 100644 src/one-shot-review.ts diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index c382926..eebf399 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -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. +## Git backups + +- 2026-05-27 10:53 KST: EJClaw 정상 동작 상태를 git commit `1509108` (`backup current stable ejclaw state`)로 백업했다. "이번 요청만 리뷰어 사용" 기능 작업은 이 백업 커밋 이후에 시작한 변경이다. + --- # CLAUDE.md diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 9f241ab..6879936 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -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, diff --git a/src/one-shot-review.test.ts b/src/one-shot-review.test.ts new file mode 100644 index 0000000..53990c3 --- /dev/null +++ b/src/one-shot-review.test.ts @@ -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); + }); +}); diff --git a/src/one-shot-review.ts b/src/one-shot-review.ts new file mode 100644 index 0000000..f01e3b7 --- /dev/null +++ b/src/one-shot-review.ts @@ -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; +}