feat: auto-trigger reviewer after owner completion with round trip limit

- Owner finishes → auto mark review_ready → reviewer executes
- Reviewer finishes → task back to active → owner can respond
- Automatic ping-pong until consensus or round trip limit
- PAIRED_MAX_ROUND_TRIPS env var (default 10, 0 = unlimited)
- round_trip_count tracked per task in DB
This commit is contained in:
Eyejoker
2026-03-29 18:57:16 +09:00
parent 338c1f6a1d
commit a1e5418a2f
10 changed files with 105 additions and 45 deletions

View File

@@ -91,6 +91,12 @@ export const RECOVERY_CONCURRENT_AGENTS = parseInt(
10,
);
// ── Paired review ─────────────────────────────────────────────────
// Max owner↔reviewer round trips per task. 0 = unlimited.
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10';
export const PAIRED_MAX_ROUND_TRIPS =
rawMaxRoundTrips === '0' ? Infinity : parseInt(rawMaxRoundTrips, 10) || 10;
// ── Container isolation ───────────────────────────────────────────
export const REVIEWER_CONTAINER_IMAGE =
getEnv('REVIEWER_CONTAINER_IMAGE') || 'ejclaw-reviewer:latest';

View File

@@ -556,7 +556,7 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review',
title: 'wire up workspaces',
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
@@ -594,7 +594,7 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: null,
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',

View File

