From dc8dd98e84c6e921f6c68dac85c9cd43a7fe0369 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 29 Mar 2026 01:32:43 +0900 Subject: [PATCH] feat: scaffold paired governance state --- src/db.test.ts | 132 +++++++++++++++++++- src/db.ts | 173 ++++++++++++++++++++++++++- src/message-agent-executor.test.ts | 6 + src/message-runtime.test.ts | 115 +++++++++++++++++- src/paired-execution-context.test.ts | 81 +++++++------ src/paired-execution-context.ts | 5 +- src/paired-review-command.test.ts | 28 +++-- src/paired-workspace-manager.test.ts | 24 ++++ src/session-commands.test.ts | 27 +++-- src/types.ts | 23 +++- 10 files changed, 543 insertions(+), 71 deletions(-) diff --git a/src/db.test.ts b/src/db.test.ts index 1c0eaa3..e46694a 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -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( diff --git a/src/db.ts b/src/db.ts index 63664b1..3f77cfc 100644 --- a/src/db.ts +++ b/src/db.ts @@ -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); diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 2c94c84..19d47c8 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -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', diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 35a5e30..d44f8f9 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -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 = {}; + 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( + './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(); diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index f9c6fa5..1da4a33 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -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'), ); }); }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 160be37..49cc6c6 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -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, }; diff --git a/src/paired-review-command.test.ts b/src/paired-review-command.test.ts index 80f3a1a..03cc820 100644 --- a/src/paired-review-command.test.ts +++ b/src/paired-review-command.test.ts @@ -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); }); }); diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index f961932..7d28cce 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -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, diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index 69ba849..23fb53d 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -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'), ); }); diff --git a/src/types.ts b/src/types.ts index 4f432f9..e0276bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;