Route STEP_DONE through reviewer

This commit is contained in:
ejclaw
2026-04-24 12:03:12 +09:00
parent 6d28a4ae19
commit f84dccfa10
8 changed files with 105 additions and 156 deletions

View File

@@ -83,7 +83,7 @@ describe('message-runtime-follow-up', () => {
expect(enqueue).toHaveBeenCalledTimes(1); expect(enqueue).toHaveBeenCalledTimes(1);
}); });
it('uses the fallback STEP_DONE verdict to schedule owner follow-ups when the owner keeps the task active', () => { it('does not use fallback owner STEP_DONE verdict to schedule owner follow-ups while active', () => {
const enqueue = vi.fn(); const enqueue = vi.fn();
const result = dispatchPairedFollowUpForEvent({ const result = dispatchPairedFollowUpForEvent({
chatJid: 'group@test', chatJid: 'group@test',
@@ -102,14 +102,12 @@ describe('message-runtime-follow-up', () => {
}); });
expect(result).toMatchObject({ expect(result).toMatchObject({
kind: 'paired-follow-up', kind: 'none',
intentKind: 'owner-follow-up',
scheduled: true,
taskStatus: 'active', taskStatus: 'active',
lastTurnOutputRole: 'owner', lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done', lastTurnOutputVerdict: 'step_done',
}); });
expect(enqueue).toHaveBeenCalledTimes(1); expect(enqueue).not.toHaveBeenCalled();
}); });
it('prefers the latest persisted turn output over the fallback delivery role when both are present', () => { it('prefers the latest persisted turn output over the fallback delivery role when both are present', () => {

View File

@@ -120,7 +120,7 @@ describe('message-runtime-rules', () => {
lastTurnOutputVerdict: 'step_done', lastTurnOutputVerdict: 'step_done',
intentKind: 'owner-follow-up', intentKind: 'owner-follow-up',
}), }),
).toBe(true); ).toBe(false);
}); });
it('maps active tasks with reviewer output to an owner follow-up', () => { it('maps active tasks with reviewer output to an owner follow-up', () => {
@@ -132,14 +132,14 @@ describe('message-runtime-rules', () => {
).toEqual({ kind: 'owner-follow-up' }); ).toEqual({ kind: 'owner-follow-up' });
}); });
it('maps active tasks with owner STEP_DONE output to an owner follow-up', () => { it('does not map active owner STEP_DONE output to an owner follow-up', () => {
expect( expect(
resolveNextTurnAction({ resolveNextTurnAction({
taskStatus: 'active', taskStatus: 'active',
lastTurnOutputRole: 'owner', lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done', lastTurnOutputVerdict: 'step_done',
}), }),
).toEqual({ kind: 'owner-follow-up' }); ).toEqual({ kind: 'none' });
}); });
it('returns none when an active task has no reviewer or arbiter handoff output', () => { it('returns none when an active task has no reviewer or arbiter handoff output', () => {
@@ -329,7 +329,7 @@ describe('message-runtime-rules', () => {
lastTurnOutputRole: 'owner', lastTurnOutputRole: 'owner',
lastTurnOutputVerdict: 'step_done', lastTurnOutputVerdict: 'step_done',
}), }),
).toBe('owner'); ).toBeNull();
}); });
it('resolves reviewer execution target from review_ready task status', () => { it('resolves reviewer execution target from review_ready task status', () => {

View File

@@ -146,8 +146,7 @@ export function resolveNextTurnAction(args: {
? { kind: 'none' } ? { kind: 'none' }
: { kind: 'finalize-owner-turn' }; : { kind: 'finalize-owner-turn' };
case 'active': case 'active':
return args.lastTurnOutputVerdict === 'step_done' || return args.lastTurnOutputRole === 'reviewer' ||
args.lastTurnOutputRole === 'reviewer' ||
args.lastTurnOutputRole === 'arbiter' args.lastTurnOutputRole === 'arbiter'
? { kind: 'owner-follow-up' } ? { kind: 'owner-follow-up' }
: { kind: 'none' }; : { kind: 'none' };

View File

@@ -2126,7 +2126,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
expect(pending?.prompt).toContain('이전 reviewer 피드백'); expect(pending?.prompt).toContain('이전 reviewer 피드백');
}); });
it('builds an owner follow-up pending turn when the latest owner output is STEP_DONE on an active task', () => { it('does not build an owner follow-up pending turn from owner STEP_DONE on an active task', () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('claude-code'); const group = makeGroup('claude-code');
const task = { const task = {
@@ -2170,12 +2170,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
resolveChannel: () => makeChannel(chatJid), resolveChannel: () => makeChannel(chatJid),
}); });
expect(pending).not.toBeNull(); expect(pending).toBeNull();
expect(pending).toMatchObject({
role: 'owner',
intentKind: 'owner-follow-up',
taskId: task.id,
});
}); });
it('builds a reviewer pending turn when a review_ready task follows owner TASK_DONE output', () => { it('builds a reviewer pending turn when a review_ready task follows owner TASK_DONE output', () => {
@@ -2335,7 +2330,7 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
); );
}); });
it('re-enqueues owner follow-up after a successful owner STEP_DONE delivery keeps the task active', async () => { it('does not re-enqueue owner follow-up after a successful owner STEP_DONE delivery keeps the task active', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');
const ownerChannel: Channel = { const ownerChannel: Channel = {
@@ -2445,17 +2440,12 @@ If your first line is DONE_WITH_CONCERNS, the system will reopen review instead
chatJid, chatJid,
'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행', 'STEP_DONE\n리팩토링 1단계 완료, 다음 단계 진행',
); );
expect(enqueueMessageCheck).toHaveBeenCalledWith(chatJid); expect(enqueueMessageCheck).not.toHaveBeenCalled();
expect(pairedTask.status).toBe('active'); expect(pairedTask.status).toBe('active');
expect(pairedTask.round_trip_count).toBe(1); expect(pairedTask.round_trip_count).toBe(1);
expect(pairedTask.owner_failure_count).toBe(0); expect(pairedTask.owner_failure_count).toBe(0);
expect(logger.info).toHaveBeenCalledWith( expect(logger.info).not.toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
chatJid,
runId: 'run-owner-step-done-follow-up',
completedRole: 'owner',
taskId: 'task-owner-step-done-follow-up',
taskStatus: 'active',
intentKind: 'owner-follow-up', intentKind: 'owner-follow-up',
}), }),
'Queued paired follow-up after successful owner delivery', 'Queued paired follow-up after successful owner delivery',