@@ -250,11 +250,7 @@ function createSchema(database: Database.Database): void {
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
last_finalized_checkpoint TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
reviewer_verdict_note TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -566,7 +562,9 @@ function createSchema(database: Database.Database): void {
.get() as { sql?: string } | undefined;
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
const pairedTasksNeedsRebuild =
pairedTasksSql && pairedTasksSql.includes('task_policy');
pairedTasksSql &&
(pairedTasksSql.includes('task_policy') ||
!pairedTasksSql.includes('round_trip_count'));
if (pairedTasksNeedsRebuild) {
database.exec(`DROP TABLE IF EXISTS paired_tasks`);
database.exec(`
@@ -580,11 +578,7 @@ function createSchema(database: Database.Database): void {
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
last_finalized_checkpoint TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
reviewer_verdict_note TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
@@ -1847,11 +1841,12 @@ export function createPairedTask(task: PairedTask): void {
source_ref,
plan_notes,
review_requested_at,
round_trip_count,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
@@ -1863,6 +1858,7 @@ export function createPairedTask(task: PairedTask): void {
task.source_ref,
task.plan_notes,
task.review_requested_at,
task.round_trip_count,
task.status,
task.created_at,
task.updated_at,
@@ -1917,6 +1913,7 @@ export function updatePairedTask(
| 'source_ref'
| 'plan_notes'
| 'review_requested_at'
| 'round_trip_count'
| 'status'
| 'updated_at'
>
@@ -1941,6 +1938,10 @@ export function updatePairedTask(
fields.push('review_requested_at = ?');
values.push(updates.review_requested_at);
}
if (updates.round_trip_count !== undefined) {
fields.push('round_trip_count = ?');
values.push(updates.round_trip_count);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);

View File

@@ -326,7 +326,7 @@ describe('runAgentForGroup room memory', () => {
title: null,
source_ref: 'HEAD',
review_requested_at: null,
plan_notes: null,
plan_notes: null, round_trip_count: 0,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
@@ -386,7 +386,7 @@ describe('runAgentForGroup room memory', () => {
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
@@ -467,7 +467,7 @@ describe('runAgentForGroup room memory', () => {
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',

View File

@@ -1,6 +1,9 @@
import { getErrorMessage } from './utils.js';
import { getAgentOutputText, getStructuredAgentOutput } from './agent-output.js';
import {
getAgentOutputText,
getStructuredAgentOutput,
} from './agent-output.js';
import {
AgentOutput,
runAgentProcess,
@@ -226,6 +229,7 @@ export async function runAgentForGroup(
});
completePairedExecutionContext({
taskId: pairedExecutionContext.task.id,
role: roomRoleContext?.role ?? 'owner',
status: pairedExecutionStatus,
summary: pairedExecutionSummary,
});
@@ -871,6 +875,7 @@ export async function runAgentForGroup(
if (pairedExecutionContext) {
completePairedExecutionContext({
taskId: pairedExecutionContext.task.id,
role: roomRoleContext?.role ?? 'owner',
status: pairedExecutionStatus,
summary: pairedExecutionSummary,
});

View File

@@ -262,7 +262,7 @@ describe('createMessageRuntime', () => {
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-29T00:00:00.000Z',
@@ -354,7 +354,7 @@ describe('createMessageRuntime', () => {
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
@@ -2841,5 +2841,4 @@ describe('createMessageRuntime', () => {
expect(enqueueTask).not.toHaveBeenCalled();
expect(db.getMessagesSinceSeq).not.toHaveBeenCalled();
});
});

View File

@@ -215,7 +215,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
@@ -246,7 +246,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
@@ -280,7 +280,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
@@ -320,7 +320,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
created_at: '2026-03-28T00:00:00.000Z',
@@ -351,6 +351,7 @@ describe('paired execution context', () => {
it('completePairedExecutionContext logs without error', () => {
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'done',
});
@@ -373,7 +374,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
@@ -387,7 +388,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
@@ -454,7 +455,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
@@ -468,7 +469,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
@@ -510,7 +511,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
@@ -524,7 +525,7 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',

View File

@@ -1,7 +1,7 @@
import { execFileSync } from 'child_process';
import crypto from 'crypto';
import { SERVICE_ID, normalizeServiceId } from './config.js';
import { SERVICE_ID, normalizeServiceId, PAIRED_MAX_ROUND_TRIPS } from './config.js';
import {
createPairedTask,
getLatestPairedTaskForChat,
@@ -103,6 +103,7 @@ function ensureActiveTask(
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
plan_notes: null,
review_requested_at: null,
round_trip_count: 0,
status: 'active',
created_at: now,
updated_at: now,
@@ -203,17 +204,63 @@ export function preparePairedExecutionContext(args: {
export function completePairedExecutionContext(args: {
taskId: string;
role: 'owner' | 'reviewer';
status: 'succeeded' | 'failed';
summary?: string | null;
}): void {
const { taskId, role, status } = args;
logger.info(
{
taskId: args.taskId,
status: args.status,
taskId,
role,
status,
summary: args.summary?.slice(0, 200),
},
'Paired execution completed',
);
if (status !== 'succeeded') return;
const task = getPairedTaskById(taskId);
if (!task) return;
// Owner finished → auto-trigger reviewer (if within round trip limit)
if (role === 'owner') {
if (task.round_trip_count >= PAIRED_MAX_ROUND_TRIPS) {
logger.info(
{ taskId, roundTrips: task.round_trip_count, max: PAIRED_MAX_ROUND_TRIPS },
'Round trip limit reached, skipping auto-review',
);
return;
}
const now = new Date().toISOString();
const result = markPairedTaskReviewReady(taskId);
if (result) {
updatePairedTask(taskId, {
round_trip_count: task.round_trip_count + 1,
review_requested_at: now,
updated_at: now,
});
logger.info(
{ taskId, roundTrip: task.round_trip_count + 1 },
'Auto-triggered reviewer after owner completion',
);
}
}
// Reviewer finished → set task back to active so owner can respond
if (role === 'reviewer') {
const now = new Date().toISOString();
updatePairedTask(taskId, {
status: 'active',
updated_at: now,
});
logger.info(
{ taskId },
'Reviewer completed, task set back to active for owner',
);
}
}
// ---------------------------------------------------------------------------

View File

@@ -74,7 +74,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: 'review the owner changes',
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -162,7 +162,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -214,7 +214,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: 'cross service review',
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -289,7 +289,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -363,7 +363,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -503,7 +503,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -564,7 +564,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -619,7 +619,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: '2026-03-28T00:01:00.000Z',
status: 'review_ready',
created_at: now,
@@ -680,7 +680,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -743,7 +743,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,
@@ -831,7 +831,7 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
plan_notes: null,
plan_notes: null, round_trip_count: 0,
review_requested_at: null,
status: 'active',
created_at: now,

View File

@@ -62,6 +62,7 @@ export interface PairedTask {
source_ref: string | null;
plan_notes: string | null;
review_requested_at: string | null;
round_trip_count: number;
status: PairedTaskStatus;
created_at: string;
updated_at: string;