add one-shot reviewer mode for single rooms
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
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