View File

@@ -20,10 +20,9 @@ import {
} from './paired-execution-context-shared.js'; } from './paired-execution-context-shared.js';
import type { PairedTask } from './types.js'; import type { PairedTask } from './types.js';
type OwnerFinalizeOutcome = 'stop' | 're_review' | 'continue_owner'; type OwnerFinalizeOutcome = 'stop' | 're_review';
const OWNER_FAILURE_ESCALATION_THRESHOLD = 2; const OWNER_FAILURE_ESCALATION_THRESHOLD = 2;
const EMPTY_STEP_DONE_THRESHOLD = 2; const EMPTY_STEP_DONE_THRESHOLD = 2;
const OWNER_STEP_DONE_LOOP_THRESHOLD = 3;
export function handleFailedOwnerExecution(args: { export function handleFailedOwnerExecution(args: {
task: PairedTask; task: PairedTask;
@@ -157,7 +156,7 @@ function handleOwnerFinalizeCompletion(args: {
} }
if ( if (
signal.kind === 'request_owner_continue' && ownerVerdict === 'step_done' &&
hasNewChanges === false && hasNewChanges === false &&
nextEmptyStepDoneStreak >= EMPTY_STEP_DONE_THRESHOLD nextEmptyStepDoneStreak >= EMPTY_STEP_DONE_THRESHOLD
) { ) {
@@ -200,7 +199,8 @@ function handleOwnerFinalizeCompletion(args: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0, owner_step_done_streak: 0,
finalize_step_done_count: nextFinalizeStepDoneCount, finalize_step_done_count: nextFinalizeStepDoneCount,
empty_step_done_streak: 0, empty_step_done_streak:
ownerVerdict === 'step_done' ? nextEmptyStepDoneStreak : 0,
}, },
}); });
} }
@@ -213,36 +213,27 @@ function handleOwnerFinalizeCompletion(args: {
}, },
ownerVerdict === 'done_with_concerns' ownerVerdict === 'done_with_concerns'
? 'Owner raised concerns during finalize — task set back to active' ? 'Owner raised concerns during finalize — task set back to active'
: 'Owner made changes after reviewer approval — task set back to active before re-review', : ownerVerdict === 'step_done'
? 'Owner reported STEP_DONE during finalize — task set back to active before review'
: 'Owner made changes after reviewer approval — task set back to active before re-review',
); );
return 're_review'; maybeAutoTriggerReviewerAfterOwnerCompletion({
} task,
if (signal.kind === 'request_owner_continue') {
transitionPairedTaskStatus({
taskId, taskId,
currentStatus: task.status, now,
nextStatus: 'active', logMessage:
expectedUpdatedAt: task.updated_at, ownerVerdict === 'step_done'
updatedAt: now, ? 'Auto-triggered reviewer after owner finalize STEP_DONE'
patch: { : 'Auto-triggered reviewer after owner finalize required re-review',
owner_failure_count: 0, patch:
owner_step_done_streak: 0, ownerVerdict === 'step_done'
finalize_step_done_count: nextFinalizeStepDoneCount, ? {
empty_step_done_streak: nextEmptyStepDoneStreak, finalize_step_done_count: nextFinalizeStepDoneCount,
}, empty_step_done_streak: nextEmptyStepDoneStreak,
}
: undefined,
}); });
logger.info( return 'stop';
{
taskId,
ownerVerdict,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
finalizeStepDoneCount: nextFinalizeStepDoneCount,
summary: summary?.slice(0, 100),
},
'Owner marked finalize output as an intermediate step — task returned to active without re-review',
);
return 'continue_owner';
} }
transitionPairedTaskStatus({ transitionPairedTaskStatus({
@@ -271,6 +262,11 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: {
taskId: string; taskId: string;
now: string; now: string;
logMessage: string; logMessage: string;
patch?: {
owner_step_done_streak?: number;
finalize_step_done_count?: number;
empty_step_done_streak?: number;
};
}): void { }): void {
const { task, taskId, now, logMessage } = args; const { task, taskId, now, logMessage } = args;
if (task.round_trip_count >= PAIRED_MAX_ROUND_TRIPS) { if (task.round_trip_count >= PAIRED_MAX_ROUND_TRIPS) {
@@ -300,6 +296,7 @@ function maybeAutoTriggerReviewerAfterOwnerCompletion(args: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: 0, owner_step_done_streak: 0,
empty_step_done_streak: 0, empty_step_done_streak: 0,
...args.patch,
}, },
}); });
if (hasActiveCiWatcherForChat(task.chat_jid)) { if (hasActiveCiWatcherForChat(task.chat_jid)) {
@@ -345,16 +342,8 @@ export function handleOwnerCompletion(args: {
} }
const ownerVerdict = parseVisibleVerdict(summary); const ownerVerdict = parseVisibleVerdict(summary);
const workspace = getPairedWorkspace(task.id, 'owner');
const hasNewChanges = workspace?.workspace_dir
? hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref)
: null;
const nextOwnerStepDoneStreak = const nextOwnerStepDoneStreak =
ownerVerdict === 'step_done' ? (task.owner_step_done_streak ?? 0) + 1 : 0; ownerVerdict === 'step_done' ? (task.owner_step_done_streak ?? 0) + 1 : 0;
const nextEmptyStepDoneStreak =
ownerVerdict === 'step_done' && hasNewChanges === false
? (task.empty_step_done_streak ?? 0) + 1
: 0;
const signal = resolveOwnerCompletionSignal({ const signal = resolveOwnerCompletionSignal({
phase: 'normal', phase: 'normal',
visibleVerdict: ownerVerdict, visibleVerdict: ownerVerdict,
@@ -372,86 +361,23 @@ export function handleOwnerCompletion(args: {
taskId, taskId,
ownerVerdict, ownerVerdict,
ownerStepDoneStreak: nextOwnerStepDoneStreak, ownerStepDoneStreak: nextOwnerStepDoneStreak,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
summary: summary?.slice(0, 100), summary: summary?.slice(0, 100),
}, },
patch: { patch: {
owner_failure_count: 0, owner_failure_count: 0,
owner_step_done_streak: nextOwnerStepDoneStreak, owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
}, },
}); });
return; return;
} }
if ( if (nextOwnerStepDoneStreak !== (task.owner_step_done_streak ?? 0)) {
signal.kind === 'request_owner_continue' &&
hasNewChanges === false &&
nextOwnerStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD &&
nextEmptyStepDoneStreak >= OWNER_STEP_DONE_LOOP_THRESHOLD
) {
requestArbiterOrEscalate({
taskId,
currentStatus: task.status,
expectedUpdatedAt: task.updated_at,
now,
arbiterLogMessage:
'Owner repeated STEP_DONE in active mode without code changes — requesting arbiter',
escalateLogMessage:
'Owner repeated STEP_DONE in active mode without code changes — escalating to user',
logContext: {
taskId,
ownerVerdict,
hasNewChanges,
ownerStepDoneStreak: nextOwnerStepDoneStreak,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
summary: summary?.slice(0, 100),
},
patch: {
owner_failure_count: 0,
owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
},
});
return;
}
if (signal.kind === 'request_owner_continue') {
applyPairedTaskPatch({
taskId,
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
owner_failure_count: 0,
owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
},
});
logger.info(
{
taskId,
ownerVerdict,
hasNewChanges,
ownerStepDoneStreak: nextOwnerStepDoneStreak,
emptyStepDoneStreak: nextEmptyStepDoneStreak,
summary: summary?.slice(0, 100),
},
'Owner marked the current output as an intermediate completed step — keeping task active for follow-up',
);
return;
}
if (
nextOwnerStepDoneStreak !== (task.owner_step_done_streak ?? 0) ||
nextEmptyStepDoneStreak !== (task.empty_step_done_streak ?? 0)
) {
applyPairedTaskPatch({ applyPairedTaskPatch({
taskId, taskId,
expectedUpdatedAt: task.updated_at, expectedUpdatedAt: task.updated_at,
updatedAt: now, updatedAt: now,
patch: { patch: {
owner_step_done_streak: nextOwnerStepDoneStreak, owner_step_done_streak: nextOwnerStepDoneStreak,
empty_step_done_streak: nextEmptyStepDoneStreak,
}, },
}); });
} }

