feat: enforce paired reviewer gate verdicts
This commit is contained in:
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
66
src/db.ts
66
src/db.ts
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
expect.objectContaining({
|
||||||
executionId: 'run-room-role:claude',
|
executionId: 'run-room-role:claude',
|
||||||
status: 'succeeded',
|
status: 'succeeded',
|
||||||
summary: 'ok',
|
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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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".',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
14
src/types.ts
14
src/types.ts
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user