feat: enforce paired reviewer gate verdicts

This commit is contained in:
Eyejoker
2026-03-29 06:03:45 +09:00
parent 29310ce67f
commit dad6f50937
12 changed files with 908 additions and 33 deletions

View File

@@ -54,6 +54,7 @@ interface ContainerOutput {
output?: { output?: {
visibility: 'public' | 'silent'; visibility: 'public' | 'silent';
text?: string; text?: string;
verdict?: 'done' | 'done_with_concerns' | 'blocked' | 'silent';
}; };
newSessionId?: string; newSessionId?: string;
error?: string; error?: string;
@@ -217,14 +218,27 @@ function normalizeStructuredOutput(result: string | null): {
const trimmed = result.trim(); const trimmed = result.trim();
try { try {
const parsed = JSON.parse(trimmed) as { const parsed = JSON.parse(trimmed) as {
ejclaw?: { visibility?: unknown; text?: unknown }; ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown };
}; };
const envelope = parsed?.ejclaw; const envelope = parsed?.ejclaw;
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) { if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
if (envelope.visibility === 'silent') { if (envelope.visibility === 'silent') {
if (
envelope.verdict !== undefined &&
envelope.verdict !== 'silent'
) {
return {
result,
output: { visibility: 'public', text: result },
};
}
return { return {
result: null, result: null,
output: { visibility: 'silent' }, output: {
visibility: 'silent',
verdict:
envelope.verdict === 'silent' ? ('silent' as const) : undefined,
},
}; };
} }
if ( if (
@@ -232,9 +246,30 @@ function normalizeStructuredOutput(result: string | null): {
typeof envelope.text === 'string' && typeof envelope.text === 'string' &&
envelope.text.length > 0 envelope.text.length > 0
) { ) {
if (
envelope.verdict !== undefined &&
envelope.verdict !== 'done' &&
envelope.verdict !== 'done_with_concerns' &&
envelope.verdict !== 'blocked'
) {
return {
result,
output: { visibility: 'public', text: result },
};
}
return { return {
result: envelope.text, result: envelope.text,
output: { visibility: 'public', text: envelope.text }, output: {
visibility: 'public',
text: envelope.text,
verdict:
typeof envelope.verdict === 'string'
? (envelope.verdict as
| 'done'
| 'done_with_concerns'
| 'blocked')
: undefined,
},
}; };
} }
} }

View File

@@ -48,6 +48,7 @@ interface ContainerOutput {
output?: { output?: {
visibility: 'public' | 'silent'; visibility: 'public' | 'silent';
text?: string; text?: string;
verdict?: 'done' | 'done_with_concerns' | 'blocked' | 'silent';
}; };
phase?: 'progress' | 'final'; phase?: 'progress' | 'final';
newSessionId?: string; newSessionId?: string;
@@ -92,14 +93,27 @@ function normalizeStructuredOutput(result: string | null): {
const trimmed = result.trim(); const trimmed = result.trim();
try { try {
const parsed = JSON.parse(trimmed) as { const parsed = JSON.parse(trimmed) as {
ejclaw?: { visibility?: unknown; text?: unknown }; ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown };
}; };
const envelope = parsed?.ejclaw; const envelope = parsed?.ejclaw;
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) { if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
if (envelope.visibility === 'silent') { if (envelope.visibility === 'silent') {
if (
envelope.verdict !== undefined &&
envelope.verdict !== 'silent'
) {
return {
result,
output: { visibility: 'public', text: result },
};
}
return { return {
result: null, result: null,
output: { visibility: 'silent' }, output: {
visibility: 'silent',
verdict:
envelope.verdict === 'silent' ? ('silent' as const) : undefined,
},
}; };
} }
if ( if (
@@ -107,9 +121,30 @@ function normalizeStructuredOutput(result: string | null): {
typeof envelope.text === 'string' && typeof envelope.text === 'string' &&
envelope.text.length > 0 envelope.text.length > 0
) { ) {
if (
envelope.verdict !== undefined &&
envelope.verdict !== 'done' &&
envelope.verdict !== 'done_with_concerns' &&
envelope.verdict !== 'blocked'
) {
return {
result,
output: { visibility: 'public', text: result },
};
}
return { return {
result: envelope.text, result: envelope.text,
output: { visibility: 'public', text: envelope.text }, output: {
visibility: 'public',
text: envelope.text,
verdict:
typeof envelope.verdict === 'string'
? (envelope.verdict as
| 'done'
| 'done_with_concerns'
| 'blocked')
: undefined,
},
}; };
} }
} }

View File

@@ -1,3 +1,4 @@
import { parseStructuredOutputEnvelope } from './output-suppression.js';
import type { StructuredAgentOutput } from './types.js'; import type { StructuredAgentOutput } from './types.js';
export function stringifyLegacyAgentResult( export function stringifyLegacyAgentResult(
@@ -13,15 +14,29 @@ export function stringifyLegacyAgentResult(
} }
} }
export function getStructuredAgentOutput(output: {
output?: StructuredAgentOutput;
result?: string | object | null;
}): StructuredAgentOutput | null {
if (output.output) {
return output.output;
}
if (typeof output.result !== 'string') {
return null;
}
return parseStructuredOutputEnvelope(output.result.trim());
}
export function getAgentOutputText(output: { export function getAgentOutputText(output: {
output?: StructuredAgentOutput; output?: StructuredAgentOutput;
result?: string | object | null; result?: string | object | null;
}): string | null { }): string | null {
if (output.output?.visibility === 'silent') { const structured = getStructuredAgentOutput(output);
if (structured?.visibility === 'silent') {
return null; return null;
} }
if (output.output?.visibility === 'public') { if (structured?.visibility === 'public') {
return output.output.text; return structured.text;
} }
return stringifyLegacyAgentResult(output.result); return stringifyLegacyAgentResult(output.result);
} }
@@ -38,6 +53,7 @@ export function hasAgentOutputPayload(output: {
export function isSilentAgentOutput(output: { export function isSilentAgentOutput(output: {
output?: StructuredAgentOutput; output?: StructuredAgentOutput;
result?: string | object | null;
}): boolean { }): boolean {
return output.output?.visibility === 'silent'; return getStructuredAgentOutput(output)?.visibility === 'silent';
} }

View File

@@ -635,6 +635,12 @@ describe('paired task state', () => {
expect(getPairedTaskById('paired-task-1')?.plan_status).toBe( expect(getPairedTaskById('paired-task-1')?.plan_status).toBe(
'not_requested', 'not_requested',
); );
expect(getPairedTaskById('paired-task-1')?.gate_turn_kind ?? null).toBe(
null,
);
expect(getPairedTaskById('paired-task-1')?.reviewer_verdict ?? null).toBe(
null,
);
expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending'); expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending');
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe( expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
'/tmp/paired-room/owner', '/tmp/paired-room/owner',
@@ -960,6 +966,10 @@ describe('paired task state', () => {
task_policy: 'autonomous', task_policy: 'autonomous',
risk_level: 'low', risk_level: 'low',
plan_status: 'approved', plan_status: 'approved',
gate_turn_kind: 'implementation_start',
reviewer_verdict: 'done_with_concerns',
reviewer_verdict_at: '2026-03-28T00:09:00.000Z',
reviewer_verdict_note: '**DONE_WITH_CONCERNS** okay to proceed',
status: 'review_pending', status: 'review_pending',
review_requested_at: '2026-03-28T00:10:00.000Z', review_requested_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:10:00.000Z', updated_at: '2026-03-28T00:10:00.000Z',
@@ -969,6 +979,13 @@ describe('paired task state', () => {
started_at: '2026-03-28T00:11:00.000Z', started_at: '2026-03-28T00:11:00.000Z',
}); });
expect(getPairedTaskById('paired-task-2')?.gate_turn_kind).toBe(
'implementation_start',
);
expect(getPairedTaskById('paired-task-2')?.reviewer_verdict).toBe(
'done_with_concerns',
);
upsertPairedWorkspace({ upsertPairedWorkspace({
id: 'paired-task-2:reviewer', id: 'paired-task-2:reviewer',
task_id: 'paired-task-2', task_id: 'paired-task-2',

View File

@@ -258,6 +258,10 @@ function createSchema(database: Database.Database): void {
risk_level TEXT NOT NULL DEFAULT 'low', risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested', plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT, review_requested_at TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
reviewer_verdict_note TEXT,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@@ -271,6 +275,14 @@ function createSchema(database: Database.Database): void {
'changes_requested' 'changes_requested'
) )
), ),
CHECK (
gate_turn_kind IS NULL OR
gate_turn_kind IN ('implementation_start', 'commit', 'push')
),
CHECK (
reviewer_verdict IS NULL OR
reviewer_verdict IN ('done', 'done_with_concerns', 'blocked', 'silent')
),
CHECK ( CHECK (
status IN ( status IN (
'active', 'active',
@@ -690,7 +702,9 @@ function createSchema(database: Database.Database): void {
!pairedTasksSql.includes("'plan_review_pending'") || !pairedTasksSql.includes("'plan_review_pending'") ||
!pairedTasksSql.includes('task_policy TEXT') || !pairedTasksSql.includes('task_policy TEXT') ||
!pairedTasksSql.includes('risk_level TEXT') || !pairedTasksSql.includes('risk_level TEXT') ||
!pairedTasksSql.includes('plan_status TEXT')); !pairedTasksSql.includes('plan_status TEXT') ||
!pairedTasksSql.includes('gate_turn_kind TEXT') ||
!pairedTasksSql.includes('reviewer_verdict TEXT'));
if (pairedTasksNeedsMigration) { if (pairedTasksNeedsMigration) {
database.exec(` database.exec(`
CREATE TABLE paired_tasks_new ( CREATE TABLE paired_tasks_new (
@@ -705,6 +719,10 @@ function createSchema(database: Database.Database): void {
risk_level TEXT NOT NULL DEFAULT 'low', risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested', plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT, review_requested_at TEXT,
gate_turn_kind TEXT,
reviewer_verdict TEXT,
reviewer_verdict_at TEXT,
reviewer_verdict_note TEXT,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
@@ -718,6 +736,14 @@ function createSchema(database: Database.Database): void {
'changes_requested' 'changes_requested'
) )
), ),
CHECK (
gate_turn_kind IS NULL OR
gate_turn_kind IN ('implementation_start', 'commit', 'push')
),
CHECK (
reviewer_verdict IS NULL OR
reviewer_verdict IN ('done', 'done_with_concerns', 'blocked', 'silent')
),
CHECK ( CHECK (
status IN ( status IN (
'active', 'active',
@@ -748,6 +774,10 @@ function createSchema(database: Database.Database): void {
risk_level, risk_level,
plan_status, plan_status,
review_requested_at, review_requested_at,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
reviewer_verdict_note,
status, status,
created_at, created_at,
updated_at updated_at
@@ -774,6 +804,10 @@ function createSchema(database: Database.Database): void {
ELSE 'not_requested' ELSE 'not_requested'
END, END,
review_requested_at, review_requested_at,
NULL,
NULL,
NULL,
NULL,
status, status,
created_at, created_at,
updated_at updated_at
@@ -2042,11 +2076,15 @@ export function createPairedTask(task: PairedTask): void {
risk_level, risk_level,
plan_status, plan_status,
review_requested_at, review_requested_at,
gate_turn_kind,
reviewer_verdict,
reviewer_verdict_at,
reviewer_verdict_note,
status, status,
created_at, created_at,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
task.id, task.id,
@@ -2060,6 +2098,10 @@ export function createPairedTask(task: PairedTask): void {
task.risk_level, task.risk_level,
task.plan_status, task.plan_status,
task.review_requested_at, task.review_requested_at,
task.gate_turn_kind ?? null,
task.reviewer_verdict ?? null,
task.reviewer_verdict_at ?? null,
task.reviewer_verdict_note ?? null,
task.status, task.status,
task.created_at, task.created_at,
task.updated_at, task.updated_at,
@@ -2100,6 +2142,10 @@ export function updatePairedTask(
| 'risk_level' | 'risk_level'
| 'plan_status' | 'plan_status'
| 'review_requested_at' | 'review_requested_at'
| 'gate_turn_kind'
| 'reviewer_verdict'
| 'reviewer_verdict_at'
| 'reviewer_verdict_note'
| 'status' | 'status'
| 'updated_at' | 'updated_at'
> >
@@ -2132,6 +2178,22 @@ export function updatePairedTask(
fields.push('review_requested_at = ?'); fields.push('review_requested_at = ?');
values.push(updates.review_requested_at); values.push(updates.review_requested_at);
} }
if (updates.gate_turn_kind !== undefined) {
fields.push('gate_turn_kind = ?');
values.push(updates.gate_turn_kind);
}
if (updates.reviewer_verdict !== undefined) {
fields.push('reviewer_verdict = ?');
values.push(updates.reviewer_verdict);
}
if (updates.reviewer_verdict_at !== undefined) {
fields.push('reviewer_verdict_at = ?');
values.push(updates.reviewer_verdict_at);
}
if (updates.reviewer_verdict_note !== undefined) {
fields.push('reviewer_verdict_note = ?');
values.push(updates.reviewer_verdict_note);
}
if (updates.status !== undefined) { if (updates.status !== undefined) {
fields.push('status = ?'); fields.push('status = ?');
values.push(updates.status); values.push(updates.status);

View File

@@ -115,6 +115,7 @@ vi.mock('./paired-execution-context.js', () => ({
})); }));
import * as agentRunner from './agent-runner.js'; import * as agentRunner from './agent-runner.js';
import type { AgentOutput } from './agent-runner.js';
import * as codexTokenRotation from './codex-token-rotation.js'; import * as codexTokenRotation from './codex-token-rotation.js';
import * as db from './db.js'; import * as db from './db.js';
import { buildRoomMemoryBriefing } from './memento-client.js'; import { buildRoomMemoryBriefing } from './memento-client.js';
@@ -303,6 +304,344 @@ describe('runAgentForGroup room memory', () => {
); );
}); });
it('requires a visible structured verdict in reviewer gate turns', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-gate',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {},
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
});
await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-gate-prompt',
});
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
prompt: expect.stringContaining(
'This turn is a paired-room gate turn for implementation_start. Silent output is forbidden.',
),
}),
expect.any(Function),
undefined,
{},
);
});
it('blocks silent reviewer output on gate turns and records a silent verdict', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-gate',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {},
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: null,
output: { visibility: 'silent', verdict: 'silent' },
phase: 'final',
});
return {
status: 'success',
result: null,
};
},
);
const outputs: AgentOutput[] = [];
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-gate-silent',
onOutput: async (output) => {
outputs.push(output);
},
});
expect(result).toBe('error');
expect(outputs.at(-1)?.output).toEqual({
visibility: 'public',
text: expect.stringContaining(
'Reviewer gate turn requires a visible structured verdict.',
),
});
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith(
expect.objectContaining({
executionId: 'run-gate:claude',
status: 'failed',
reviewerVerdict: 'silent',
}),
);
});
it('allows structured reviewer verdicts on gate turns', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-gate',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {},
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: null,
output: {
visibility: 'public',
verdict: 'done_with_concerns',
text: '**DONE_WITH_CONCERNS**\n문제는 없지만 확인 포인트는 있습니다.',
},
phase: 'final',
});
return {
status: 'success',
result: null,
};
},
);
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-gate-done',
onOutput: async () => {},
});
expect(result).toBe('success');
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith(
expect.objectContaining({
executionId: 'run-gate:claude',
status: 'succeeded',
reviewerVerdict: 'done_with_concerns',
}),
);
});
it('still allows silent reviewer outputs on non-gate turns', async () => {
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(
pairedExecutionContext.preparePairedExecutionContext,
).mockReturnValue({
task: {
id: 'paired-task-gate',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: null,
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
execution: {
id: 'run-gate:claude',
task_id: 'paired-task-gate',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-29T00:00:00.000Z',
started_at: '2026-03-29T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {},
gateTurnKind: null,
requiresVisibleVerdict: false,
});
vi.mocked(agentRunner.runAgentProcess).mockImplementation(
async (_group, _input, _onProcess, onOutput) => {
await onOutput?.({
status: 'success',
result: null,
output: { visibility: 'silent', verdict: 'silent' },
phase: 'final',
});
return {
status: 'success',
result: null,
};
},
);
const outputs: AgentOutput[] = [];
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-review-non-gate-silent',
onOutput: async (output) => {
outputs.push(output);
},
});
expect(result).toBe('success');
expect(outputs).toEqual([
expect.objectContaining({
output: { visibility: 'silent', verdict: 'silent' },
}),
]);
});
it('passes paired workspace env overrides into the runner when execution metadata exists', async () => { it('passes paired workspace env overrides into the runner when execution metadata exists', async () => {
const group = { const group = {
...makeGroup(), ...makeGroup(),
@@ -384,11 +723,13 @@ describe('runAgentForGroup room memory', () => {
); );
expect( expect(
pairedExecutionContext.completePairedExecutionContext, pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith({ ).toHaveBeenCalledWith(
executionId: 'run-room-role:claude', expect.objectContaining({
status: 'succeeded', executionId: 'run-room-role:claude',
summary: 'ok', status: 'succeeded',
}); summary: 'ok',
}),
);
}); });
it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => { it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => {

View File

@@ -1,6 +1,6 @@
import { getErrorMessage } from './utils.js'; import { getErrorMessage } from './utils.js';
import { getAgentOutputText } from './agent-output.js'; import { getAgentOutputText, getStructuredAgentOutput } from './agent-output.js';
import { import {
AgentOutput, AgentOutput,
runAgentProcess, runAgentProcess,
@@ -51,7 +51,7 @@ import {
} from './codex-token-rotation.js'; } from './codex-token-rotation.js';
import type { CodexRotationReason } from './agent-error-detection.js'; import type { CodexRotationReason } from './agent-error-detection.js';
import { getTokenCount } from './token-rotation.js'; import { getTokenCount } from './token-rotation.js';
import type { RegisteredGroup } from './types.js'; import type { PairedReviewerVerdict, RegisteredGroup } from './types.js';
// ── Main executor ───────────────────────────────────────────────── // ── Main executor ─────────────────────────────────────────────────
@@ -138,9 +138,26 @@ export async function runAgentForGroup(
}); });
const effectivePrompt = buildStructuredOutputPrompt(prompt, { const effectivePrompt = buildStructuredOutputPrompt(prompt, {
reviewerMode, reviewerMode,
gateTurnKind: pairedExecutionContext?.gateTurnKind,
requiresVisibleVerdict: pairedExecutionContext?.requiresVisibleVerdict,
}); });
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null; let pairedExecutionSummary: string | null = null;
let pairedReviewerVerdict: PairedReviewerVerdict | null = null;
let pairedReviewerVerdictNote: string | null = null;
let reviewerGateValidationFailed = false;
const formatReviewerGateFailureMessage = (): string => {
const taskId = pairedExecutionContext?.task.id ?? 'unknown-task';
const gateTurnKind =
pairedExecutionContext?.gateTurnKind ?? 'implementation_start';
return [
'Reviewer gate turn requires a visible structured verdict.',
`- Task: ${taskId}`,
`- Gate: ${gateTurnKind}`,
'- Allowed verdicts: done, done_with_concerns, blocked',
].join('\n');
};
const shouldHandoffToCodex = ( const shouldHandoffToCodex = (
reason: AgentTriggerReason, reason: AgentTriggerReason,
@@ -369,6 +386,41 @@ export async function runAgentForGroup(
sawVisibleOutput: true, sawVisibleOutput: true,
}; };
} }
if (
pairedExecutionContext?.requiresVisibleVerdict &&
(output.phase === undefined || output.phase === 'final')
) {
const structuredOutput = getStructuredAgentOutput(output);
const reviewerVerdict = structuredOutput?.verdict ?? null;
if (
structuredOutput?.visibility === 'silent' ||
reviewerVerdict === null
) {
reviewerGateValidationFailed = true;
pairedReviewerVerdict =
structuredOutput?.visibility === 'silent' ? 'silent' : null;
pairedReviewerVerdictNote = null;
const failureMessage = formatReviewerGateFailureMessage();
pairedExecutionSummary = failureMessage.slice(0, 500);
await onOutput({
status: 'success',
result: null,
output: {
visibility: 'public',
text: failureMessage,
},
phase: 'final',
});
return;
}
if (structuredOutput) {
pairedReviewerVerdict = reviewerVerdict;
pairedReviewerVerdictNote =
structuredOutput.visibility === 'public'
? structuredOutput.text
: null;
}
}
await onOutput(output); await onOutput(output);
} }
: undefined; : undefined;
@@ -863,6 +915,26 @@ export async function runAgentForGroup(
return 'error'; return 'error';
} }
if (
pairedExecutionContext?.requiresVisibleVerdict &&
(reviewerGateValidationFailed || pairedReviewerVerdict === null)
) {
if (!reviewerGateValidationFailed && onOutput) {
const failureMessage = formatReviewerGateFailureMessage();
pairedExecutionSummary = failureMessage.slice(0, 500);
await onOutput({
status: 'success',
result: null,
output: {
visibility: 'public',
text: failureMessage,
},
phase: 'final',
});
}
return 'error';
}
pairedExecutionStatus = 'succeeded'; pairedExecutionStatus = 'succeeded';
return 'success'; return 'success';
} finally { } finally {
@@ -871,6 +943,8 @@ export async function runAgentForGroup(
executionId: pairedExecutionContext.execution.id, executionId: pairedExecutionContext.execution.id,
status: pairedExecutionStatus, status: pairedExecutionStatus,
summary: pairedExecutionSummary, summary: pairedExecutionSummary,
reviewerVerdict: pairedReviewerVerdict,
reviewerVerdictNote: pairedReviewerVerdictNote,
}); });
} }
} }