View File

@@ -27,7 +27,7 @@ describe('paired execution context shared verdict helpers', () => {
visibleVerdict: 'step_done', visibleVerdict: 'step_done',
}), }),
).toEqual({ ).toEqual({
kind: 'request_owner_continue', kind: 'request_reviewer',
resetStatusToActive: false, resetStatusToActive: false,
}); });
expect( expect(
@@ -57,7 +57,7 @@ describe('paired execution context shared verdict helpers', () => {
deadlockThreshold: 2, deadlockThreshold: 2,
}), }),
).toEqual({ ).toEqual({
kind: 'request_owner_continue', kind: 'request_reviewer',
resetStatusToActive: true, resetStatusToActive: true,
}); });
expect( expect(

View File

@@ -9,7 +9,6 @@ import type { PairedTaskStatus } from './types.js';
export type CompletionSignal = export type CompletionSignal =
| { kind: 'request_reviewer'; resetStatusToActive: boolean } | { kind: 'request_reviewer'; resetStatusToActive: boolean }
| { kind: 'request_owner_finalize' } | { kind: 'request_owner_finalize' }
| { kind: 'request_owner_continue'; resetStatusToActive: boolean }
| { kind: 'request_owner_changes' } | { kind: 'request_owner_changes' }
| { kind: 'request_arbiter' } | { kind: 'request_arbiter' }
| { kind: 'complete'; completionReason: 'done' | 'escalated' } | { kind: 'complete'; completionReason: 'done' | 'escalated' }
@@ -38,12 +37,6 @@ export function resolveOwnerCompletionSignal(args: {
} }
if (phase === 'normal') { if (phase === 'normal') {
if (visibleVerdict === 'step_done') {
return {
kind: 'request_owner_continue',
resetStatusToActive: false,
};
}
return { return {
kind: 'request_reviewer', kind: 'request_reviewer',
resetStatusToActive: false, resetStatusToActive: false,
@@ -52,7 +45,7 @@ export function resolveOwnerCompletionSignal(args: {
if (visibleVerdict === 'step_done') { if (visibleVerdict === 'step_done') {
return { return {
kind: 'request_owner_continue', kind: 'request_reviewer',
resetStatusToActive: true, resetStatusToActive: true,
}; };
} }

View File

@@ -802,13 +802,22 @@ describe('paired execution context', () => {
).not.toHaveBeenCalled(); ).not.toHaveBeenCalled();
}); });
it('keeps an active task in owner follow-up mode when the owner reports STEP_DONE', () => { it('requests review when the owner reports STEP_DONE in active mode', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue( vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({ buildPairedTask({
status: 'active', status: 'active',
round_trip_count: 1, round_trip_count: 1,
}), }),
); );
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({ completePairedExecutionContext({
taskId: 'task-1', taskId: 'task-1',
@@ -820,22 +829,17 @@ describe('paired execution context', () => {
expect(db.updatePairedTask).toHaveBeenCalledWith( expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1', 'task-1',
expect.objectContaining({ expect.objectContaining({
owner_failure_count: 0, round_trip_count: 2,
owner_step_done_streak: 1, owner_step_done_streak: 0,
empty_step_done_streak: 0,
}), }),
); );
expect( expect(
pairedWorkspaceManager.markPairedTaskReviewReady, pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled(); ).toHaveBeenCalledWith('task-1');
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'active',
}),
);
}); });
it('requests arbiter when active STEP_DONE repeats without code changes', () => { it('requests review instead of arbiter when active STEP_DONE repeats without code changes', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
const repoDir = createCanonicalRepoWithCommit('active step done loop'); const repoDir = createCanonicalRepoWithCommit('active step done loop');
@@ -851,6 +855,15 @@ describe('paired execution context', () => {
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) => vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined, role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
); );
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', repoDir),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({ completePairedExecutionContext({
taskId: 'task-1', taskId: 'task-1',
@@ -862,23 +875,44 @@ describe('paired execution context', () => {
expect(db.updatePairedTask).toHaveBeenCalledWith( expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1', 'task-1',
expect.objectContaining({ expect.objectContaining({
status: 'arbiter_requested', round_trip_count: 1,
owner_step_done_streak: 3, owner_step_done_streak: 0,
empty_step_done_streak: 3, empty_step_done_streak: 0,
}), }),
); );
expect( expect(
pairedWorkspaceManager.markPairedTaskReviewReady, pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled(); ).toHaveBeenCalledWith('task-1');
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
}),
);
}); });
it('returns merge_ready owner finalize output to active when the owner reports STEP_DONE', () => { it('re-triggers review when the owner reports STEP_DONE during finalize', () => {
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
vi.mocked(db.getPairedTaskById).mockReturnValue( vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({ buildPairedTask({
status: 'merge_ready', status: 'merge_ready',
source_ref: approvedSourceRef,
round_trip_count: 1, round_trip_count: 1,
}), }),
); );
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', repoDir),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({ completePairedExecutionContext({
taskId: 'task-1', taskId: 'task-1',
@@ -893,11 +927,20 @@ describe('paired execution context', () => {
status: 'active', status: 'active',
owner_failure_count: 0, owner_failure_count: 0,
finalize_step_done_count: 1, finalize_step_done_count: 1,
empty_step_done_streak: 1,
}),
);
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 2,
finalize_step_done_count: 1,
empty_step_done_streak: 1,
}), }),
); );
expect( expect(
pairedWorkspaceManager.markPairedTaskReviewReady, pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled(); ).toHaveBeenCalledWith('task-1');
}); });
it('requests arbiter when finalize STEP_DONE repeats without code changes', () => { it('requests arbiter when finalize STEP_DONE repeats without code changes', () => {