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:
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
25
src/db.ts
25
src/db.ts
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user