View File

@@ -85,6 +85,18 @@ describe('parseStructuredOutputEnvelope', () => {
), ),
).toEqual({ visibility: 'public', text: 'hello' }); ).toEqual({ visibility: 'public', text: 'hello' });
}); });
it('parses a public envelope with a reviewer verdict', () => {
expect(
parseStructuredOutputEnvelope(
'{"ejclaw":{"visibility":"public","verdict":"done_with_concerns","text":"**DONE_WITH_CONCERNS**"}}',
),
).toEqual({
visibility: 'public',
verdict: 'done_with_concerns',
text: '**DONE_WITH_CONCERNS**',
});
});
}); });
describe('buildStructuredOutputPrompt', () => { describe('buildStructuredOutputPrompt', () => {
@@ -104,4 +116,25 @@ describe('buildStructuredOutputPrompt', () => {
'If you have not already emitted any visible progress, status update, or partial answer in this turn and you are only agreeing, mirroring, or restating without adding a concrete correction, risk, missing prerequisite, test gap, or code change, output only the JSON object.', 'If you have not already emitted any visible progress, status update, or partial answer in this turn and you are only agreeing, mirroring, or restating without adding a concrete correction, risk, missing prerequisite, test gap, or code change, output only the JSON object.',
); );
}); });
it('requires a visible structured verdict on reviewer gate turns', () => {
expect(
buildStructuredOutputPrompt('hello', {
reviewerMode: true,
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
}),
).toContain(
'This turn is a paired-room gate turn for implementation_start. Silent output is forbidden.',
);
expect(
buildStructuredOutputPrompt('hello', {
reviewerMode: true,
gateTurnKind: 'implementation_start',
requiresVisibleVerdict: true,
}),
).toContain(
'Allowed verdict values are: "done", "done_with_concerns", "blocked".',
);
});
}); });

