feat: verdict-based ping-pong control with merge_ready finalization

- DONE → merge_ready → owner final turn (commit/push) → completed
- DONE_WITH_CONCERNS → active → owner addresses → ping-pong continues
- BLOCKED/NEEDS_CONTEXT → completed (escalate to user, stop ping-pong)
- Regular feedback → active → ping-pong continues
- Owner detects merge_ready status and finalizes autonomously
This commit is contained in:
Eyejoker
2026-03-29 20:59:08 +09:00
parent 16d20fe627
commit d8cb4b691e
5 changed files with 125 additions and 69 deletions

View File

@@ -106,7 +106,9 @@ export const REVIEWER_AGENT_TYPE: AgentType =
/** Service ID for the reviewer based on agent type. */ /** Service ID for the reviewer based on agent type. */
export const REVIEWER_SERVICE_ID_FOR_TYPE = export const REVIEWER_SERVICE_ID_FOR_TYPE =
REVIEWER_AGENT_TYPE === 'claude-code' ? CLAUDE_SERVICE_ID : CODEX_REVIEW_SERVICE_ID; REVIEWER_AGENT_TYPE === 'claude-code'
? CLAUDE_SERVICE_ID
: CODEX_REVIEW_SERVICE_ID;
// Max owner↔reviewer round trips per task. 0 = unlimited. // Max owner↔reviewer round trips per task. 0 = unlimited.
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10'; const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10';

View File

