Fix paired reviewer finalize flow
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
completePairedExecutionContext,
|
completePairedExecutionContext,
|
||||||
type PreparedPairedExecutionContext,
|
type PreparedPairedExecutionContext,
|
||||||
} from './paired-execution-context.js';
|
} from './paired-execution-context.js';
|
||||||
|
import { hasRecognizedPairedTerminalSummary } from './paired-execution-context-shared.js';
|
||||||
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
|
import { resolvePairedFollowUpQueueAction } from './message-agent-executor-rules.js';
|
||||||
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
|
import { enqueuePairedFollowUpAfterEvent } from './message-runtime-follow-up.js';
|
||||||
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
@@ -60,7 +61,8 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
|
|
||||||
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
||||||
let pairedExecutionSummary: string | null = null;
|
let pairedExecutionSummary: string | null = null;
|
||||||
let pairedFinalOutput: string | null = null;
|
let pairedTerminalSummary: string | null = null;
|
||||||
|
let pairedTerminalOutput: string | null = null;
|
||||||
let pairedExecutionCompleted = false;
|
let pairedExecutionCompleted = false;
|
||||||
let pairedExecutionDelegated = false;
|
let pairedExecutionDelegated = false;
|
||||||
let pairedSawOutput = false;
|
let pairedSawOutput = false;
|
||||||
@@ -131,12 +133,20 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
leaseHeartbeatTimer.unref?.();
|
leaseHeartbeatTimer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rememberTerminalOutputIfRecognized = (outputText: string) => {
|
||||||
|
if (!hasRecognizedPairedTerminalSummary(completedRole, outputText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pairedTerminalSummary = outputText.slice(0, 500);
|
||||||
|
pairedTerminalOutput = outputText;
|
||||||
|
};
|
||||||
|
|
||||||
const persistPairedTurnOutputIfNeeded = () => {
|
const persistPairedTurnOutputIfNeeded = () => {
|
||||||
if (
|
if (
|
||||||
!pairedExecutionContext ||
|
!pairedExecutionContext ||
|
||||||
pairedTurnOutputPersisted ||
|
pairedTurnOutputPersisted ||
|
||||||
!pairedFinalOutput ||
|
!pairedTerminalOutput ||
|
||||||
pairedFinalOutput.length === 0
|
pairedTerminalOutput.length === 0
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -146,7 +156,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
pairedExecutionContext.task.id,
|
pairedExecutionContext.task.id,
|
||||||
turnNumber,
|
turnNumber,
|
||||||
completedRole,
|
completedRole,
|
||||||
pairedFinalOutput,
|
pairedTerminalOutput,
|
||||||
);
|
);
|
||||||
pairedTurnOutputPersisted = true;
|
pairedTurnOutputPersisted = true;
|
||||||
};
|
};
|
||||||
@@ -156,8 +166,8 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
completedRole !== 'owner' ||
|
completedRole !== 'owner' ||
|
||||||
!pairedExecutionContext ||
|
!pairedExecutionContext ||
|
||||||
pairedExecutionCompleted ||
|
pairedExecutionCompleted ||
|
||||||
!pairedFinalOutput ||
|
!pairedTerminalOutput ||
|
||||||
pairedFinalOutput.length === 0
|
pairedTerminalOutput.length === 0
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,7 +181,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
role: completedRole,
|
role: completedRole,
|
||||||
status: 'succeeded',
|
status: 'succeeded',
|
||||||
runId,
|
runId,
|
||||||
summary: pairedExecutionSummary,
|
summary: pairedTerminalSummary ?? pairedExecutionSummary,
|
||||||
});
|
});
|
||||||
pairedExecutionCompleted = true;
|
pairedExecutionCompleted = true;
|
||||||
};
|
};
|
||||||
@@ -180,6 +190,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
updateSummary({ outputText, errorText }) {
|
updateSummary({ outputText, errorText }) {
|
||||||
if (outputText && outputText.length > 0) {
|
if (outputText && outputText.length > 0) {
|
||||||
pairedExecutionSummary = outputText.slice(0, 500);
|
pairedExecutionSummary = outputText.slice(0, 500);
|
||||||
|
rememberTerminalOutputIfRecognized(outputText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +200,7 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
},
|
},
|
||||||
|
|
||||||
recordFinalOutputBeforeDelivery(outputText) {
|
recordFinalOutputBeforeDelivery(outputText) {
|
||||||
pairedFinalOutput = outputText;
|
rememberTerminalOutputIfRecognized(outputText);
|
||||||
completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded();
|
completeSuccessfulOwnerTurnBeforeDeliveryIfNeeded();
|
||||||
persistPairedTurnOutputIfNeeded();
|
persistPairedTurnOutputIfNeeded();
|
||||||
},
|
},
|
||||||
@@ -254,12 +265,29 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const missingReviewerOrArbiterVerdict =
|
||||||
|
(completedRole === 'reviewer' || completedRole === 'arbiter') &&
|
||||||
|
pairedExecutionStatus === 'succeeded' &&
|
||||||
|
!pairedTerminalSummary;
|
||||||
const effectiveStatus =
|
const effectiveStatus =
|
||||||
completedRole === 'owner' &&
|
completedRole === 'owner' &&
|
||||||
pairedExecutionStatus === 'succeeded' &&
|
pairedExecutionStatus === 'succeeded' &&
|
||||||
!pairedSawOutput
|
!pairedSawOutput
|
||||||
? 'failed'
|
? 'failed'
|
||||||
: pairedExecutionStatus;
|
: missingReviewerOrArbiterVerdict
|
||||||
|
? 'failed'
|
||||||
|
: pairedExecutionStatus;
|
||||||
|
|
||||||
|
if (missingReviewerOrArbiterVerdict) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
pairedTaskId: pairedExecutionContext?.task.id ?? null,
|
||||||
|
role: completedRole,
|
||||||
|
summary: pairedExecutionSummary,
|
||||||
|
},
|
||||||
|
'Downgraded paired reviewer/arbiter completion to failed because no explicit terminal verdict was emitted',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
if (pairedExecutionContext && !pairedExecutionCompleted) {
|
||||||
if (effectiveStatus === 'succeeded') {
|
if (effectiveStatus === 'succeeded') {
|
||||||
@@ -278,7 +306,10 @@ export function createPairedExecutionLifecycle(args: {
|
|||||||
role: completedRole,
|
role: completedRole,
|
||||||
status: effectiveStatus,
|
status: effectiveStatus,
|
||||||
runId,
|
runId,
|
||||||
summary: pairedExecutionSummary,
|
summary:
|
||||||
|
effectiveStatus === 'succeeded'
|
||||||
|
? (pairedTerminalSummary ?? pairedExecutionSummary)
|
||||||
|
: (pairedExecutionSummary ?? pairedTerminalSummary),
|
||||||
});
|
});
|
||||||
pairedExecutionCompleted = true;
|
pairedExecutionCompleted = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1236,6 +1236,99 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails a paired reviewer turn when the run ends after progress-only output without an explicit reviewer verdict', async () => {
|
||||||
|
const group = {
|
||||||
|
...makeGroup(),
|
||||||
|
folder: 'test-group',
|
||||||
|
workDir: '/repo/canonical',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(
|
||||||
|
pairedExecutionContext.preparePairedExecutionContext,
|
||||||
|
).mockReturnValue({
|
||||||
|
task: {
|
||||||
|
id: 'paired-task-reviewer-progress-only',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'codex-review',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
review_requested_at: '2026-04-10T00:00:00.000Z',
|
||||||
|
status: 'in_review',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-04-10T00:00:00.000Z',
|
||||||
|
updated_at: '2026-04-10T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
workspace: null,
|
||||||
|
envOverrides: {},
|
||||||
|
});
|
||||||
|
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
|
||||||
|
async (_group, _input, _onProcess, onOutput) => {
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result:
|
||||||
|
'요청 범위가 실제로 지워졌는지와 검증 근거가 맞는지 확인하겠습니다.',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '요청 범위가 실제로 지워졌는지와 검증 근거가 맞는지 확인하겠습니다.',
|
||||||
|
},
|
||||||
|
} as AgentOutput);
|
||||||
|
await onOutput?.({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result:
|
||||||
|
'우선 변경 파일과 현재 계약 노출 지점을 직접 읽고, 필요하면 전용 검증도 다시 돌리겠습니다.',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '우선 변경 파일과 현재 계약 노출 지점을 직접 읽고, 필요하면 전용 검증도 다시 돌리겠습니다.',
|
||||||
|
},
|
||||||
|
} as AgentOutput);
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
result: null,
|
||||||
|
} as AgentOutput;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'review please',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-reviewer-progress-only',
|
||||||
|
forcedRole: 'reviewer',
|
||||||
|
forcedAgentType: 'codex',
|
||||||
|
pairedTurnIdentity: {
|
||||||
|
turnId:
|
||||||
|
'paired-task-reviewer-progress-only:2026-04-10T00:00:00.000Z:reviewer-turn',
|
||||||
|
taskId: 'paired-task-reviewer-progress-only',
|
||||||
|
taskUpdatedAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
role: 'reviewer',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toBe('success');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
pairedExecutionContext.completePairedExecutionContext,
|
||||||
|
).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
taskId: 'paired-task-reviewer-progress-only',
|
||||||
|
role: 'reviewer',
|
||||||
|
status: 'failed',
|
||||||
|
summary: expect.stringContaining(
|
||||||
|
'우선 변경 파일과 현재 계약 노출 지점을',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('logs streamed activity with resolved execution attribution', async () => {
|
it('logs streamed activity with resolved execution attribution', async () => {
|
||||||
const group = {
|
const group = {
|
||||||
...makeGroup(),
|
...makeGroup(),
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export async function runAgentForGroup(
|
|||||||
runId,
|
runId,
|
||||||
roomRoleContext,
|
roomRoleContext,
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
|
pairedTurnIdentity: args.pairedTurnIdentity,
|
||||||
});
|
});
|
||||||
const preparedTurnTaskUpdatedAt = pairedExecutionContext
|
const preparedTurnTaskUpdatedAt = pairedExecutionContext
|
||||||
? (pairedExecutionContext.claimedTaskUpdatedAt ??
|
? (pairedExecutionContext.claimedTaskUpdatedAt ??
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ function makeTurnIdentity(): PairedTurnIdentity {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTurnIdentityWithRole(
|
||||||
|
role: PairedTurnIdentity['role'],
|
||||||
|
): PairedTurnIdentity {
|
||||||
|
return {
|
||||||
|
...makeTurnIdentity(),
|
||||||
|
role,
|
||||||
|
intentKind:
|
||||||
|
role === 'arbiter'
|
||||||
|
? 'arbiter-turn'
|
||||||
|
: role === 'owner'
|
||||||
|
? 'owner-turn'
|
||||||
|
: 'reviewer-turn',
|
||||||
|
turnId: `task-1:2026-04-10T14:22:00.000Z:${
|
||||||
|
role === 'arbiter'
|
||||||
|
? 'arbiter-turn'
|
||||||
|
: role === 'owner'
|
||||||
|
? 'owner-turn'
|
||||||
|
: 'reviewer-turn'
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function flushAsync(): Promise<void> {
|
async function flushAsync(): Promise<void> {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
@@ -221,4 +243,44 @@ describe('MessageTurnController outbound audit logging', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not promote paired reviewer progress-only output to a final message when no explicit verdict was emitted', async () => {
|
||||||
|
const channel = makeChannel();
|
||||||
|
const deliverFinalText = vi.fn().mockResolvedValue(true);
|
||||||
|
|
||||||
|
const controller = new MessageTurnController({
|
||||||
|
chatJid: 'dc:test-room',
|
||||||
|
group: makeGroup(),
|
||||||
|
runId: 'run-reviewer-progress-only',
|
||||||
|
channel,
|
||||||
|
idleTimeout: 1_000,
|
||||||
|
failureFinalText: '실패',
|
||||||
|
isClaudeCodeAgent: false,
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
requestClose: vi.fn(),
|
||||||
|
deliverFinalText,
|
||||||
|
deliveryRole: 'reviewer',
|
||||||
|
deliveryServiceId: 'codex-review',
|
||||||
|
pairedTurnIdentity: makeTurnIdentityWithRole('reviewer'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.start();
|
||||||
|
await controller.handleOutput({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result:
|
||||||
|
'요청 범위가 실제로 지워졌는지와 검증 근거가 맞는지 확인하겠습니다.',
|
||||||
|
} as any);
|
||||||
|
await controller.handleOutput({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result:
|
||||||
|
'우선 변경 파일과 현재 계약 노출 지점을 직접 읽고, 필요하면 전용 검증도 다시 돌리겠습니다.',
|
||||||
|
} as any);
|
||||||
|
await flushAsync();
|
||||||
|
await controller.finish('success');
|
||||||
|
|
||||||
|
expect(channel.sendAndTrack).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deliverFinalText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { formatOutbound } from './router.js';
|
|||||||
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js';
|
||||||
import { formatElapsedKorean } from './utils.js';
|
import { formatElapsedKorean } from './utils.js';
|
||||||
|
import { hasRecognizedPairedTerminalSummary } from './paired-execution-context-shared.js';
|
||||||
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
import {
|
import {
|
||||||
normalizeAgentOutputPhase,
|
normalizeAgentOutputPhase,
|
||||||
@@ -338,11 +339,30 @@ export class MessageTurnController {
|
|||||||
!this.hadError &&
|
!this.hadError &&
|
||||||
this.latestProgressTextForFinal
|
this.latestProgressTextForFinal
|
||||||
) {
|
) {
|
||||||
this.log.info(
|
const pairedRole = this.options.pairedTurnIdentity?.role ?? null;
|
||||||
'Sending a separate final message from the last progress output after agent completion',
|
const shouldPromoteToFinal =
|
||||||
);
|
!pairedRole ||
|
||||||
await this.finalizeProgressMessage();
|
hasRecognizedPairedTerminalSummary(
|
||||||
await this.deliverFinalText(this.latestProgressTextForFinal);
|
pairedRole,
|
||||||
|
this.latestProgressTextForFinal,
|
||||||
|
);
|
||||||
|
if (shouldPromoteToFinal) {
|
||||||
|
this.log.info(
|
||||||
|
'Sending a separate final message from the last progress output after agent completion',
|
||||||
|
);
|
||||||
|
await this.finalizeProgressMessage();
|
||||||
|
await this.deliverFinalText(this.latestProgressTextForFinal);
|
||||||
|
} else {
|
||||||
|
this.log.info(
|
||||||
|
{
|
||||||
|
pairedRole,
|
||||||
|
turnId: this.options.pairedTurnIdentity?.turnId ?? null,
|
||||||
|
},
|
||||||
|
'Skipped separate final message promotion because paired progress did not contain an explicit terminal verdict',
|
||||||
|
);
|
||||||
|
await this.finalizeProgressMessage();
|
||||||
|
this.latestProgressTextForFinal = null;
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
this.visiblePhase === 'progress' &&
|
this.visiblePhase === 'progress' &&
|
||||||
!this.terminalObserved() &&
|
!this.terminalObserved() &&
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
hasRecognizedPairedTerminalSummary,
|
||||||
parseVisibleVerdict,
|
parseVisibleVerdict,
|
||||||
resolveOwnerCompletionSignal,
|
resolveOwnerCompletionSignal,
|
||||||
resolveReviewerCompletionSignal,
|
resolveReviewerCompletionSignal,
|
||||||
@@ -18,6 +19,33 @@ describe('paired execution context shared verdict helpers', () => {
|
|||||||
expect(parseVisibleVerdict('random prose')).toBe('continue');
|
expect(parseVisibleVerdict('random prose')).toBe('continue');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recognizes paired terminal summaries only when the role-specific verdict is explicit', () => {
|
||||||
|
expect(
|
||||||
|
hasRecognizedPairedTerminalSummary('owner', 'plain owner update'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
hasRecognizedPairedTerminalSummary(
|
||||||
|
'reviewer',
|
||||||
|
'DONE_WITH_CONCERNS\nfollow-up detail',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
hasRecognizedPairedTerminalSummary(
|
||||||
|
'reviewer',
|
||||||
|
'요청 범위를 실제로 지워졌는지 확인하겠습니다.',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasRecognizedPairedTerminalSummary('arbiter', 'PROCEED\n승인합니다.'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
hasRecognizedPairedTerminalSummary(
|
||||||
|
'arbiter',
|
||||||
|
'대화와 현재 코드 상태를 대조해 먼저 확인하겠습니다.',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('maps normal owner completion verdicts to reviewer or arbiter signals', () => {
|
it('maps normal owner completion verdicts to reviewer or arbiter signals', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveOwnerCompletionSignal({
|
resolveOwnerCompletionSignal({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { execFileSync } from 'child_process';
|
|||||||
import { isArbiterEnabled } from './config.js';
|
import { isArbiterEnabled } from './config.js';
|
||||||
import { updatePairedTaskIfUnchanged } from './db.js';
|
import { updatePairedTaskIfUnchanged } from './db.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import type { PairedTaskStatus } from './types.js';
|
import type { PairedRoomRole, PairedTaskStatus } from './types.js';
|
||||||
|
|
||||||
export type VisibleVerdict =
|
export type VisibleVerdict =
|
||||||
| 'done'
|
| 'done'
|
||||||
@@ -149,6 +149,25 @@ export function classifyArbiterVerdict(
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasRecognizedPairedTerminalSummary(
|
||||||
|
role: PairedRoomRole,
|
||||||
|
summary: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!summary || summary.trim().length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'owner') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'reviewer') {
|
||||||
|
return parseVisibleVerdict(summary) !== 'continue';
|
||||||
|
}
|
||||||
|
|
||||||
|
return classifyArbiterVerdict(summary) !== 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveCanonicalSourceRef(workDir: string): string {
|
export function resolveCanonicalSourceRef(workDir: string): string {
|
||||||
const treeHash = resolveCanonicalTreeHash(workDir);
|
const treeHash = resolveCanonicalTreeHash(workDir);
|
||||||
return treeHash || 'HEAD';
|
return treeHash || 'HEAD';
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
preparePairedExecutionContext,
|
preparePairedExecutionContext,
|
||||||
} from './paired-execution-context.js';
|
} from './paired-execution-context.js';
|
||||||
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
|
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
|
||||||
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
import type {
|
import type {
|
||||||
PairedTask,
|
PairedTask,
|
||||||
PairedWorkspace,
|
PairedWorkspace,
|
||||||
@@ -335,6 +336,51 @@ describe('paired execution context', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses the persisted reviewer turn revision when a claimed handoff re-enters an already in_review task', () => {
|
||||||
|
const pairedTurnIdentity: PairedTurnIdentity = {
|
||||||
|
turnId: 'task-1:2026-03-28T00:00:00.000Z:reviewer-turn',
|
||||||
|
taskId: 'task-1',
|
||||||
|
taskUpdatedAt: '2026-03-28T00:00:00.000Z',
|
||||||
|
intentKind: 'reviewer-turn',
|
||||||
|
role: 'reviewer',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
|
||||||
|
buildPairedTask({
|
||||||
|
status: 'in_review',
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:05.000Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
|
buildPairedTask({
|
||||||
|
status: 'in_review',
|
||||||
|
review_requested_at: '2026-03-28T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-28T00:00:05.000Z',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(
|
||||||
|
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
|
||||||
|
).mockReturnValue({
|
||||||
|
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
|
||||||
|
autoRefreshed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = preparePairedExecutionContext({
|
||||||
|
group,
|
||||||
|
chatJid: 'dc:test',
|
||||||
|
runId: 'run-reviewer-handoff-reentry',
|
||||||
|
roomRoleContext: reviewerContext,
|
||||||
|
pairedTurnIdentity,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result?.claimedTaskUpdatedAt).toBe('2026-03-28T00:00:00.000Z');
|
||||||
|
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({ status: 'in_review' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not change task state when the reviewer snapshot is not ready', () => {
|
it('does not change task state when the reviewer snapshot is not ready', () => {
|
||||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
prepareReviewerWorkspaceForExecution,
|
prepareReviewerWorkspaceForExecution,
|
||||||
provisionOwnerWorkspaceForPairedTask,
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
} from './paired-workspace-manager.js';
|
} from './paired-workspace-manager.js';
|
||||||
|
import type { PairedTurnIdentity } from './paired-turn-identity.js';
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
PairedRoomRole,
|
PairedRoomRole,
|
||||||
@@ -193,6 +194,7 @@ export function preparePairedExecutionContext(args: {
|
|||||||
runId: string;
|
runId: string;
|
||||||
roomRoleContext?: RoomRoleContext;
|
roomRoleContext?: RoomRoleContext;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
pairedTurnIdentity?: PairedTurnIdentity;
|
||||||
}): PreparedPairedExecutionContext | undefined {
|
}): PreparedPairedExecutionContext | undefined {
|
||||||
const { group, chatJid, roomRoleContext } = args;
|
const { group, chatJid, roomRoleContext } = args;
|
||||||
if (!roomRoleContext || !group.workDir) {
|
if (!roomRoleContext || !group.workDir) {
|
||||||
@@ -210,7 +212,18 @@ export function preparePairedExecutionContext(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latestTask = getPairedTaskById(task.id) ?? task;
|
const latestTask = getPairedTaskById(task.id) ?? task;
|
||||||
const claimedTaskUpdatedAt = latestTask.updated_at;
|
const claimedTaskUpdatedAt =
|
||||||
|
args.pairedTurnIdentity &&
|
||||||
|
args.pairedTurnIdentity.taskId === latestTask.id &&
|
||||||
|
args.pairedTurnIdentity.role === roomRoleContext.role &&
|
||||||
|
((roomRoleContext.role === 'reviewer' &&
|
||||||
|
(latestTask.status === 'review_ready' ||
|
||||||
|
latestTask.status === 'in_review')) ||
|
||||||
|
(roomRoleContext.role === 'arbiter' &&
|
||||||
|
(latestTask.status === 'arbiter_requested' ||
|
||||||
|
latestTask.status === 'in_arbitration')))
|
||||||
|
? args.pairedTurnIdentity.taskUpdatedAt
|
||||||
|
: latestTask.updated_at;
|
||||||
let workspace: PairedWorkspace | null = null;
|
let workspace: PairedWorkspace | null = null;
|
||||||
let blockMessage: string | undefined;
|
let blockMessage: string | undefined;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|||||||
Reference in New Issue
Block a user