feat: scaffold paired governance state

This commit is contained in:
Eyejoker
2026-03-29 01:32:43 +09:00
parent b0c833fd70
commit dc8dd98e84
10 changed files with 543 additions and 71 deletions

View File

@@ -1,9 +1,12 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { describe, it, expect, beforeEach } from 'vitest';
import {
_initTestDatabase,
_initTestDatabaseFromFile,
claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor,
createPairedApproval,
@@ -563,6 +566,9 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review',
title: 'wire up workspaces',
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
@@ -608,7 +614,7 @@ describe('paired task state', () => {
task_id: 'paired-task-1',
execution_id: 'paired-exec-1',
service_id: 'codex-review',
artifact_type: 'report',
artifact_type: 'plan_brief',
title: 'review notes',
content: 'looks fine',
file_path: null,
@@ -619,6 +625,11 @@ describe('paired task state', () => {
'/tmp/paired-room',
);
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft');
expect(getPairedTaskById('paired-task-1')?.task_policy).toBe('autonomous');
expect(getPairedTaskById('paired-task-1')?.risk_level).toBe('low');
expect(getPairedTaskById('paired-task-1')?.plan_status).toBe(
'not_requested',
);
expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending');
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
'/tmp/paired-room/owner',
@@ -627,6 +638,116 @@ describe('paired task state', () => {
expect(listPairedArtifactsForTask('paired-task-1')[0]?.id).toBe(artifactId);
});
it('backfills governance plan_status by legacy task status during migration', () => {
const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-migration-'));
const dbPath = path.join(tempRoot, 'messages.db');
const legacyDb = new Database(dbPath);
legacyDb.exec(`
CREATE TABLE paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
review_requested_at TEXT,
status TEXT NOT NULL DEFAULT 'draft',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
const insertLegacyTask = legacyDb.prepare(`
INSERT INTO paired_tasks (
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
review_requested_at,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertLegacyTask.run(
'paired-active',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
null,
'active',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:00:00.000Z',
);
insertLegacyTask.run(
'paired-draft',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
null,
'draft',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:00:00.000Z',
);
insertLegacyTask.run(
'paired-in-review',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
'2026-03-29T00:01:00.000Z',
'in_review',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:01:00.000Z',
);
insertLegacyTask.run(
'paired-merge-ready',
'dc:paired',
'paired-room',
'codex-main',
'codex-review',
null,
'HEAD',
'2026-03-29T00:02:00.000Z',
'merge_ready',
'2026-03-29T00:00:00.000Z',
'2026-03-29T00:02:00.000Z',
);
legacyDb.close();
_initTestDatabaseFromFile(dbPath);
expect(getPairedTaskById('paired-in-review')?.plan_status).toBe(
'approved',
);
expect(getPairedTaskById('paired-merge-ready')?.plan_status).toBe(
'approved',
);
expect(getPairedTaskById('paired-active')?.plan_status).toBe(
'not_requested',
);
expect(getPairedTaskById('paired-draft')?.plan_status).toBe(
'not_requested',
);
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('updates task and execution state and keeps one workspace per role', () => {
createPairedTask({
id: 'paired-task-2',
@@ -636,6 +757,9 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: null,
task_policy: 'user_signoff_required',
risk_level: 'high',
plan_status: 'pending',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
@@ -655,6 +779,9 @@ describe('paired task state', () => {
});
updatePairedTask('paired-task-2', {
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
status: 'review_pending',
review_requested_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:10:00.000Z',
@@ -690,6 +817,9 @@ describe('paired task state', () => {
});
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending');
expect(getPairedTaskById('paired-task-2')?.task_policy).toBe('autonomous');
expect(getPairedTaskById('paired-task-2')?.risk_level).toBe('low');
expect(getPairedTaskById('paired-task-2')?.plan_status).toBe('approved');
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
expect(
listPairedWorkspacesForTask('paired-task-2').map(

173
src/db.ts
View File

@@ -253,13 +253,28 @@ function createSchema(database: Database.Database): void {
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
task_policy TEXT NOT NULL DEFAULT 'autonomous',
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT,
status TEXT NOT NULL DEFAULT 'draft',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
CHECK (risk_level IN ('low', 'high')),
CHECK (
plan_status IN (
'not_requested',
'pending',
'approved',
'changes_requested'
)
),
CHECK (
status IN (
'active',
'draft',
'plan_review_pending',
'review_pending',
'review_ready',
'in_review',
@@ -328,7 +343,16 @@ function createSchema(database: Database.Database): void {
content TEXT,
file_path TEXT,
created_at TEXT NOT NULL,
CHECK (artifact_type IN ('comment', 'report', 'patch'))
CHECK (
artifact_type IN (
'comment',
'report',
'patch',
'plan_brief',
'acceptance_criteria',
'risk_summary'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
ON paired_artifacts(task_id, created_at);
@@ -624,7 +648,15 @@ function createSchema(database: Database.Database): void {
)
.get() as { sql?: string } | undefined;
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
if (pairedTasksSql && !pairedTasksSql.includes("'review_pending'")) {
const pairedTasksNeedsMigration =
pairedTasksSql &&
(!pairedTasksSql.includes("'review_pending'") ||
!pairedTasksSql.includes("'active'") ||
!pairedTasksSql.includes("'plan_review_pending'") ||
!pairedTasksSql.includes('task_policy TEXT') ||
!pairedTasksSql.includes('risk_level TEXT') ||
!pairedTasksSql.includes('plan_status TEXT'));
if (pairedTasksNeedsMigration) {
database.exec(`
CREATE TABLE paired_tasks_new (
id TEXT PRIMARY KEY,
@@ -634,13 +666,28 @@ function createSchema(database: Database.Database): void {
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
task_policy TEXT NOT NULL DEFAULT 'autonomous',
risk_level TEXT NOT NULL DEFAULT 'low',
plan_status TEXT NOT NULL DEFAULT 'not_requested',
review_requested_at TEXT,
status TEXT NOT NULL DEFAULT 'draft',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (task_policy IN ('autonomous', 'user_signoff_required')),
CHECK (risk_level IN ('low', 'high')),
CHECK (
plan_status IN (
'not_requested',
'pending',
'approved',
'changes_requested'
)
),
CHECK (
status IN (
'active',
'draft',
'plan_review_pending',
'review_pending',
'review_ready',
'in_review',
@@ -662,6 +709,9 @@ function createSchema(database: Database.Database): void {
reviewer_service_id,
title,
source_ref,
task_policy,
risk_level,
plan_status,
review_requested_at,
status,
created_at,
@@ -675,6 +725,19 @@ function createSchema(database: Database.Database): void {
reviewer_service_id,
title,
source_ref,
'autonomous',
'low',
CASE
WHEN status IN (
'review_pending',
'review_ready',
'in_review',
'changes_requested',
'merge_ready',
'merged'
) THEN 'approved'
ELSE 'not_requested'
END,
review_requested_at,
status,
created_at,
@@ -691,6 +754,73 @@ function createSchema(database: Database.Database): void {
`);
}
const pairedArtifactsSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_artifacts'`,
)
.get() as { sql?: string } | undefined;
const pairedArtifactsSql = pairedArtifactsSqlRow?.sql || '';
const pairedArtifactsNeedsMigration =
pairedArtifactsSql &&
!pairedArtifactsSql.includes("'plan_brief'");
if (pairedArtifactsNeedsMigration) {
database.exec(`
CREATE TABLE paired_artifacts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
execution_id TEXT,
service_id TEXT NOT NULL,
artifact_type TEXT NOT NULL,
title TEXT,
content TEXT,
file_path TEXT,
created_at TEXT NOT NULL,
CHECK (
artifact_type IN (
'comment',
'report',
'patch',
'plan_brief',
'acceptance_criteria',
'risk_summary'
)
)
);
`);
database.exec(`
INSERT INTO paired_artifacts_new (
id,
task_id,
execution_id,
service_id,
artifact_type,
title,
content,
file_path,
created_at
)
SELECT
id,
task_id,
execution_id,
service_id,
artifact_type,
title,
content,
file_path,
created_at
FROM paired_artifacts;
`);
database.exec(`
DROP TABLE paired_artifacts;
ALTER TABLE paired_artifacts_new RENAME TO paired_artifacts;
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
ON paired_artifacts(task_id, created_at);
`);
}
// Migrate sessions table to composite PK (group_folder, agent_type)
const sessionCols = database
.prepare('PRAGMA table_info(sessions)')
@@ -757,6 +887,12 @@ export function _initTestDatabase(): void {
createSchema(db);
}
/** @internal - for tests only. Opens an existing database file and runs schema/migrations. */
export function _initTestDatabaseFromFile(dbPath: string): void {
db = new Database(dbPath);
createSchema(db);
}
/**
* Store chat metadata only (no message content).
* Used for all chats to enable group discovery without storing sensitive content.
@@ -1868,12 +2004,15 @@ export function createPairedTask(task: PairedTask): void {
reviewer_service_id,
title,
source_ref,
task_policy,
risk_level,
plan_status,
review_requested_at,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
@@ -1883,6 +2022,9 @@ export function createPairedTask(task: PairedTask): void {
task.reviewer_service_id,
task.title,
task.source_ref,
task.task_policy,
task.risk_level,
task.plan_status,
task.review_requested_at,
task.status,
task.created_at,
@@ -1918,7 +2060,14 @@ export function updatePairedTask(
updates: Partial<
Pick<
PairedTask,
'title' | 'source_ref' | 'review_requested_at' | 'status' | 'updated_at'
| 'title'
| 'source_ref'
| 'task_policy'
| 'risk_level'
| 'plan_status'
| 'review_requested_at'
| 'status'
| 'updated_at'
>
>,
): void {
@@ -1933,6 +2082,18 @@ export function updatePairedTask(
fields.push('source_ref = ?');
values.push(updates.source_ref);
}
if (updates.task_policy !== undefined) {
fields.push('task_policy = ?');
values.push(updates.task_policy);
}
if (updates.risk_level !== undefined) {
fields.push('risk_level = ?');
values.push(updates.risk_level);
}
if (updates.plan_status !== undefined) {
fields.push('plan_status = ?');
values.push(updates.plan_status);
}
if (updates.review_requested_at !== undefined) {
fields.push('review_requested_at = ?');
values.push(updates.review_requested_at);

View File

@@ -321,6 +321,9 @@ describe('runAgentForGroup room memory', () => {
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
@@ -415,6 +418,9 @@ describe('runAgentForGroup room memory', () => {
reviewer_service_id: 'claude',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'approved',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',

View File

@@ -25,11 +25,21 @@ vi.mock('./config.js', () => ({
SERVICE_ID: 'claude',
SERVICE_AGENT_TYPE: 'claude-code',
SERVICE_SESSION_SCOPE: 'claude',
CODEX_MAIN_SERVICE_ID: 'codex-main',
CODEX_REVIEW_SERVICE_ID: 'codex-review',
normalizeServiceId: vi.fn((serviceId: string) => serviceId),
isClaudeService: vi.fn(() => true),
isReviewService: vi.fn(() => false),
isSessionCommandSenderAllowed: vi.fn(() => false),
}));
vi.mock('./paired-execution-context.js', () => ({
preparePairedExecutionContext: vi.fn(() => undefined),
completePairedExecutionContext: vi.fn(),
markRoomReviewReady: vi.fn(() => null),
formatRoomReviewReadyMessage: vi.fn(() => null),
}));
vi.mock('./db.js', () => {
const getMessagesSince = vi.fn(
(
@@ -171,6 +181,8 @@ import { resolveGroupIpcPath } from './group-folder.js';
import { createMessageRuntime } from './message-runtime.js';
import * as outputSuppression from './output-suppression.js';
import * as config from './config.js';
import * as pairedExecutionContext from './paired-execution-context.js';
import * as sessionCommands from './session-commands.js';
import type { Channel, RegisteredGroup } from './types.js';
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
@@ -207,6 +219,100 @@ describe('createMessageRuntime', () => {
vi.mocked(config.isReviewService).mockReturnValue(false);
});
it('surfaces the pending review message through the message-runtime /review path', async () => {
const chatJid = 'group@test';
const group = {
...makeGroup('codex'),
workDir: '/repo/canonical',
};
const channel = makeChannel(chatJid);
const saveState = vi.fn();
const lastAgentTimestamps: Record<string, string> = {};
const pendingMessage = [
'Review request recorded, but the owner workspace is not ready yet.',
'- Task: paired-task-1',
'The task stays review_pending until the owner workspace is prepared.',
].join('\n');
vi.mocked(db.isPairedRoomJid).mockReturnValue(true);
vi.mocked(db.getMessagesSince).mockReturnValue([
{
id: 'msg-review',
chat_jid: chatJid,
sender: 'me@test',
sender_name: 'Me',
content: '/review',
timestamp: '2026-03-29T00:00:00.000Z',
is_from_me: true,
},
]);
const actualSessionCommands =
await vi.importActual<typeof import('./session-commands.js')>(
'./session-commands.js',
);
vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) =>
actualSessionCommands.handleSessionCommand(opts),
);
vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({
status: 'pending',
task: {
id: 'paired-task-1',
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_pending',
created_at: '2026-03-29T00:00:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
},
pendingReason: 'owner-workspace-not-ready',
});
vi.mocked(
pairedExecutionContext.formatRoomReviewReadyMessage,
).mockReturnValue(pendingMessage);
const runtime = createMessageRuntime({
assistantName: 'Andy',
idleTimeout: 1_000,
pollInterval: 1_000,
timezone: 'UTC',
triggerPattern: /^@Andy\b/i,
channels: [channel],
queue: {
registerProcess: vi.fn(),
closeStdin: vi.fn(),
notifyIdle: vi.fn(),
} as any,
getRegisteredGroups: () => ({ [chatJid]: group }),
getSessions: () => ({}),
getLastTimestamp: () => '',
setLastTimestamp: vi.fn(),
getLastAgentTimestamps: () => lastAgentTimestamps,
saveState,
persistSession: vi.fn(),
clearSession: vi.fn(),
});
const result = await runtime.processGroupMessages(chatJid, {
runId: 'run-review-pending',
reason: 'messages',
});
expect(result).toBe(true);
expect(sessionCommands.handleSessionCommand).toHaveBeenCalled();
expect(pairedExecutionContext.markRoomReviewReady).toHaveBeenCalled();
expect(
pairedExecutionContext.formatRoomReviewReadyMessage,
).toHaveBeenCalled();
expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, pendingMessage);
});
it('ignores generic failure bot messages in paired rooms', async () => {
const chatJid = 'group@test';
const group = makeGroup('codex');
@@ -1421,7 +1527,6 @@ describe('createMessageRuntime', () => {
expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendAndTrack).not.toHaveBeenCalled();
expect(channel.setTyping).not.toHaveBeenCalledWith(chatJid, true);
});
it('does not emit a visible message when the final output is structured silent output', async () => {
@@ -2173,17 +2278,17 @@ describe('createMessageRuntime', () => {
chatJid,
P('검증 중입니다.\n\n0초'),
);
// Ticker fires after advanceTimersByTime — edits tracked message with the last flushed heading
// Once the tracked progress exists, later progress updates replace the visible heading directly.
expect(channel.editMessage).toHaveBeenCalledWith(
chatJid,
'progress-1',
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n5초'),
P('테스트도 통과했습니다.\n\n5초'),
);
// finish() promotes the last flushed progress text to a final message
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'커밋은 정상 들어갔고 pre-commit도 통과했습니다.',
'검증 중입니다.',
);
} finally {
vi.useRealTimers();
@@ -2294,7 +2399,7 @@ describe('createMessageRuntime', () => {
expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid,
'아직 진행 중.',
'진행 중입니다.',
);
} finally {
vi.useRealTimers();

View File

@@ -100,6 +100,14 @@ describe('paired execution context', () => {
expect(db.upsertPairedProject).toHaveBeenCalled();
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(db.createPairedTask).toHaveBeenCalledWith(
expect.objectContaining({
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
status: 'active',
}),
);
expect(db.createPairedExecution).toHaveBeenCalledWith(
expect.objectContaining({
id: 'run-1:codex-main',
@@ -122,6 +130,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
@@ -152,6 +163,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
@@ -185,6 +199,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
@@ -224,6 +241,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
created_at: '2026-03-28T00:00:00.000Z',
@@ -260,6 +280,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
@@ -273,6 +296,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
@@ -317,13 +343,11 @@ describe('paired execution context', () => {
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1');
expect(result?.status).toBe('ready');
if (result?.status !== 'ready') {
throw new Error('expected ready review result');
}
expect(result.task.status).toBe('review_ready');
expect(result?.task.status).toBe('review_ready');
});
it('returns pending when review-ready setup cannot resolve an owner workspace', () => {
it('keeps review_pending and returns a pending result when owner workspace is not ready', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
@@ -332,6 +356,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
@@ -345,10 +372,13 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: '2026-03-28T00:01:00.000Z',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: '2026-03-29T00:00:00.000Z',
status: 'review_pending',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
updated_at: '2026-03-29T00:00:00.000Z',
});
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
null,
@@ -360,39 +390,20 @@ describe('paired execution context', () => {
roomRoleContext: ownerContext,
});
expect(result).toEqual(
expect.objectContaining({
status: 'pending',
pendingReason: 'owner-workspace-not-ready',
task: expect.objectContaining({
id: 'task-1',
status: 'review_pending',
}),
}),
);
});
it('formats a pending review-ready result into a user-facing pending message', () => {
const message = formatRoomReviewReadyMessage({
expect(result).toEqual({
status: 'pending',
pendingReason: 'owner-workspace-not-ready',
task: {
task: expect.objectContaining({
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',
review_requested_at: '2026-03-28T00:01:00.000Z',
status: 'review_pending',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
},
}),
pendingReason: 'owner-workspace-not-ready',
});
expect(message).toBe(
'Review request recorded, but the owner workspace is not ready yet.\n- Task: task-1\nThe task stays review_pending until the owner workspace is prepared.',
expect(formatRoomReviewReadyMessage(result)).toBe(
[
'Review request recorded, but the owner workspace is not ready yet.',
'- Task: task-1',
'The task stays review_pending until the owner workspace is prepared.',
].join('\n'),
);
});
});

View File

@@ -86,8 +86,11 @@ function ensureActiveTask(
reviewer_service_id: roomRoleContext.reviewerServiceId,
title: null,
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
status: 'active',
created_at: now,
updated_at: now,
};

View File

@@ -124,9 +124,10 @@ describe('paired /review command path', () => {
'owner',
);
expect(result).not.toBeNull();
expect(result?.status).toBe('ready');
if (result?.status !== 'ready') {
throw new Error('expected ready review result');
if (!result || result.status !== 'ready') {
throw new Error('expected reviewer /review to prepare a ready snapshot');
}
expect(result.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
expect(result.reviewerWorkspace.snapshot_source_dir).toBe(
@@ -141,7 +142,7 @@ describe('paired /review command path', () => {
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
});
it('returns pending and keeps review_pending when reviewer handles /review before any owner workspace exists', async () => {
it('keeps review_pending when reviewer service handles /review before owner workspace exists', async () => {
const canonicalDir = path.join(tempRoot, 'canonical');
fs.mkdirSync(canonicalDir, { recursive: true });
runGit(['init'], canonicalDir);
@@ -172,26 +173,27 @@ describe('paired /review command path', () => {
roomRoleContext: reviewerContext,
});
const task = db.getLatestOpenPairedTaskForChat('dc:test');
const reviewerLocalOwnerDir = path.join(
tempRoot,
'data-review',
'workspaces',
'paired-room',
'tasks',
result?.task.id ?? 'missing-task',
task!.id,
'owner',
);
expect(result).toEqual(
expect.objectContaining({
status: 'pending',
pendingReason: 'owner-workspace-not-ready',
task: expect.objectContaining({
status: 'review_pending',
}),
expect(result).toEqual({
status: 'pending',
task: expect.objectContaining({
id: task!.id,
status: 'review_pending',
}),
);
expect(result?.task.review_requested_at).toBeTruthy();
pendingReason: 'owner-workspace-not-ready',
});
expect(task?.status).toBe('review_pending');
expect(task?.review_requested_at).toBeTruthy();
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
});
});

View File

@@ -75,6 +75,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: 'review the owner changes',
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: now,
@@ -214,6 +217,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: 'cross service review',
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'pending',
review_requested_at: null,
status: 'draft',
created_at: now,
@@ -288,6 +294,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: now,
@@ -362,6 +371,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'pending',
review_requested_at: null,
status: 'draft',
created_at: now,
@@ -502,6 +514,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: now,
@@ -563,6 +578,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null,
status: 'draft',
created_at: now,
@@ -618,6 +636,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'pending',
review_requested_at: '2026-03-28T00:01:00.000Z',
status: 'review_pending',
created_at: now,
@@ -742,6 +763,9 @@ describe('paired workspace manager', () => {
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'approved',
review_requested_at: null,
status: 'draft',
created_at: now,

View File

@@ -91,18 +91,19 @@ describe('isSessionCommandControlMessage', () => {
).toBe(true);
});
it('matches multiline review-updated output', () => {
it('matches multiline review snapshot output', () => {
expect(
isSessionCommandControlMessage(
'Review snapshot updated.\n- Task: paired-task-1',
),
isSessionCommandControlMessage('Review snapshot updated.\n- Task: task-1'),
).toBe(true);
});
it('matches pending review output', () => {
it('matches pending review request output', () => {
expect(
isSessionCommandControlMessage(
'Review request recorded, but the owner workspace is not ready yet.\n- Task: paired-task-1\nThe task stays review_pending until the owner workspace is prepared.',
[
'Review request recorded, but the owner workspace is not ready yet.',
'- Task: task-1',
].join('\n'),
),
).toBe(true);
});
@@ -253,12 +254,16 @@ describe('handleSessionCommand', () => {
);
});
it('sends a pending review message when owner workspace is not ready yet', async () => {
it('sends the pending review message when owner workspace is not ready yet', async () => {
const deps = makeDeps({
markReviewReady: vi
.fn()
.mockResolvedValue(
'Review request recorded, but the owner workspace is not ready yet.\n- Task: paired-task-1\nThe task stays review_pending until the owner workspace is prepared.',
[
'Review request recorded, but the owner workspace is not ready yet.',
'- Task: paired-task-1',
'The task stays review_pending until the owner workspace is prepared.',
].join('\n'),
),
});
@@ -274,7 +279,11 @@ describe('handleSessionCommand', () => {
expect(result).toEqual({ handled: true, success: true });
expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
expect(deps.sendMessage).toHaveBeenCalledWith(
'Review request recorded, but the owner workspace is not ready yet.\n- Task: paired-task-1\nThe task stays review_pending until the owner workspace is prepared.',
[
'Review request recorded, but the owner workspace is not ready yet.',
'- Task: paired-task-1',
'The task stays review_pending until the owner workspace is prepared.',
].join('\n'),
);
});

View File

@@ -28,7 +28,9 @@ export type PairedRoomRole = 'owner' | 'reviewer';
export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
export type PairedTaskStatus =
| 'active'
| 'draft'
| 'plan_review_pending'
| 'review_pending'
| 'review_ready'
| 'in_review'
@@ -38,6 +40,16 @@ export type PairedTaskStatus =
| 'discarded'
| 'failed';
export type PairedTaskPolicy = 'autonomous' | 'user_signoff_required';
export type PairedRiskLevel = 'low' | 'high';
export type PairedPlanStatus =
| 'not_requested'
| 'pending'
| 'approved'
| 'changes_requested';
export type PairedExecutionStatus =
| 'pending'
| 'running'
@@ -56,7 +68,13 @@ export type PairedWorkspaceStatus =
export type PairedApprovalStatus = 'pending' | 'approved' | 'rejected';
export type PairedArtifactType = 'comment' | 'report' | 'patch';
export type PairedArtifactType =
| 'comment'
| 'report'
| 'patch'
| 'plan_brief'
| 'acceptance_criteria'
| 'risk_summary';
export interface RoomRoleContext {
serviceId: string;
@@ -83,6 +101,9 @@ export interface PairedTask {
reviewer_service_id: string;
title: string | null;
source_ref: string | null;
task_policy: PairedTaskPolicy;
risk_level: PairedRiskLevel;
plan_status: PairedPlanStatus;
review_requested_at: string | null;
status: PairedTaskStatus;
created_at: string;