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 fs from 'fs';
import path from 'path';
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import { import {
_initTestDatabase, _initTestDatabase,
_initTestDatabaseFromFile,
claimServiceHandoff, claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor, completeServiceHandoffAndAdvanceTargetCursor,
createPairedApproval, createPairedApproval,
@@ -563,6 +566,9 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: 'wire up workspaces', title: 'wire up workspaces',
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null, review_requested_at: null,
status: 'draft', status: 'draft',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -608,7 +614,7 @@ describe('paired task state', () => {
task_id: 'paired-task-1', task_id: 'paired-task-1',
execution_id: 'paired-exec-1', execution_id: 'paired-exec-1',
service_id: 'codex-review', service_id: 'codex-review',
artifact_type: 'report', artifact_type: 'plan_brief',
title: 'review notes', title: 'review notes',
content: 'looks fine', content: 'looks fine',
file_path: null, file_path: null,
@@ -619,6 +625,11 @@ describe('paired task state', () => {
'/tmp/paired-room', '/tmp/paired-room',
); );
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft'); 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(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',
@@ -627,6 +638,116 @@ describe('paired task state', () => {
expect(listPairedArtifactsForTask('paired-task-1')[0]?.id).toBe(artifactId); 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', () => { it('updates task and execution state and keeps one workspace per role', () => {
createPairedTask({ createPairedTask({
id: 'paired-task-2', id: 'paired-task-2',
@@ -636,6 +757,9 @@ describe('paired task state', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: null, source_ref: null,
task_policy: 'user_signoff_required',
risk_level: 'high',
plan_status: 'pending',
review_requested_at: null, review_requested_at: null,
status: 'draft', status: 'draft',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -655,6 +779,9 @@ describe('paired task state', () => {
}); });
updatePairedTask('paired-task-2', { updatePairedTask('paired-task-2', {
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
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',
@@ -690,6 +817,9 @@ describe('paired task state', () => {
}); });
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending'); 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(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
expect( expect(
listPairedWorkspacesForTask('paired-task-2').map( 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, reviewer_service_id TEXT NOT NULL,
title TEXT, title TEXT,
source_ref 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, review_requested_at TEXT,
status TEXT NOT NULL DEFAULT 'draft', 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,
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 ( CHECK (
status IN ( status IN (
'active',
'draft', 'draft',
'plan_review_pending',
'review_pending', 'review_pending',
'review_ready', 'review_ready',
'in_review', 'in_review',
@@ -328,7 +343,16 @@ function createSchema(database: Database.Database): void {
content TEXT, content TEXT,
file_path TEXT, file_path TEXT,
created_at TEXT NOT NULL, 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 CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
ON paired_artifacts(task_id, created_at); ON paired_artifacts(task_id, created_at);
@@ -624,7 +648,15 @@ function createSchema(database: Database.Database): void {
) )
.get() as { sql?: string } | undefined; .get() as { sql?: string } | undefined;
const pairedTasksSql = pairedTasksSqlRow?.sql || ''; 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(` database.exec(`
CREATE TABLE paired_tasks_new ( CREATE TABLE paired_tasks_new (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -634,13 +666,28 @@ function createSchema(database: Database.Database): void {
reviewer_service_id TEXT NOT NULL, reviewer_service_id TEXT NOT NULL,
title TEXT, title TEXT,
source_ref 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, review_requested_at TEXT,
status TEXT NOT NULL DEFAULT 'draft', 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,
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 ( CHECK (
status IN ( status IN (
'active',
'draft', 'draft',
'plan_review_pending',
'review_pending', 'review_pending',
'review_ready', 'review_ready',
'in_review', 'in_review',
@@ -662,6 +709,9 @@ function createSchema(database: Database.Database): void {
reviewer_service_id, reviewer_service_id,
title, title,
source_ref, source_ref,
task_policy,
risk_level,
plan_status,
review_requested_at, review_requested_at,
status, status,
created_at, created_at,
@@ -675,6 +725,19 @@ function createSchema(database: Database.Database): void {
reviewer_service_id, reviewer_service_id,
title, title,
source_ref, 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, review_requested_at,
status, status,
created_at, 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) // Migrate sessions table to composite PK (group_folder, agent_type)
const sessionCols = database const sessionCols = database
.prepare('PRAGMA table_info(sessions)') .prepare('PRAGMA table_info(sessions)')
@@ -757,6 +887,12 @@ export function _initTestDatabase(): void {
createSchema(db); 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). * Store chat metadata only (no message content).
* Used for all chats to enable group discovery without storing sensitive 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, reviewer_service_id,
title, title,
source_ref, source_ref,
task_policy,
risk_level,
plan_status,
review_requested_at, review_requested_at,
status, status,
created_at, created_at,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
task.id, task.id,
@@ -1883,6 +2022,9 @@ export function createPairedTask(task: PairedTask): void {
task.reviewer_service_id, task.reviewer_service_id,
task.title, task.title,
task.source_ref, task.source_ref,
task.task_policy,
task.risk_level,
task.plan_status,
task.review_requested_at, task.review_requested_at,
task.status, task.status,
task.created_at, task.created_at,
@@ -1918,7 +2060,14 @@ export function updatePairedTask(
updates: Partial< updates: Partial<
Pick< Pick<
PairedTask, 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 { ): void {
@@ -1933,6 +2082,18 @@ export function updatePairedTask(
fields.push('source_ref = ?'); fields.push('source_ref = ?');
values.push(updates.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) { if (updates.review_requested_at !== undefined) {
fields.push('review_requested_at = ?'); fields.push('review_requested_at = ?');
values.push(updates.review_requested_at); values.push(updates.review_requested_at);

View File

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

View File

@@ -25,11 +25,21 @@ vi.mock('./config.js', () => ({
SERVICE_ID: 'claude', SERVICE_ID: 'claude',
SERVICE_AGENT_TYPE: 'claude-code', SERVICE_AGENT_TYPE: 'claude-code',
SERVICE_SESSION_SCOPE: 'claude', 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), isClaudeService: vi.fn(() => true),
isReviewService: vi.fn(() => false), isReviewService: vi.fn(() => false),
isSessionCommandSenderAllowed: 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', () => { vi.mock('./db.js', () => {
const getMessagesSince = vi.fn( const getMessagesSince = vi.fn(
( (
@@ -171,6 +181,8 @@ import { resolveGroupIpcPath } from './group-folder.js';
import { createMessageRuntime } from './message-runtime.js'; import { createMessageRuntime } from './message-runtime.js';
import * as outputSuppression from './output-suppression.js'; import * as outputSuppression from './output-suppression.js';
import * as config from './config.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'; import type { Channel, RegisteredGroup } from './types.js';
function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup { function makeGroup(agentType: 'claude-code' | 'codex'): RegisteredGroup {
@@ -207,6 +219,100 @@ describe('createMessageRuntime', () => {
vi.mocked(config.isReviewService).mockReturnValue(false); 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 () => { it('ignores generic failure bot messages in paired rooms', async () => {
const chatJid = 'group@test'; const chatJid = 'group@test';
const group = makeGroup('codex'); const group = makeGroup('codex');
@@ -1421,7 +1527,6 @@ describe('createMessageRuntime', () => {
expect(lastAgentTimestamps[chatJid]).toBe('1'); expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(channel.sendMessage).not.toHaveBeenCalled(); expect(channel.sendMessage).not.toHaveBeenCalled();
expect(channel.sendAndTrack).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 () => { it('does not emit a visible message when the final output is structured silent output', async () => {
@@ -2173,17 +2278,17 @@ describe('createMessageRuntime', () => {
chatJid, chatJid,
P('검증 중입니다.\n\n0초'), 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( expect(channel.editMessage).toHaveBeenCalledWith(
chatJid, chatJid,
'progress-1', 'progress-1',
P('커밋은 정상 들어갔고 pre-commit도 통과했습니다.\n\n5초'), P('테스트도 통과했습니다.\n\n5초'),
); );
// finish() promotes the last flushed progress text to a final message // finish() promotes the last flushed progress text to a final message
expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith( expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid, chatJid,
'커밋은 정상 들어갔고 pre-commit도 통과했습니다.', '검증 중입니다.',
); );
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
@@ -2294,7 +2399,7 @@ describe('createMessageRuntime', () => {
expect(channel.sendMessage).toHaveBeenCalledTimes(1); expect(channel.sendMessage).toHaveBeenCalledTimes(1);
expect(channel.sendMessage).toHaveBeenCalledWith( expect(channel.sendMessage).toHaveBeenCalledWith(
chatJid, chatJid,
'아직 진행 중.', '진행 중입니다.',
); );
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();

View File

@@ -100,6 +100,14 @@ describe('paired execution context', () => {
expect(db.upsertPairedProject).toHaveBeenCalled(); expect(db.upsertPairedProject).toHaveBeenCalled();
expect(db.createPairedTask).toHaveBeenCalledTimes(1); 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(db.createPairedExecution).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: 'run-1:codex-main', id: 'run-1:codex-main',
@@ -122,6 +130,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: '2026-03-28T00:00:00.000Z', review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready', status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -152,6 +163,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:00:00.000Z', review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready', status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -185,6 +199,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null, review_requested_at: null,
status: 'draft', status: 'draft',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -224,6 +241,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'high',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:00:00.000Z', review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review', status: 'in_review',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -260,6 +280,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null, review_requested_at: null,
status: 'draft', status: 'draft',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -273,6 +296,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'approved',
review_requested_at: '2026-03-28T00:00:00.000Z', review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready', status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -317,13 +343,11 @@ describe('paired execution context', () => {
pairedWorkspaceManager.markPairedTaskReviewReady, pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1'); ).toHaveBeenCalledWith('task-1');
expect(result?.status).toBe('ready'); 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');
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({ vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1', id: 'task-1',
chat_jid: 'dc:test', chat_jid: 'dc:test',
@@ -332,6 +356,9 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', source_ref: 'HEAD',
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null, review_requested_at: null,
status: 'draft', status: 'draft',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -345,10 +372,13 @@ describe('paired execution context', () => {
reviewer_service_id: 'codex-review', reviewer_service_id: 'codex-review',
title: null, title: null,
source_ref: 'HEAD', 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', status: 'review_pending',
created_at: '2026-03-28T00:00:00.000Z', 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( vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
null, null,
@@ -360,39 +390,20 @@ describe('paired execution context', () => {
roomRoleContext: ownerContext, roomRoleContext: ownerContext,
}); });
expect(result).toEqual( 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({
status: 'pending', status: 'pending',
pendingReason: 'owner-workspace-not-ready', task: expect.objectContaining({
task: {
id: 'task-1', 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', 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(formatRoomReviewReadyMessage(result)).toBe(
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.', '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, reviewer_service_id: roomRoleContext.reviewerServiceId,
title: null, title: null,
source_ref: resolveCanonicalSourceRef(canonicalWorkDir), source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
task_policy: 'autonomous',
risk_level: 'low',
plan_status: 'not_requested',
review_requested_at: null, review_requested_at: null,
status: 'draft', status: 'active',
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };

View File

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

View File

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

View File

@@ -91,18 +91,19 @@ describe('isSessionCommandControlMessage', () => {
).toBe(true); ).toBe(true);
}); });
it('matches multiline review-updated output', () => { it('matches multiline review snapshot output', () => {
expect( expect(
isSessionCommandControlMessage( isSessionCommandControlMessage('Review snapshot updated.\n- Task: task-1'),
'Review snapshot updated.\n- Task: paired-task-1',
),
).toBe(true); ).toBe(true);
}); });
it('matches pending review output', () => { it('matches pending review request output', () => {
expect( expect(
isSessionCommandControlMessage( 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); ).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({ const deps = makeDeps({
markReviewReady: vi markReviewReady: vi
.fn() .fn()
.mockResolvedValue( .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(result).toEqual({ handled: true, success: true });
expect(deps.markReviewReady).toHaveBeenCalledTimes(1); expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
expect(deps.sendMessage).toHaveBeenCalledWith( 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 PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
export type PairedTaskStatus = export type PairedTaskStatus =
| 'active'
| 'draft' | 'draft'
| 'plan_review_pending'
| 'review_pending' | 'review_pending'
| 'review_ready' | 'review_ready'
| 'in_review' | 'in_review'
@@ -38,6 +40,16 @@ export type PairedTaskStatus =
| 'discarded' | 'discarded'
| 'failed'; | '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 = export type PairedExecutionStatus =
| 'pending' | 'pending'
| 'running' | 'running'
@@ -56,7 +68,13 @@ export type PairedWorkspaceStatus =
export type PairedApprovalStatus = 'pending' | 'approved' | 'rejected'; 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 { export interface RoomRoleContext {
serviceId: string; serviceId: string;
@@ -83,6 +101,9 @@ export interface PairedTask {
reviewer_service_id: string; reviewer_service_id: string;
title: string | null; title: string | null;
source_ref: string | null; source_ref: string | null;
task_policy: PairedTaskPolicy;
risk_level: PairedRiskLevel;
plan_status: PairedPlanStatus;
review_requested_at: string | null; review_requested_at: string | null;
status: PairedTaskStatus; status: PairedTaskStatus;
created_at: string; created_at: string;