Files
EJClaw/src/paired-execution-context-reviewer.ts
Codex e14ac3dfca Merge remote-tracking branch 'gitea/main'
# Conflicts:
#	prompts/owner-common-paired-room.md
#	src/channels/discord.ts
#	src/codex-warmup.ts
#	src/db/bootstrap.test.ts
#	src/db/migrations/index.ts
#	src/paired-execution-context-reviewer.ts
#	src/usage-primer.test.ts
#	src/usage-primer.ts
2026-06-18 05:41:07 +09:00

277 lines
8.5 KiB
TypeScript

import { ARBITER_DEADLOCK_THRESHOLD } from './config.js';
import { getPairedWorkspace } from './db.js';
import { isTerminalCodexAccountFailure } from './agent-error-detection.js';
import { logger } from './logger.js';
import { requestArbiterOrEscalate } from './paired-arbiter-request.js';
import {
applyPairedTaskPatch,
transitionPairedTaskStatus,
} from './paired-task-status.js';
import {
resolveReviewerCompletionSignal,
resolveReviewerFailureSignal,
} from './paired-completion-signals.js';
import { resolveCanonicalSourceRef } from './paired-source-ref.js';
import { parseVisibleVerdict } from './paired-verdict.js';
import type { PairedTask } from './types.js';
/**
* Maximum consecutive reviewer executions that may produce no verdict
* before we stop the ping-pong loop. Mirrors the owner-side failure cap and
* closes the "reviewer silently fails forever" hole: codex sometimes emits a
* final result with no visible text, the runtime marks it failed, the
* next-turn scheduler re-queues another reviewer-turn, and the original
* round_trip_count never increments — so the existing round-trip / arbiter
* caps never fire.
*/
const REVIEWER_FAILURE_ESCALATION_THRESHOLD = 2;
export function handleFailedReviewerExecution(args: {
task: PairedTask;
taskId: string;
summary?: string | null;
}): void {
const { task, taskId, summary } = args;
const now = new Date().toISOString();
if (isTerminalCodexAccountFailure(summary)) {
transitionPairedTaskStatus({
taskId,
currentStatus: task.status,
nextStatus: 'completed',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
arbiter_verdict: 'escalate',
arbiter_requested_at: null,
completion_reason: 'reviewer_codex_unavailable',
},
});
logger.warn(
{
taskId,
role: 'reviewer',
status: task.status,
summary: summary?.slice(0, 200),
},
'Completed reviewer task after terminal Codex account failure instead of preserving review loop',
);
return;
}
if (summary) {
const verdict = parseVisibleVerdict(summary);
const signal = resolveReviewerFailureSignal({
visibleVerdict: verdict,
});
if (
signal.kind === 'request_owner_finalize' ||
signal.kind === 'complete' ||
signal.kind === 'request_owner_changes'
) {
const ownerWs =
signal.kind === 'request_owner_finalize'
? getPairedWorkspace(taskId, 'owner')
: null;
const approvedSourceRef =
signal.kind === 'request_owner_finalize' && ownerWs?.workspace_dir
? resolveCanonicalSourceRef(ownerWs.workspace_dir)
: task.source_ref;
transitionPairedTaskStatus({
taskId,
currentStatus: task.status,
nextStatus:
signal.kind === 'request_owner_finalize'
? 'merge_ready'
: signal.kind === 'request_owner_changes'
? 'active'
: 'completed',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
...(signal.kind === 'request_owner_finalize'
? {
source_ref: approvedSourceRef,
owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: 0,
empty_step_done_streak: 0,
arbiter_verdict: null,
arbiter_requested_at: null,
}
: {}),
...(signal.kind === 'complete'
? { completion_reason: signal.completionReason }
: {}),
},
});
logger.info(
{
taskId,
verdict,
approvedSourceRef,
summary: summary.slice(0, 100),
},
'Reviewer verdict detected from failed execution — stopping ping-pong',
);
return;
}
}
// Reviewer produced no usable verdict and no recognised infrastructure
// failure. Track consecutive flakes — after the threshold, stop the
// ping-pong loop instead of preserving review_ready forever.
const nextFailureCount = (task.reviewer_failure_count ?? 0) + 1;
if (nextFailureCount >= REVIEWER_FAILURE_ESCALATION_THRESHOLD) {
requestArbiterOrEscalate({
taskId,
currentStatus: task.status,
expectedUpdatedAt: task.updated_at,
now,
arbiterLogMessage:
'Reviewer failed repeatedly without a visible verdict — requesting arbiter',
escalateLogMessage:
'Reviewer failed repeatedly without a visible verdict — escalating to user',
logContext: {
taskId,
role: 'reviewer',
previousStatus: task.status,
reviewerFailureCount: nextFailureCount,
summary: summary?.slice(0, 160),
},
patch: {
reviewer_failure_count: nextFailureCount,
},
});
return;
}
const fallbackStatus =
task.status === 'in_review' || task.status === 'review_ready'
? 'review_ready'
: task.status;
if (fallbackStatus !== task.status) {
transitionPairedTaskStatus({
taskId,
currentStatus: task.status,
nextStatus: fallbackStatus,
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
reviewer_failure_count: nextFailureCount,
},
});
} else {
applyPairedTaskPatch({
taskId,
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
reviewer_failure_count: nextFailureCount,
},
});
}
logger.warn(
{
taskId,
role: 'reviewer',
previousStatus: task.status,
reviewerFailureCount: nextFailureCount,
summary: summary?.slice(0, 160),
},
'Preserved reviewer task in review-ready state after failed execution',
);
}
export function handleReviewerCompletion(args: {
task: PairedTask;
taskId: string;
summary?: string | null;
}): void {
const { task, taskId, summary } = args;
const now = new Date().toISOString();
const verdict = parseVisibleVerdict(summary);
const signal = resolveReviewerCompletionSignal({
visibleVerdict: verdict,
roundTripCount: task.round_trip_count,
deadlockThreshold: ARBITER_DEADLOCK_THRESHOLD,
});
switch (signal.kind) {
case 'request_owner_finalize': {
const ownerWs = getPairedWorkspace(taskId, 'owner');
const approvedSourceRef = ownerWs?.workspace_dir
? resolveCanonicalSourceRef(ownerWs.workspace_dir)
: task.source_ref;
transitionPairedTaskStatus({
taskId,
currentStatus: task.status,
nextStatus: 'merge_ready',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: {
source_ref: approvedSourceRef,
owner_failure_count: 0,
reviewer_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: 0,
empty_step_done_streak: 0,
arbiter_verdict: null,
arbiter_requested_at: null,
},
});
logger.info(
{
taskId,
verdict,
approvedSourceRef,
summary: summary?.slice(0, 100),
},
'Reviewer approved — owner gets final turn to finalize',
);
return;
}
case 'request_arbiter': {
const arbiterLogMessage =
verdict === 'blocked' || verdict === 'needs_context'
? 'Reviewer blocked/needs_context — requesting arbiter before escalating'
: 'Deadlock detected — requesting arbiter intervention';
const escalateLogMessage =
verdict === 'blocked' || verdict === 'needs_context'
? 'Reviewer escalated to user — ping-pong stopped'
: 'Stopped ping-pong — escalating to user (arbiter not configured)';
requestArbiterOrEscalate({
taskId,
currentStatus: task.status,
expectedUpdatedAt: task.updated_at,
now,
arbiterLogMessage,
escalateLogMessage,
logContext: { taskId, verdict, summary: summary?.slice(0, 100) },
arbiterInterventionCount: task.arbiter_intervention_count ?? 0,
patch: { reviewer_failure_count: 0 },
});
return;
}
case 'request_owner_changes':
transitionPairedTaskStatus({
taskId,
currentStatus: task.status,
nextStatus: 'active',
expectedUpdatedAt: task.updated_at,
updatedAt: now,
patch: { reviewer_failure_count: 0 },
});
logger.info(
{ taskId, verdict },
'Reviewer has feedback, task set back to active for owner',
);
return;
default:
return;
}
}