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:
@@ -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';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user