feat: formalize paired review command flow

This commit is contained in:
Eyejoker
2026-03-28 23:36:46 +09:00
parent 9006b92648
commit 29053d3273
11 changed files with 863 additions and 59 deletions

View File

@@ -588,6 +588,7 @@ describe('paired task state', () => {
role: 'owner', role: 'owner',
workspace_dir: '/tmp/paired-room/owner', workspace_dir: '/tmp/paired-room/owner',
snapshot_source_dir: null, snapshot_source_dir: null,
snapshot_source_fingerprint: null,
status: 'ready', status: 'ready',
snapshot_refreshed_at: null, snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -654,7 +655,7 @@ describe('paired task state', () => {
}); });
updatePairedTask('paired-task-2', { updatePairedTask('paired-task-2', {
status: 'review_ready', 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',
}); });
@@ -669,6 +670,7 @@ describe('paired task state', () => {
role: 'reviewer', role: 'reviewer',
workspace_dir: '/tmp/reviewer-v1', workspace_dir: '/tmp/reviewer-v1',
snapshot_source_dir: '/tmp/owner', snapshot_source_dir: '/tmp/owner',
snapshot_source_fingerprint: 'fingerprint-v1',
status: 'ready', status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z', snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
created_at: '2026-03-28T00:10:00.000Z', created_at: '2026-03-28T00:10:00.000Z',
@@ -680,13 +682,14 @@ describe('paired task state', () => {
role: 'reviewer', role: 'reviewer',
workspace_dir: '/tmp/reviewer-v2', workspace_dir: '/tmp/reviewer-v2',
snapshot_source_dir: '/tmp/owner', snapshot_source_dir: '/tmp/owner',
snapshot_source_fingerprint: 'fingerprint-v2',
status: 'ready', status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z', snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
created_at: '2026-03-28T00:10:00.000Z', created_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:12:00.000Z', updated_at: '2026-03-28T00:12:00.000Z',
}); });
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready'); expect(getPairedTaskById('paired-task-2')?.status).toBe('review_pending');
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(

View File

@@ -260,6 +260,7 @@ function createSchema(database: Database.Database): void {
CHECK ( CHECK (
status IN ( status IN (
'draft', 'draft',
'review_pending',
'review_ready', 'review_ready',
'in_review', 'in_review',
'changes_requested', 'changes_requested',
@@ -294,6 +295,7 @@ function createSchema(database: Database.Database): void {
role TEXT NOT NULL, role TEXT NOT NULL,
workspace_dir TEXT NOT NULL, workspace_dir TEXT NOT NULL,
snapshot_source_dir TEXT, snapshot_source_dir TEXT,
snapshot_source_fingerprint TEXT,
status TEXT NOT NULL DEFAULT 'provisioning', status TEXT NOT NULL DEFAULT 'provisioning',
snapshot_refreshed_at TEXT, snapshot_refreshed_at TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
@@ -418,6 +420,14 @@ function createSchema(database: Database.Database): void {
/* column already exists */ /* column already exists */
} }
try {
database.exec(
`ALTER TABLE paired_workspaces ADD COLUMN snapshot_source_fingerprint TEXT`,
);
} catch {
/* column already exists */
}
database.exec(` database.exec(`
UPDATE scheduled_tasks UPDATE scheduled_tasks
SET agent_type = COALESCE( SET agent_type = COALESCE(
@@ -608,6 +618,79 @@ function createSchema(database: Database.Database): void {
); );
} }
const pairedTasksSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
)
.get() as { sql?: string } | undefined;
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
if (pairedTasksSql && !pairedTasksSql.includes("'review_pending'")) {
database.exec(`
CREATE TABLE paired_tasks_new (
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,
CHECK (
status IN (
'draft',
'review_pending',
'review_ready',
'in_review',
'changes_requested',
'merge_ready',
'merged',
'discarded',
'failed'
)
)
);
`);
database.exec(`
INSERT INTO paired_tasks_new (
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
review_requested_at,
status,
created_at,
updated_at
)
SELECT
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
review_requested_at,
status,
created_at,
updated_at
FROM paired_tasks;
`);
database.exec(`
DROP TABLE paired_tasks;
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_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)')
@@ -1960,15 +2043,17 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
role, role,
workspace_dir, workspace_dir,
snapshot_source_dir, snapshot_source_dir,
snapshot_source_fingerprint,
status, status,
snapshot_refreshed_at, snapshot_refreshed_at,
created_at, created_at,
updated_at updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
workspace_dir = excluded.workspace_dir, workspace_dir = excluded.workspace_dir,
snapshot_source_dir = excluded.snapshot_source_dir, snapshot_source_dir = excluded.snapshot_source_dir,
snapshot_source_fingerprint = excluded.snapshot_source_fingerprint,
status = excluded.status, status = excluded.status,
snapshot_refreshed_at = excluded.snapshot_refreshed_at, snapshot_refreshed_at = excluded.snapshot_refreshed_at,
updated_at = excluded.updated_at updated_at = excluded.updated_at
@@ -1979,6 +2064,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
workspace.role, workspace.role,
workspace.workspace_dir, workspace.workspace_dir,
workspace.snapshot_source_dir, workspace.snapshot_source_dir,
workspace.snapshot_source_fingerprint,
workspace.status, workspace.status,
workspace.snapshot_refreshed_at, workspace.snapshot_refreshed_at,
workspace.created_at, workspace.created_at,

View File

@@ -344,6 +344,7 @@ describe('runAgentForGroup room memory', () => {
role: 'owner', role: 'owner',
workspace_dir: '/tmp/paired/owner', workspace_dir: '/tmp/paired/owner',
snapshot_source_dir: null, snapshot_source_dir: null,
snapshot_source_fingerprint: null,
status: 'ready', status: 'ready',
snapshot_refreshed_at: null, snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -387,7 +388,7 @@ describe('runAgentForGroup room memory', () => {
}); });
}); });
it('blocks reviewer execution before review-ready and does not spawn the runner', async () => { it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => {
const group = { const group = {
...makeGroup(), ...makeGroup(),
folder: 'test-group', folder: 'test-group',
@@ -439,7 +440,7 @@ describe('runAgentForGroup room memory', () => {
EJCLAW_REVIEWER_RUNTIME: '1', EJCLAW_REVIEWER_RUNTIME: '1',
}, },
blockMessage: blockMessage:
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.', 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
}); });
const result = await runAgentForGroup(makeDeps(), { const result = await runAgentForGroup(makeDeps(), {
@@ -462,7 +463,7 @@ describe('runAgentForGroup room memory', () => {
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled(); expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
expect(outputs).toEqual([ expect(outputs).toEqual([
{ {
text: 'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.', text: 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
result: null, result: null,
}, },
]); ]);
@@ -472,7 +473,7 @@ describe('runAgentForGroup room memory', () => {
executionId: 'run-blocked-reviewer:claude', executionId: 'run-blocked-reviewer:claude',
status: 'failed', status: 'failed',
summary: summary:
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.', 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
}); });
}); });
}); });

View File

@@ -14,11 +14,13 @@ vi.mock('./db.js', () => ({
vi.mock('./paired-workspace-manager.js', () => ({ vi.mock('./paired-workspace-manager.js', () => ({
markPairedTaskReviewReady: vi.fn(), markPairedTaskReviewReady: vi.fn(),
prepareReviewerWorkspaceForExecution: vi.fn(),
provisionOwnerWorkspaceForPairedTask: vi.fn(), provisionOwnerWorkspaceForPairedTask: vi.fn(),
})); }));
vi.mock('./logger.js', () => ({ vi.mock('./logger.js', () => ({
logger: { logger: {
debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
error: vi.fn(), error: vi.fn(),
@@ -73,11 +75,18 @@ describe('paired execution context', () => {
role: 'owner', role: 'owner',
workspace_dir: '/tmp/paired/task-1/owner', workspace_dir: '/tmp/paired/task-1/owner',
snapshot_source_dir: null, snapshot_source_dir: null,
snapshot_source_fingerprint: null,
status: 'ready', status: 'ready',
snapshot_refreshed_at: null, snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: null,
autoRefreshed: false,
});
}); });
it('creates an owner execution with a worktree override', () => { it('creates an owner execution with a worktree override', () => {
@@ -103,7 +112,7 @@ describe('paired execution context', () => {
}); });
}); });
it('uses the reviewer snapshot only after review_ready and marks the task in_review', () => { it('uses the reviewer snapshot after lazy auto-refresh and marks the task in_review', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1', id: 'task-1',
chat_jid: 'dc:test', chat_jid: 'dc:test',
@@ -117,14 +126,33 @@ describe('paired execution context', () => {
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
vi.mocked(db.getPairedWorkspace).mockReturnValue({ vi.mocked(
id: 'task-1:reviewer', pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
task_id: 'task-1', ).mockReturnValue({
role: 'reviewer', workspace: {
workspace_dir: '/tmp/paired/task-1/reviewer', id: 'task-1:reviewer',
snapshot_source_dir: '/tmp/paired/task-1/owner', task_id: 'task-1',
status: 'ready', role: 'reviewer',
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z', workspace_dir: '/tmp/paired/task-1/reviewer',
snapshot_source_dir: '/tmp/paired/task-1/owner',
snapshot_source_fingerprint: 'fingerprint-1',
status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
autoRefreshed: true,
});
vi.mocked(db.getPairedTaskById).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
@@ -147,7 +175,7 @@ describe('paired execution context', () => {
}); });
}); });
it('blocks reviewer execution before review_ready instead of falling back to the canonical repo', () => { it('does not change task state for a general reviewer turn before /review', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1', id: 'task-1',
chat_jid: 'dc:test', chat_jid: 'dc:test',
@@ -161,19 +189,65 @@ describe('paired execution context', () => {
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z',
}); });
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: null,
autoRefreshed: false,
blockMessage:
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.',
});
const result = preparePairedExecutionContext({ const result = preparePairedExecutionContext({
group, group,
chatJid: 'dc:test', chatJid: 'dc:test',
runId: 'run-blocked-reviewer', runId: 'run-general-reviewer',
roomRoleContext: reviewerContext, roomRoleContext: reviewerContext,
}); });
expect(result?.blockMessage).toBe( expect(result?.blockMessage).toBe(
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.', 'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.',
);
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({ status: 'in_review' }),
);
});
it('blocks reviewer execution when an in-review snapshot became stale', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: null,
autoRefreshed: false,
blockMessage:
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-stale-reviewer',
roomRoleContext: reviewerContext,
});
expect(result?.blockMessage).toBe(
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
); );
expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined(); expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined();
expect(db.getPairedWorkspace).not.toHaveBeenCalled();
}); });
it('marks the active room task review-ready through the workspace manager', () => { it('marks the active room task review-ready through the workspace manager', () => {
@@ -211,6 +285,7 @@ describe('paired execution context', () => {
role: 'owner', role: 'owner',
workspace_dir: '/tmp/paired/task-1/owner', workspace_dir: '/tmp/paired/task-1/owner',
snapshot_source_dir: null, snapshot_source_dir: null,
snapshot_source_fingerprint: null,
status: 'ready', status: 'ready',
snapshot_refreshed_at: null, snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -222,6 +297,7 @@ describe('paired execution context', () => {
role: 'reviewer', role: 'reviewer',
workspace_dir: '/tmp/paired/task-1/reviewer', workspace_dir: '/tmp/paired/task-1/reviewer',
snapshot_source_dir: '/tmp/paired/task-1/owner', snapshot_source_dir: '/tmp/paired/task-1/owner',
snapshot_source_fingerprint: 'fingerprint-1',
status: 'ready', status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:00:00.000Z', snapshot_refreshed_at: '2026-03-28T00:00:00.000Z',
created_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z',
@@ -241,4 +317,31 @@ describe('paired execution context', () => {
).toHaveBeenCalledWith('task-1'); ).toHaveBeenCalledWith('task-1');
expect(result?.task.status).toBe('review_ready'); expect(result?.task.status).toBe('review_ready');
}); });
it('returns null when review-ready setup cannot resolve an owner workspace', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
null,
);
const result = markRoomReviewReady({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(result).toBeNull();
});
}); });

View File

@@ -1,13 +1,16 @@
import { execFileSync } from 'child_process'; import { execFileSync } from 'child_process';
import crypto from 'crypto'; import crypto from 'crypto';
import {
SERVICE_ID,
normalizeServiceId,
} from './config.js';
import { import {
createPairedExecution, createPairedExecution,
createPairedTask, createPairedTask,
getLatestOpenPairedTaskForChat, getLatestOpenPairedTaskForChat,
getPairedExecutionById, getPairedExecutionById,
getPairedTaskById, getPairedTaskById,
getPairedWorkspace,
updatePairedExecution, updatePairedExecution,
updatePairedTask, updatePairedTask,
upsertPairedProject, upsertPairedProject,
@@ -15,6 +18,7 @@ import {
import { logger } from './logger.js'; import { logger } from './logger.js';
import { import {
markPairedTaskReviewReady, markPairedTaskReviewReady,
prepareReviewerWorkspaceForExecution,
provisionOwnerWorkspaceForPairedTask, provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js'; } from './paired-workspace-manager.js';
import type { import type {
@@ -170,22 +174,17 @@ export function preparePairedExecutionContext(args: {
if (roomRoleContext.role === 'owner') { if (roomRoleContext.role === 'owner') {
workspace = provisionOwnerWorkspaceForPairedTask(task.id); workspace = provisionOwnerWorkspaceForPairedTask(task.id);
} else if ( } else {
task.status === 'review_ready' || const reviewerWorkspace = prepareReviewerWorkspaceForExecution(task);
task.status === 'in_review' || workspace = reviewerWorkspace.workspace;
task.status === 'merge_ready' || blockMessage = reviewerWorkspace.blockMessage;
task.status === 'changes_requested' const latestTask = getPairedTaskById(task.id) ?? task;
) { if (workspace && latestTask.status === 'review_ready') {
workspace = getPairedWorkspace(task.id, 'reviewer') ?? null;
if (workspace && task.status === 'review_ready') {
updatePairedTask(task.id, { updatePairedTask(task.id, {
status: 'in_review', status: 'in_review',
updated_at: now, updated_at: now,
}); });
} }
} else {
blockMessage =
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.';
} }
const execution = ensureExecutionRecord({ const execution = ensureExecutionRecord({
@@ -247,9 +246,18 @@ export function markRoomReviewReady(args: {
return null; return null;
} }
const { ownerWorkspace, reviewerWorkspace } = markPairedTaskReviewReady( const isOwnerService =
task.id, normalizeServiceId(SERVICE_ID) === task.owner_service_id;
); if (isOwnerService) {
provisionOwnerWorkspaceForPairedTask(task.id);
}
const reviewReady = markPairedTaskReviewReady(task.id);
if (!reviewReady) {
return null;
}
const { ownerWorkspace, reviewerWorkspace } = reviewReady;
return { return {
task: getPairedTaskById(task.id) ?? task, task: getPairedTaskById(task.id) ?? task,
ownerWorkspace, ownerWorkspace,

View File

@@ -0,0 +1,140 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { RegisteredGroup, RoomRoleContext } from './types.js';
function runGit(args: string[], cwd: string): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
async function loadModules() {
const db = await import('./db.js');
const executionContext = await import('./paired-execution-context.js');
return { db, executionContext };
}
const ownerContext: RoomRoleContext = {
serviceId: 'codex-main',
role: 'owner',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
};
const reviewerContext: RoomRoleContext = {
serviceId: 'codex-review',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
};
describe('paired /review command path', () => {
let tempRoot: string;
let previousDataDir: string | undefined;
let previousGroupsDir: string | undefined;
let previousStoreDir: string | undefined;
let previousServiceId: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-review-command-'));
previousDataDir = process.env.EJCLAW_DATA_DIR;
previousGroupsDir = process.env.EJCLAW_GROUPS_DIR;
previousStoreDir = process.env.EJCLAW_STORE_DIR;
previousServiceId = process.env.SERVICE_ID;
process.env.EJCLAW_STORE_DIR = path.join(tempRoot, 'shared-store');
process.env.EJCLAW_GROUPS_DIR = path.join(tempRoot, 'shared-groups');
vi.resetModules();
});
afterEach(() => {
if (previousDataDir === undefined) delete process.env.EJCLAW_DATA_DIR;
else process.env.EJCLAW_DATA_DIR = previousDataDir;
if (previousGroupsDir === undefined) delete process.env.EJCLAW_GROUPS_DIR;
else process.env.EJCLAW_GROUPS_DIR = previousGroupsDir;
if (previousStoreDir === undefined) delete process.env.EJCLAW_STORE_DIR;
else process.env.EJCLAW_STORE_DIR = previousStoreDir;
if (previousServiceId === undefined) delete process.env.SERVICE_ID;
else process.env.SERVICE_ID = previousServiceId;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('reuses the db owner workspace when reviewer service handles /review', async () => {
const canonicalDir = path.join(tempRoot, 'canonical');
fs.mkdirSync(canonicalDir, { recursive: true });
runGit(['init'], canonicalDir);
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
runGit(['add', 'README.md'], canonicalDir);
runGit(['commit', '-m', 'initial'], canonicalDir);
const group: RegisteredGroup = {
name: 'Paired Room',
folder: 'paired-room',
trigger: '@codex',
added_at: '2026-03-28T00:00:00.000Z',
agentType: 'codex',
workDir: canonicalDir,
};
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
process.env.SERVICE_ID = 'codex-main';
vi.resetModules();
let { db, executionContext } = await loadModules();
db.initDatabase();
const ownerResult = executionContext.preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-owner',
roomRoleContext: ownerContext,
});
expect(ownerResult?.workspace?.workspace_dir).toBeTruthy();
const ownerWorkspaceDir = ownerResult!.workspace!.workspace_dir;
fs.writeFileSync(path.join(ownerWorkspaceDir, 'README.md'), 'owner change\n');
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-review');
process.env.SERVICE_ID = 'codex-review';
vi.resetModules();
({ db, executionContext } = await loadModules());
db.initDatabase();
const result = executionContext.markRoomReviewReady({
group,
chatJid: 'dc:test',
roomRoleContext: reviewerContext,
});
const reviewerLocalOwnerDir = path.join(
tempRoot,
'data-review',
'workspaces',
'paired-room',
'tasks',
ownerResult!.task.id,
'owner',
);
expect(result).not.toBeNull();
expect(result!.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
expect(result!.reviewerWorkspace.snapshot_source_dir).toBe(
ownerWorkspaceDir,
);
expect(
fs.readFileSync(
path.join(result!.reviewerWorkspace.workspace_dir, 'README.md'),
'utf-8',
),
).toBe('owner change\n');
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
});
});

View File

@@ -96,8 +96,9 @@ describe('paired workspace manager', () => {
'review me\n', 'review me\n',
); );
const { reviewerWorkspace } = const reviewReady = manager.markPairedTaskReviewReady('paired-task-1');
manager.markPairedTaskReviewReady('paired-task-1'); expect(reviewReady).not.toBeNull();
const reviewerWorkspace = reviewReady!.reviewerWorkspace;
expect( expect(
fs.readFileSync( fs.readFileSync(
@@ -121,9 +122,92 @@ describe('paired workspace manager', () => {
runGit(['status', '--short'], reviewerWorkspace.workspace_dir), runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
).toContain('M README.md'); ).toContain('M README.md');
expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready'); expect(db.getPairedTaskById('paired-task-1')?.status).toBe('review_ready');
expect(db.getPairedTaskById('paired-task-1')?.review_requested_at).toBeTruthy();
expect( expect(
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at, db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
).toBeTruthy(); ).toBeTruthy();
expect(
db.getPairedWorkspace('paired-task-1', 'reviewer')
?.snapshot_source_fingerprint,
).toBeTruthy();
});
it('uses the shared DB owner workspace across service-local data dirs', async () => {
const canonicalDir = path.join(tempRoot, 'canonical');
fs.mkdirSync(canonicalDir, { recursive: true });
runGit(['init'], canonicalDir);
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n');
runGit(['add', 'README.md'], canonicalDir);
runGit(['commit', '-m', 'initial'], canonicalDir);
process.env.EJCLAW_STORE_DIR = path.join(tempRoot, 'shared-store');
process.env.EJCLAW_GROUPS_DIR = path.join(tempRoot, 'shared-groups');
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner');
vi.resetModules();
let { db, manager } = await loadModules();
db.initDatabase();
const now = '2026-03-28T00:00:00.000Z';
db.upsertPairedProject({
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
updated_at: now,
});
db.createPairedTask({
id: 'paired-task-cross-service',
chat_jid: 'dc:test',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: 'cross service review',
source_ref: 'HEAD',
review_requested_at: null,
status: 'draft',
created_at: now,
updated_at: now,
});
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-cross-service');
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
'owner modified\n',
);
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-review');
vi.resetModules();
({ db, manager } = await loadModules());
db.initDatabase();
const reviewerWorkspace = manager.refreshReviewerSnapshotForPairedTask(
'paired-task-cross-service',
);
const reviewServiceOwnerDir = path.join(
tempRoot,
'data-review',
'workspaces',
'paired-room',
'tasks',
'paired-task-cross-service',
'owner',
);
expect(
fs.readFileSync(
path.join(reviewerWorkspace.workspace_dir, 'README.md'),
'utf-8',
),
).toBe('owner modified\n');
expect(reviewerWorkspace.snapshot_source_dir).toBe(
ownerWorkspace.workspace_dir,
);
expect(fs.existsSync(reviewServiceOwnerDir)).toBe(false);
}); });
it('replaces stale reviewer files on snapshot refresh', async () => { it('replaces stale reviewer files on snapshot refresh', async () => {
@@ -377,6 +461,7 @@ describe('paired workspace manager', () => {
updated_at: now, updated_at: now,
}); });
manager.provisionOwnerWorkspaceForPairedTask('paired-task-4');
const reviewerWorkspace = const reviewerWorkspace =
manager.refreshReviewerSnapshotForPairedTask('paired-task-4'); manager.refreshReviewerSnapshotForPairedTask('paired-task-4');
@@ -400,4 +485,203 @@ describe('paired workspace manager', () => {
), ),
).toBe('EXAMPLE=1\n'); ).toBe('EXAMPLE=1\n');
}); });
it('does not auto-refresh a missing reviewer snapshot while the task is still draft', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
fs.mkdirSync(canonicalDir, { recursive: true });
runGit(['init'], canonicalDir);
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n');
runGit(['add', 'README.md'], canonicalDir);
runGit(['commit', '-m', 'initial'], canonicalDir);
const now = '2026-03-28T00:00:00.000Z';
db.upsertPairedProject({
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
updated_at: now,
});
db.createPairedTask({
id: 'paired-task-5',
chat_jid: 'dc:test',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: null,
status: 'draft',
created_at: now,
updated_at: now,
});
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-5');
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
'owner change\n',
);
const result = manager.prepareReviewerWorkspaceForExecution(
db.getPairedTaskById('paired-task-5')!,
);
expect(result.workspace).toBeNull();
expect(result.autoRefreshed).toBe(false);
expect(result.blockMessage).toBe(
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.',
);
expect(db.getPairedTaskById('paired-task-5')?.status).toBe('draft');
});
it('auto-refreshes a missing reviewer snapshot after an explicit review request', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
fs.mkdirSync(canonicalDir, { recursive: true });
runGit(['init'], canonicalDir);
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n');
runGit(['add', 'README.md'], canonicalDir);
runGit(['commit', '-m', 'initial'], canonicalDir);
const now = '2026-03-28T00:00:00.000Z';
db.upsertPairedProject({
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
updated_at: now,
});
db.createPairedTask({
id: 'paired-task-5b',
chat_jid: 'dc:test',
group_folder: 'paired-room',
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: now,
updated_at: '2026-03-28T00:01:00.000Z',
});
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-5b');
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
'owner change\n',
);
const result = manager.prepareReviewerWorkspaceForExecution(
db.getPairedTaskById('paired-task-5b')!,
);
expect(result.autoRefreshed).toBe(true);
expect(result.blockMessage).toBeUndefined();
expect(
fs.readFileSync(
path.join(result.workspace!.workspace_dir, 'README.md'),
'utf-8',
),
).toBe('owner change\n');
expect(db.getPairedTaskById('paired-task-5b')?.status).toBe('review_ready');
expect(db.getPairedTaskById('paired-task-5b')?.review_requested_at).toBe(
'2026-03-28T00:01:00.000Z',
);
});
it('blocks once when an in-review snapshot becomes stale, then refreshes on retry', async () => {
const { db, manager } = await loadModules();
db._initTestDatabase();
const canonicalDir = path.join(tempRoot, 'canonical');
fs.mkdirSync(canonicalDir, { recursive: true });
runGit(['init'], canonicalDir);
runGit(['config', 'user.email', 'test@example.com'], canonicalDir);
runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir);
fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'base\n');
runGit(['add', 'README.md'], canonicalDir);
runGit(['commit', '-m', 'initial'], canonicalDir);
const now = '2026-03-28T00:00:00.000Z';
db.upsertPairedProject({
chat_jid: 'dc:test',
group_folder: 'paired-room',
canonical_work_dir: canonicalDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
updated_at: now,
});
db.createPairedTask({
id: 'paired-task-6',
chat_jid: 'dc:test',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: 'HEAD',
review_requested_at: null,
status: 'draft',
created_at: now,
updated_at: now,
});
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-6');
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
'first review\n',
);
manager.markPairedTaskReviewReady('paired-task-6');
db.updatePairedTask('paired-task-6', {
status: 'in_review',
updated_at: '2026-03-28T00:05:00.000Z',
});
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
'owner changed again\n',
);
const blocked = manager.prepareReviewerWorkspaceForExecution(
db.getPairedTaskById('paired-task-6')!,
);
expect(blocked.workspace).toBeNull();
expect(blocked.autoRefreshed).toBe(false);
expect(blocked.blockMessage).toBe(
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.',
);
expect(db.getPairedTaskById('paired-task-6')?.status).toBe('review_pending');
expect(db.getPairedTaskById('paired-task-6')?.review_requested_at).toBeTruthy();
expect(db.getPairedWorkspace('paired-task-6', 'reviewer')?.status).toBe(
'stale',
);
const refreshed = manager.prepareReviewerWorkspaceForExecution(
db.getPairedTaskById('paired-task-6')!,
);
expect(refreshed.autoRefreshed).toBe(true);
expect(refreshed.blockMessage).toBeUndefined();
expect(
fs.readFileSync(
path.join(refreshed.workspace!.workspace_dir, 'README.md'),
'utf-8',
),
).toBe('owner changed again\n');
expect(db.getPairedTaskById('paired-task-6')?.review_requested_at).toBeTruthy();
});
}); });

View File

@@ -1,4 +1,5 @@
import { execFileSync } from 'child_process'; import { execFileSync } from 'child_process';
import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -13,6 +14,11 @@ import { resolvePairedTaskWorkspacePath } from './group-folder.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import type { PairedTask, PairedWorkspace } from './types.js'; import type { PairedTask, PairedWorkspace } from './types.js';
const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE =
'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.';
const REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE =
'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.';
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([ const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
'.git', '.git',
'.claude', '.claude',
@@ -115,14 +121,6 @@ function ensureCleanDirectory(targetDir: string): void {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
} }
function resetDirectoryExceptGit(targetDir: string): void {
if (!fs.existsSync(targetDir)) return;
for (const entry of fs.readdirSync(targetDir)) {
if (entry === '.git') continue;
fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true });
}
}
function listGitPaths(repoDir: string, args: string[]): string[] { function listGitPaths(repoDir: string, args: string[]): string[] {
const output = execFileSync('git', args, { const output = execFileSync('git', args, {
cwd: repoDir, cwd: repoDir,
@@ -221,6 +219,34 @@ function removeSnapshotPaths(targetDir: string, relativePaths: string[]): void {
} }
} }
function buildReviewerSnapshotFingerprint(args: {
sourceDir: string;
allowedTrackedFiles: string[];
deletedTrackedFiles: string[];
allowedUntrackedFiles: string[];
}): string {
const hash = crypto.createHash('sha256');
const appendFile = (kind: 'tracked' | 'untracked', relativePath: string) => {
const sourcePath = path.join(args.sourceDir, relativePath);
if (!fs.existsSync(sourcePath)) return;
hash.update(`${kind}\0${relativePath}\0`);
hash.update(fs.readFileSync(sourcePath));
hash.update('\0');
};
for (const relativePath of [...new Set(args.allowedTrackedFiles)].sort()) {
appendFile('tracked', relativePath);
}
for (const relativePath of [...new Set(args.deletedTrackedFiles)].sort()) {
hash.update(`deleted\0${relativePath}\0`);
}
for (const relativePath of [...new Set(args.allowedUntrackedFiles)].sort()) {
appendFile('untracked', relativePath);
}
return hash.digest('hex');
}
function applyReviewerSparseCheckout( function applyReviewerSparseCheckout(
reviewerDir: string, reviewerDir: string,
allowedTrackedFiles: string[], allowedTrackedFiles: string[],
@@ -275,7 +301,9 @@ function makeWorkspaceRecord(args: {
role: PairedWorkspace['role']; role: PairedWorkspace['role'];
workspaceDir: string; workspaceDir: string;
snapshotSourceDir?: string | null; snapshotSourceDir?: string | null;
snapshotSourceFingerprint?: string | null;
snapshotRefreshedAt?: string | null; snapshotRefreshedAt?: string | null;
status?: PairedWorkspace['status'];
createdAt?: string; createdAt?: string;
}): PairedWorkspace { }): PairedWorkspace {
const existing = getPairedWorkspace(args.taskId, args.role); const existing = getPairedWorkspace(args.taskId, args.role);
@@ -287,7 +315,11 @@ function makeWorkspaceRecord(args: {
workspace_dir: args.workspaceDir, workspace_dir: args.workspaceDir,
snapshot_source_dir: snapshot_source_dir:
args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null, args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
status: 'ready', snapshot_source_fingerprint:
args.snapshotSourceFingerprint ??
existing?.snapshot_source_fingerprint ??
null,
status: args.status ?? 'ready',
snapshot_refreshed_at: snapshot_refreshed_at:
args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null, args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null,
created_at: existing?.created_at || args.createdAt || now, created_at: existing?.created_at || args.createdAt || now,
@@ -334,7 +366,12 @@ export function provisionOwnerWorkspaceForPairedTask(
export function refreshReviewerSnapshotForPairedTask( export function refreshReviewerSnapshotForPairedTask(
taskId: string, taskId: string,
): PairedWorkspace { ): PairedWorkspace {
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId); const ownerWorkspace = getPairedWorkspace(taskId, 'owner');
if (!ownerWorkspace) {
throw new Error(
`Owner workspace is not available for paired task ${taskId}.`,
);
}
ensureGitRepository(ownerWorkspace.workspace_dir); ensureGitRepository(ownerWorkspace.workspace_dir);
const { task } = getTaskAndProject(taskId); const { task } = getTaskAndProject(taskId);
@@ -359,6 +396,12 @@ export function refreshReviewerSnapshotForPairedTask(
const allowedUntrackedFiles = listAllowedUntrackedFiles( const allowedUntrackedFiles = listAllowedUntrackedFiles(
ownerWorkspace.workspace_dir, ownerWorkspace.workspace_dir,
); );
const snapshotFingerprint = buildReviewerSnapshotFingerprint({
sourceDir: ownerWorkspace.workspace_dir,
allowedTrackedFiles,
deletedTrackedFiles,
allowedUntrackedFiles,
});
runGit(['reset', '--hard', 'HEAD'], reviewerDir); runGit(['reset', '--hard', 'HEAD'], reviewerDir);
runGit(['clean', '-fdx'], reviewerDir); runGit(['clean', '-fdx'], reviewerDir);
@@ -382,6 +425,7 @@ export function refreshReviewerSnapshotForPairedTask(
role: 'reviewer', role: 'reviewer',
workspaceDir: reviewerDir, workspaceDir: reviewerDir,
snapshotSourceDir: ownerWorkspace.workspace_dir, snapshotSourceDir: ownerWorkspace.workspace_dir,
snapshotSourceFingerprint: snapshotFingerprint,
snapshotRefreshedAt: refreshedAt, snapshotRefreshedAt: refreshedAt,
}); });
upsertPairedWorkspace(workspace); upsertPairedWorkspace(workspace);
@@ -395,16 +439,122 @@ export function refreshReviewerSnapshotForPairedTask(
export function markPairedTaskReviewReady(taskId: string): { export function markPairedTaskReviewReady(taskId: string): {
ownerWorkspace: PairedWorkspace; ownerWorkspace: PairedWorkspace;
reviewerWorkspace: PairedWorkspace; reviewerWorkspace: PairedWorkspace;
} { } | null {
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId); const requestedAt = new Date().toISOString();
updatePairedTask(taskId, {
status: 'review_pending',
review_requested_at: requestedAt,
updated_at: requestedAt,
});
const ownerWorkspace = getPairedWorkspace(taskId, 'owner');
if (!ownerWorkspace) {
return null;
}
const reviewerWorkspace = refreshReviewerSnapshotForPairedTask(taskId); const reviewerWorkspace = refreshReviewerSnapshotForPairedTask(taskId);
const now = new Date().toISOString(); const now = new Date().toISOString();
updatePairedTask(taskId, { updatePairedTask(taskId, {
status: 'review_ready', status: 'review_ready',
review_requested_at: now,
updated_at: now, updated_at: now,
}); });
return { ownerWorkspace, reviewerWorkspace }; return { ownerWorkspace, reviewerWorkspace };
} }
export interface PreparedReviewerWorkspace {
workspace: PairedWorkspace | null;
blockMessage?: string;
autoRefreshed: boolean;
}
export function prepareReviewerWorkspaceForExecution(
task: PairedTask,
): PreparedReviewerWorkspace {
const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null;
if (!ownerWorkspace) {
return {
workspace: null,
autoRefreshed: false,
blockMessage: REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE,
};
}
const reviewerWorkspace = getPairedWorkspace(task.id, 'reviewer') ?? null;
const allowedTrackedFiles = listAllowedTrackedFiles(
ownerWorkspace.workspace_dir,
);
const deletedTrackedFiles = listDeletedTrackedFiles(
ownerWorkspace.workspace_dir,
);
const allowedUntrackedFiles = listAllowedUntrackedFiles(
ownerWorkspace.workspace_dir,
);
const currentFingerprint = buildReviewerSnapshotFingerprint({
sourceDir: ownerWorkspace.workspace_dir,
allowedTrackedFiles,
deletedTrackedFiles,
allowedUntrackedFiles,
});
const snapshotMissing =
!reviewerWorkspace?.snapshot_refreshed_at || !reviewerWorkspace;
const snapshotStale =
!!reviewerWorkspace &&
(reviewerWorkspace.status === 'stale' ||
reviewerWorkspace.snapshot_source_fingerprint !== currentFingerprint);
const now = new Date().toISOString();
if (snapshotMissing || snapshotStale) {
if (
task.status === 'review_pending' ||
task.status === 'review_ready'
) {
const refreshedWorkspace = refreshReviewerSnapshotForPairedTask(task.id);
updatePairedTask(task.id, {
status: 'review_ready',
updated_at: now,
});
return {
workspace: refreshedWorkspace,
autoRefreshed: true,
};
}
if (snapshotMissing) {
return {
workspace: null,
autoRefreshed: false,
blockMessage: REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE,
};
}
if (reviewerWorkspace) {
upsertPairedWorkspace(
makeWorkspaceRecord({
taskId: task.id,
role: 'reviewer',
workspaceDir: reviewerWorkspace.workspace_dir,
snapshotSourceDir: reviewerWorkspace.snapshot_source_dir,
snapshotSourceFingerprint:
reviewerWorkspace.snapshot_source_fingerprint,
snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at,
status: 'stale',
createdAt: reviewerWorkspace.created_at,
}),
);
}
updatePairedTask(task.id, {
status: 'review_pending',
updated_at: now,
});
return {
workspace: null,
autoRefreshed: false,
blockMessage: REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE,
};
}
return {
workspace: reviewerWorkspace,
autoRefreshed: false,
};
}

View File

@@ -23,10 +23,12 @@ describe('extractSessionCommand', () => {
expect(extractSessionCommand('/clear', trigger)).toBe('/clear'); expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
}); });
it('detects bare /review-ready', () => { it('detects bare /review', () => {
expect(extractSessionCommand('/review-ready', trigger)).toBe( expect(extractSessionCommand('/review', trigger)).toBe('/review');
'/review-ready', });
);
it('normalizes /review-ready to /review', () => {
expect(extractSessionCommand('/review-ready', trigger)).toBe('/review');
}); });
it('rejects /compact with extra text', () => { it('rejects /compact with extra text', () => {
@@ -185,7 +187,32 @@ describe('handleSessionCommand', () => {
); );
}); });
it('handles authorized /review-ready without invoking the agent', async () => { it('handles authorized /review without invoking the agent', async () => {
const deps = makeDeps({
markReviewReady: vi
.fn()
.mockResolvedValue('Review snapshot updated.\n- Task: paired-task-1'),
});
const result = await handleSessionCommand({
missedMessages: [makeMsg('/review')],
isMainGroup: true,
groupName: 'test',
triggerPattern: trigger,
timezone: 'UTC',
deps,
});
expect(result).toEqual({ handled: true, success: true });
expect(deps.markReviewReady).toHaveBeenCalledTimes(1);
expect(deps.runAgent).not.toHaveBeenCalled();
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
expect(deps.sendMessage).toHaveBeenCalledWith(
'Review snapshot updated.\n- Task: paired-task-1',
);
});
it('handles authorized /review-ready as an alias without invoking the agent', async () => {
const deps = makeDeps({ const deps = makeDeps({
markReviewReady: vi markReviewReady: vi
.fn() .fn()

View File

@@ -11,7 +11,7 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
/^\/compact failed\. The session is unchanged\.$/, /^\/compact failed\. The session is unchanged\.$/,
/^Conversation compacted\.$/, /^Conversation compacted\.$/,
/^Review snapshot updated\.$/, /^Review snapshot updated\.$/,
/^Review-ready is unavailable for this room\./, /^Review is unavailable for this room\./,
]; ];
/** /**
@@ -26,7 +26,7 @@ export function extractSessionCommand(
text = text.replace(triggerPattern, '').trim(); text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact'; if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear'; if (text === '/clear') return '/clear';
if (text === '/review-ready') return '/review-ready'; if (text === '/review' || text === '/review-ready') return '/review';
return null; return null;
} }
@@ -153,12 +153,12 @@ export async function handleSessionCommand(opts: {
return { handled: true, success: true }; return { handled: true, success: true };
} }
if (command === '/review-ready') { if (command === '/review') {
const result = await deps.markReviewReady(); const result = await deps.markReviewReady();
deps.advanceCursor(cmdMsg.timestamp); deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage( await deps.sendMessage(
result ?? result ??
'Review-ready is unavailable for this room. Paired workspaces require a configured project workDir.', 'Review is unavailable for this room. Paired workspaces require a configured project workDir.',
); );
return { handled: true, success: true }; return { handled: true, success: true };
} }

View File

@@ -29,6 +29,7 @@ export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
export type PairedTaskStatus = export type PairedTaskStatus =
| 'draft' | 'draft'
| 'review_pending'
| 'review_ready' | 'review_ready'
| 'in_review' | 'in_review'
| 'changes_requested' | 'changes_requested'
@@ -107,6 +108,7 @@ export interface PairedWorkspace {
role: PairedWorkspaceRole; role: PairedWorkspaceRole;
workspace_dir: string; workspace_dir: string;
snapshot_source_dir: string | null; snapshot_source_dir: string | null;
snapshot_source_fingerprint: string | null;
status: PairedWorkspaceStatus; status: PairedWorkspaceStatus;
snapshot_refreshed_at: string | null; snapshot_refreshed_at: string | null;
created_at: string; created_at: string;