View File

@@ -5,7 +5,7 @@ import {
CODEX_REVIEW_SERVICE_ID, CODEX_REVIEW_SERVICE_ID,
normalizeServiceId, normalizeServiceId,
} from './config.js'; } from './config.js';
import type { StructuredAgentOutput } from './types.js'; import type { PairedGateTurnKind, StructuredAgentOutput } from './types.js';
const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g; const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g;
const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/; const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/;
@@ -13,6 +13,12 @@ const STRUCTURED_SILENT_OUTPUT_PREFIX_PATTERN =
/^\s*\{\s*"ejclaw"\s*:\s*\{\s*"visibility"\s*:\s*"silent"/; /^\s*\{\s*"ejclaw"\s*:\s*\{\s*"visibility"\s*:\s*"silent"/;
export const STRUCTURED_SILENT_OUTPUT_ENVELOPE = export const STRUCTURED_SILENT_OUTPUT_ENVELOPE =
'{"ejclaw":{"visibility":"silent"}}'; '{"ejclaw":{"visibility":"silent"}}';
const STRUCTURED_PUBLIC_VERDICTS = new Set([
'done',
'done_with_concerns',
'blocked',
]);
const STRUCTURED_SILENT_VERDICTS = new Set(['silent']);
export function createSuppressToken(): string { export function createSuppressToken(): string {
return `__EJ_SUPPRESS_${randomBytes(12).toString('hex')}__`; return `__EJ_SUPPRESS_${randomBytes(12).toString('hex')}__`;
@@ -58,21 +64,46 @@ export function parseStructuredOutputEnvelope(
): StructuredAgentOutput | null { ): StructuredAgentOutput | null {
try { try {
const parsed = JSON.parse(rawText) as { const parsed = JSON.parse(rawText) as {
ejclaw?: { visibility?: unknown; text?: unknown }; ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown };
}; };
const envelope = parsed?.ejclaw; const envelope = parsed?.ejclaw;
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) { if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
return null; return null;
} }
if (envelope.visibility === 'silent') { if (envelope.visibility === 'silent') {
return { visibility: 'silent' }; if (
envelope.verdict !== undefined &&
!STRUCTURED_SILENT_VERDICTS.has(String(envelope.verdict))
) {
return null;
}
return {
visibility: 'silent',
verdict:
envelope.verdict === 'silent'
? ('silent' as const)
: undefined,
};
} }
if ( if (
envelope.visibility === 'public' && envelope.visibility === 'public' &&
typeof envelope.text === 'string' && typeof envelope.text === 'string' &&
envelope.text.length > 0 envelope.text.length > 0
) { ) {
return { visibility: 'public', text: envelope.text }; if (
envelope.verdict !== undefined &&
!STRUCTURED_PUBLIC_VERDICTS.has(String(envelope.verdict))
) {
return null;
}
return {
visibility: 'public',
text: envelope.text,
verdict:
typeof envelope.verdict === 'string'
? (envelope.verdict as 'done' | 'done_with_concerns' | 'blocked')
: undefined,
};
} }
} catch { } catch {
return null; return null;
@@ -85,6 +116,8 @@ export function buildStructuredOutputPrompt(
prompt: string, prompt: string,
options?: { options?: {
reviewerMode?: boolean; reviewerMode?: boolean;
gateTurnKind?: PairedGateTurnKind | null;
requiresVisibleVerdict?: boolean;
}, },
): string { ): string {
const lines = [ const lines = [
@@ -101,5 +134,17 @@ export function buildStructuredOutputPrompt(
); );
} }
if (options?.reviewerMode && options.requiresVisibleVerdict) {
lines.push(
`This turn is a paired-room gate turn for ${options.gateTurnKind ?? 'implementation_start'}. Silent output is forbidden.`,
);
lines.push(
'Your final answer must be a structured public JSON envelope with a reviewer verdict: {"ejclaw":{"visibility":"public","verdict":"done","text":"**DONE** ..."}}',
);
lines.push(
'Allowed verdict values are: "done", "done_with_concerns", "blocked".',
);
}
return `${lines.join('\n')}\n\n${prompt}`; return `${lines.join('\n')}\n\n${prompt}`;
} }

View File

@@ -248,6 +248,79 @@ describe('paired execution context', () => {
}); });
}); });
it('blocks owner execution when a gate turn lacks a visible reviewer verdict', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-gate-block',
roomRoleContext: ownerContext,
});
expect(result?.blockMessage).toContain(
'A visible reviewer verdict is required before the owner can proceed with this gate.',
);
expect(result?.blockMessage).toContain('- Gate: implementation_start');
expect(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).not.toHaveBeenCalled();
});
it('allows owner execution when the current gate has an approved reviewer verdict', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: 'done_with_concerns',
reviewer_verdict_at: '2026-03-28T00:01:00.000Z',
reviewer_verdict_note: '**DONE_WITH_CONCERNS** gate okay',
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-gate-allowed',
roomRoleContext: ownerContext,
});
expect(result?.blockMessage).toBeUndefined();
expect(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).toHaveBeenCalledWith('task-1');
});
it('does not self-cancel when the same execution resumes', () => { it('does not self-cancel when the same execution resumes', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1', id: 'task-1',
@@ -294,6 +367,57 @@ describe('paired execution context', () => {
}); });
}); });
it('records the latest structured reviewer verdict on a fresh gate turn completion', () => {
vi.mocked(db.getPairedExecutionById).mockReturnValue({
id: 'run-review:codex-review',
task_id: 'task-1',
service_id: 'codex-review',
role: 'reviewer',
workspace_id: 'task-1:reviewer',
checkpoint_fingerprint: null,
status: 'running',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: '2026-03-28T00:00:00.000Z',
completed_at: null,
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
gate_turn_kind: 'implementation_start',
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
completePairedExecutionContext({
executionId: 'run-review:codex-review',
status: 'succeeded',
reviewerVerdict: 'done_with_concerns',
reviewerVerdictNote: '**DONE_WITH_CONCERNS** gate okay',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
reviewer_verdict: 'done_with_concerns',
reviewer_verdict_note: '**DONE_WITH_CONCERNS** gate okay',
}),
);
});
it('plans reviewer recovery from the latest review checkpoint', () => { it('plans reviewer recovery from the latest review checkpoint', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1', id: 'task-1',

View File

@@ -32,12 +32,51 @@ import type {
PairedArtifactType, PairedArtifactType,
PairedEventType, PairedEventType,
PairedExecution, PairedExecution,
PairedGateTurnKind,
PairedReviewerVerdict,
PairedTask, PairedTask,
PairedWorkspace, PairedWorkspace,
RegisteredGroup, RegisteredGroup,
RoomRoleContext, RoomRoleContext,
} from './types.js'; } from './types.js';
const APPROVED_REVIEWER_GATE_VERDICTS = new Set<PairedReviewerVerdict>([
'done',
'done_with_concerns',
]);
const VISIBLE_REVIEWER_GATE_VERDICTS = new Set<PairedReviewerVerdict>([
'done',
'done_with_concerns',
'blocked',
]);
const REVIEWER_GATE_REQUIRED_MESSAGE =
'A visible reviewer verdict is required before the owner can proceed with this gate.';
function getGateTurnKind(task: PairedTask): PairedGateTurnKind | null {
return task.gate_turn_kind ?? null;
}
function hasVisibleReviewerGateVerdict(
verdict: PairedReviewerVerdict | null | undefined,
): boolean {
return verdict ? VISIBLE_REVIEWER_GATE_VERDICTS.has(verdict) : false;
}
function allowsOwnerToProceedForGate(
verdict: PairedReviewerVerdict | null | undefined,
): boolean {
return verdict ? APPROVED_REVIEWER_GATE_VERDICTS.has(verdict) : false;
}
function formatOwnerGateBlockedMessage(task: PairedTask): string {
return [
REVIEWER_GATE_REQUIRED_MESSAGE,
`- Task: ${task.id}`,
`- Gate: ${task.gate_turn_kind ?? 'implementation_start'}`,
`- Reviewer verdict: ${task.reviewer_verdict ?? 'missing'}`,
].join('\n');
}
function resolveCanonicalSourceRef(workDir: string): string { function resolveCanonicalSourceRef(workDir: string): string {
try { try {
const head = execFileSync('git', ['rev-parse', 'HEAD'], { const head = execFileSync('git', ['rev-parse', 'HEAD'], {
@@ -99,6 +138,10 @@ function ensureActiveTask(
risk_level: 'low', risk_level: 'low',
plan_status: 'not_requested', plan_status: 'not_requested',
review_requested_at: null, review_requested_at: null,
gate_turn_kind: null,
reviewer_verdict: null,
reviewer_verdict_at: null,
reviewer_verdict_note: null,
status: 'active', status: 'active',
created_at: now, created_at: now,
updated_at: now, updated_at: now,
@@ -258,6 +301,8 @@ export interface PreparedPairedExecutionContext {
execution: PairedExecution; execution: PairedExecution;
workspace: PairedWorkspace | null; workspace: PairedWorkspace | null;
envOverrides: Record<string, string>; envOverrides: Record<string, string>;
gateTurnKind?: PairedGateTurnKind | null;
requiresVisibleVerdict?: boolean;
blockMessage?: string; blockMessage?: string;
} }
@@ -285,19 +330,31 @@ export function preparePairedExecutionContext(args: {
return undefined; return undefined;
} }
const latestTask = getPairedTaskById(task.id) ?? task;
const gateTurnKind = getGateTurnKind(latestTask);
const requiresVisibleVerdict =
roomRoleContext.role === 'reviewer' &&
!!gateTurnKind &&
!hasVisibleReviewerGateVerdict(latestTask.reviewer_verdict);
let workspace: PairedWorkspace | null = null; let workspace: PairedWorkspace | null = null;
let blockMessage: string | undefined; let blockMessage: string | undefined;
const now = new Date().toISOString(); const now = new Date().toISOString();
if (roomRoleContext.role === 'owner') { if (
workspace = provisionOwnerWorkspaceForPairedTask(task.id); roomRoleContext.role === 'owner' &&
gateTurnKind &&
!allowsOwnerToProceedForGate(latestTask.reviewer_verdict)
) {
blockMessage = formatOwnerGateBlockedMessage(latestTask);
} else if (roomRoleContext.role === 'owner') {
workspace = provisionOwnerWorkspaceForPairedTask(latestTask.id);
} else { } else {
const reviewerWorkspace = prepareReviewerWorkspaceForExecution(task); const reviewerWorkspace = prepareReviewerWorkspaceForExecution(latestTask);
workspace = reviewerWorkspace.workspace; workspace = reviewerWorkspace.workspace;
blockMessage = reviewerWorkspace.blockMessage; blockMessage = reviewerWorkspace.blockMessage;
const latestTask = getPairedTaskById(task.id) ?? task; const refreshedTask = getPairedTaskById(latestTask.id) ?? latestTask;
if (workspace && latestTask.status === 'review_ready') { if (workspace && refreshedTask.status === 'review_ready') {
updatePairedTask(task.id, { updatePairedTask(latestTask.id, {
status: 'in_review', status: 'in_review',
updated_at: now, updated_at: now,
}); });
@@ -307,16 +364,16 @@ export function preparePairedExecutionContext(args: {
const execution = ensureExecutionRecord({ const execution = ensureExecutionRecord({
runId, runId,
roomRoleContext, roomRoleContext,
task, task: latestTask,
workspace: workspace ?? undefined, workspace: workspace ?? undefined,
checkpointFingerprint: resolveExecutionCheckpointFingerprint({ checkpointFingerprint: resolveExecutionCheckpointFingerprint({
taskId: task.id, taskId: latestTask.id,
role: roomRoleContext.role, role: roomRoleContext.role,
workspace, workspace,
}), }),
}); });
const envOverrides: Record<string, string> = { const envOverrides: Record<string, string> = {
EJCLAW_PAIRED_TASK_ID: task.id, EJCLAW_PAIRED_TASK_ID: latestTask.id,
EJCLAW_PAIRED_EXECUTION_ID: execution.id, EJCLAW_PAIRED_EXECUTION_ID: execution.id,
EJCLAW_PAIRED_ROLE: roomRoleContext.role, EJCLAW_PAIRED_ROLE: roomRoleContext.role,
}; };
@@ -326,13 +383,18 @@ export function preparePairedExecutionContext(args: {
} }
if (roomRoleContext.role === 'reviewer') { if (roomRoleContext.role === 'reviewer') {
envOverrides.EJCLAW_REVIEWER_RUNTIME = '1'; envOverrides.EJCLAW_REVIEWER_RUNTIME = '1';
if (requiresVisibleVerdict && gateTurnKind) {
envOverrides.EJCLAW_PAIRED_GATE_TURN_KIND = gateTurnKind;
}
} }
return { return {
task: getPairedTaskById(task.id) ?? task, task: getPairedTaskById(latestTask.id) ?? latestTask,
execution, execution,
workspace, workspace,
envOverrides, envOverrides,
gateTurnKind,
requiresVisibleVerdict,
blockMessage, blockMessage,
}; };
} }
@@ -728,6 +790,8 @@ export function completePairedExecutionContext(args: {
executionId: string; executionId: string;
status: 'succeeded' | 'failed'; status: 'succeeded' | 'failed';
summary?: string | null; summary?: string | null;
reviewerVerdict?: PairedReviewerVerdict | null;
reviewerVerdictNote?: string | null;
}): void { }): void {
const execution = getPairedExecutionById(args.executionId); const execution = getPairedExecutionById(args.executionId);
if (!execution) { if (!execution) {
@@ -764,6 +828,21 @@ export function completePairedExecutionContext(args: {
return; return;
} }
const task = getPairedTaskById(completedExecution.task_id);
if (
task &&
completedExecution.role === 'reviewer' &&
task.gate_turn_kind &&
args.reviewerVerdict
) {
updatePairedTask(task.id, {
reviewer_verdict: args.reviewerVerdict,
reviewer_verdict_at: completedAt,
reviewer_verdict_note: args.reviewerVerdictNote ?? null,
updated_at: completedAt,
});
}
maybeAutoRequestReviewForCompletedExecution({ maybeAutoRequestReviewForCompletedExecution({
execution: completedExecution, execution: completedExecution,
status: args.status, status: args.status,

View File

@@ -50,6 +50,14 @@ export type PairedPlanStatus =
| 'approved' | 'approved'
| 'changes_requested'; | 'changes_requested';
export type PairedGateTurnKind = 'implementation_start' | 'commit' | 'push';
export type PairedReviewerVerdict =
| 'done'
| 'done_with_concerns'
| 'blocked'
| 'silent';
export type PairedExecutionStatus = export type PairedExecutionStatus =
| 'pending' | 'pending'
| 'running' | 'running'
@@ -114,6 +122,10 @@ export interface PairedTask {
risk_level: PairedRiskLevel; risk_level: PairedRiskLevel;
plan_status: PairedPlanStatus; plan_status: PairedPlanStatus;
review_requested_at: string | null; review_requested_at: string | null;
gate_turn_kind?: PairedGateTurnKind | null;
reviewer_verdict?: PairedReviewerVerdict | null;
reviewer_verdict_at?: string | null;
reviewer_verdict_note?: string | null;
status: PairedTaskStatus; status: PairedTaskStatus;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -184,9 +196,11 @@ export type StructuredAgentOutput =
| { | {
visibility: 'public'; visibility: 'public';
text: string; text: string;
verdict?: Exclude<PairedReviewerVerdict, 'silent'>;
} }
| { | {
visibility: 'silent'; visibility: 'silent';
verdict?: 'silent';
}; };
export function normalizeAgentOutputPhase( export function normalizeAgentOutputPhase(