@@ -491,7 +491,10 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
(pendingTask.status === 'review_ready' || (pendingTask.status === 'review_ready' ||
pendingTask.status === 'in_review'); pendingTask.status === 'in_review');
const deliveryChannel = isReviewerWorkItem ? reviewerChannel : channel; const deliveryChannel = isReviewerWorkItem ? reviewerChannel : channel;
const delivered = await deliverOpenWorkItem(deliveryChannel, openWorkItem); const delivered = await deliverOpenWorkItem(
deliveryChannel,
openWorkItem,
);
if (!delivered) return false; if (!delivered) return false;
} }
@@ -532,7 +535,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
if (ownerContent) { if (ownerContent) {
parts.push(`Owner response:\n---\n${ownerContent}\n---`); parts.push(`Owner response:\n---\n${ownerContent}\n---`);
} }
const reviewPrompt = parts.length > 0 const reviewPrompt =
parts.length > 0
? `${parts.join('\n\n')}\n\nReview the owner's response above. Provide feedback or approve.` ? `${parts.join('\n\n')}\n\nReview the owner's response above. Provide feedback or approve.`
: 'Review the latest owner changes in the workspace. Provide feedback or approve.'; : 'Review the latest owner changes in the workspace. Provide feedback or approve.';
@@ -559,6 +563,31 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
return deliverySucceeded; return deliverySucceeded;
} }
// merge_ready: reviewer approved, owner gets final turn to finalize
if (pendingReviewTask && pendingReviewTask.status === 'merge_ready') {
const lastRaw = rawMissedMessages[rawMissedMessages.length - 1];
if (lastRaw?.seq != null) {
advanceLastAgentCursor(
deps.getLastAgentTimestamps(),
deps.saveState,
chatJid,
lastRaw.seq,
);
}
const finalizePrompt =
'The reviewer approved your work (DONE). Finalize: commit changes, push if appropriate, and report the result.';
const { deliverySucceeded } = await executeTurn({
group,
prompt: finalizePrompt,
chatJid,
runId,
channel,
startSeq: null,
endSeq: null,
});
return deliverySucceeded;
}
const lastIgnored = rawMissedMessages[rawMissedMessages.length - 1]; const lastIgnored = rawMissedMessages[rawMissedMessages.length - 1];
if (lastIgnored) { if (lastIgnored) {
advanceLastAgentCursor( advanceLastAgentCursor(

View File

@@ -28,8 +28,6 @@ export function buildStructuredOutputPrompt(
return prompt; return prompt;
} }
export function parseStructuredOutputEnvelope( export function parseStructuredOutputEnvelope(_rawText: string): null {
_rawText: string,
): null {
return null; return null;
} }

View File

@@ -29,17 +29,19 @@ import type {
} from './types.js'; } from './types.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Reviewer approval detection // Reviewer verdict detection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Only check the first line for explicit status markers from the prompt's type ReviewerVerdict = 'done' | 'done_with_concerns' | 'blocked' | 'needs_context' | 'continue';
// completion status protocol (DONE, DONE_WITH_CONCERNS, BLOCKED, NEEDS_CONTEXT).
// DONE = approved → stop ping-pong. Everything else = continue. function classifyReviewerVerdict(summary: string | null | undefined): ReviewerVerdict {
function isReviewerApproval(summary: string | null | undefined): boolean { if (!summary) return 'continue';
if (!summary) return false;
const firstLine = summary.trimStart().split('\n')[0].trim(); const firstLine = summary.trimStart().split('\n')[0].trim();
// Match DONE but not DONE_WITH_CONCERNS if (/\bBLOCKED\b/.test(firstLine)) return 'blocked';
return /\bDONE\b/.test(firstLine) && !/\bDONE_WITH_CONCERNS\b/.test(firstLine); if (/\bNEEDS_CONTEXT\b/.test(firstLine)) return 'needs_context';
if (/\bDONE_WITH_CONCERNS\b/.test(firstLine)) return 'done_with_concerns';
if (/\bDONE\b/.test(firstLine)) return 'done';
return 'continue';
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -261,8 +263,21 @@ export function completePairedExecutionContext(args: {
return; return;
} }
// Owner finished → auto-trigger reviewer (if within round trip limit) // Owner finished
if (role === 'owner') { if (role === 'owner') {
const now = new Date().toISOString();
// merge_ready → owner finalized after reviewer approval → completed
if (task.status === 'merge_ready') {
updatePairedTask(taskId, { status: 'completed', updated_at: now });
logger.info(
{ taskId, summary: args.summary?.slice(0, 100) },
'Owner finalized after reviewer approval — task completed',
);
return;
}
// Normal turn → auto-trigger reviewer (if within round trip limit)
if (task.round_trip_count >= PAIRED_MAX_ROUND_TRIPS) { if (task.round_trip_count >= PAIRED_MAX_ROUND_TRIPS) {
logger.info( logger.info(
{ {
@@ -275,7 +290,6 @@ export function completePairedExecutionContext(args: {
return; return;
} }
const now = new Date().toISOString();
const result = markPairedTaskReviewReady(taskId); const result = markPairedTaskReviewReady(taskId);
if (result) { if (result) {
updatePairedTask(taskId, { updatePairedTask(taskId, {
@@ -290,28 +304,41 @@ export function completePairedExecutionContext(args: {
} }
} }
// Reviewer finished → check if approved or needs more work // Reviewer finished → classify verdict and route accordingly
if (role === 'reviewer') { if (role === 'reviewer') {
const now = new Date().toISOString(); const now = new Date().toISOString();
const approved = isReviewerApproval(args.summary); const verdict = classifyReviewerVerdict(args.summary);
if (approved) {
updatePairedTask(taskId, { switch (verdict) {
status: 'completed', case 'done':
updated_at: now, // Approved → owner gets final turn to commit/push
}); updatePairedTask(taskId, { status: 'merge_ready', updated_at: now });
logger.info( logger.info(
{ taskId, summary: args.summary?.slice(0, 100) }, { taskId, verdict, summary: args.summary?.slice(0, 100) },
'Reviewer approved, task completed — ping-pong stopped', 'Reviewer approved — owner gets final turn to finalize',
); );
} else { break;
updatePairedTask(taskId, {
status: 'active', case 'blocked':
updated_at: now, case 'needs_context':
}); // Needs user decision — stop ping-pong, leave task active
updatePairedTask(taskId, { status: 'completed', updated_at: now });
logger.info( logger.info(
{ taskId }, { taskId, verdict, summary: args.summary?.slice(0, 100) },
'Reviewer requested changes, task set back to active for owner', 'Reviewer escalated to user — ping-pong stopped',
); );
break;
case 'done_with_concerns':
case 'continue':
default:
// Owner needs to address feedback — ping-pong continues
updatePairedTask(taskId, { status: 'active', updated_at: now });
logger.info(
{ taskId, verdict },
'Reviewer has feedback, task set back to active for owner',
);
break;
} }
} }
} }