fix: keep review requests pending without owner workspace

This commit is contained in:
Eyejoker
2026-03-29 00:21:29 +09:00
parent 29053d3273
commit b0c833fd70
7 changed files with 318 additions and 25 deletions

View File

@@ -41,7 +41,10 @@ import {
import { runAgentForGroup } from './message-agent-executor.js'; import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js'; import { MessageTurnController } from './message-turn-controller.js';
import { createSuppressToken } from './output-suppression.js'; import { createSuppressToken } from './output-suppression.js';
import { markRoomReviewReady } from './paired-execution-context.js'; import {
formatRoomReviewReadyMessage,
markRoomReviewReady,
} from './paired-execution-context.js';
import { buildRoomRoleContext } from './room-role-context.js'; import { buildRoomRoleContext } from './room-role-context.js';
import { import {
extractSessionCommand, extractSessionCommand,
@@ -573,15 +576,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
chatJid, chatJid,
roomRoleContext, roomRoleContext,
}); });
if (!result) { return formatRoomReviewReadyMessage(result);
return null;
}
return [
'Review snapshot updated.',
`- Task: ${result.task.id}`,
`- Owner workspace: ${result.ownerWorkspace.workspace_dir}`,
`- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`,
].join('\n');
}, },
}, },
}); });

View File

@@ -29,6 +29,7 @@ vi.mock('./logger.js', () => ({
import * as db from './db.js'; import * as db from './db.js';
import { import {
formatRoomReviewReadyMessage,
markRoomReviewReady, markRoomReviewReady,
preparePairedExecutionContext, preparePairedExecutionContext,
} from './paired-execution-context.js'; } from './paired-execution-context.js';
@@ -315,10 +316,14 @@ describe('paired execution context', () => {
expect( expect(
pairedWorkspaceManager.markPairedTaskReviewReady, pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1'); ).toHaveBeenCalledWith('task-1');
expect(result?.task.status).toBe('review_ready'); expect(result?.status).toBe('ready');
if (result?.status !== 'ready') {
throw new Error('expected ready review result');
}
expect(result.task.status).toBe('review_ready');
}); });
it('returns null when review-ready setup cannot resolve an owner workspace', () => { it('returns pending when review-ready setup cannot resolve an owner workspace', () => {
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 +337,19 @@ 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.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:01:00.000Z',
status: 'review_pending',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:01:00.000Z',
});
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue( vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
null, null,
); );
@@ -342,6 +360,39 @@ describe('paired execution context', () => {
roomRoleContext: ownerContext, roomRoleContext: ownerContext,
}); });
expect(result).toBeNull(); 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',
pendingReason: 'owner-workspace-not-ready',
task: {
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',
},
});
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.',
);
}); });
}); });

View File

@@ -152,6 +152,19 @@ export interface PreparedPairedExecutionContext {
blockMessage?: string; blockMessage?: string;
} }
export type MarkRoomReviewReadyResult =
| {
status: 'ready';
task: PairedTask;
ownerWorkspace: PairedWorkspace;
reviewerWorkspace: PairedWorkspace;
}
| {
status: 'pending';
task: PairedTask;
pendingReason: 'owner-workspace-not-ready';
};
export function preparePairedExecutionContext(args: { export function preparePairedExecutionContext(args: {
group: RegisteredGroup; group: RegisteredGroup;
chatJid: string; chatJid: string;
@@ -231,11 +244,7 @@ export function markRoomReviewReady(args: {
group: RegisteredGroup; group: RegisteredGroup;
chatJid: string; chatJid: string;
roomRoleContext?: RoomRoleContext; roomRoleContext?: RoomRoleContext;
}): { }): MarkRoomReviewReadyResult | null {
task: PairedTask;
ownerWorkspace: PairedWorkspace;
reviewerWorkspace: PairedWorkspace;
} | null {
const { group, chatJid, roomRoleContext } = args; const { group, chatJid, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) { if (!roomRoleContext || !group.workDir) {
return null; return null;
@@ -253,14 +262,43 @@ export function markRoomReviewReady(args: {
} }
const reviewReady = markPairedTaskReviewReady(task.id); const reviewReady = markPairedTaskReviewReady(task.id);
const latestTask = getPairedTaskById(task.id) ?? task;
if (!reviewReady) { if (!reviewReady) {
return null; return {
status: 'pending',
task: latestTask,
pendingReason: 'owner-workspace-not-ready',
};
} }
const { ownerWorkspace, reviewerWorkspace } = reviewReady; const { ownerWorkspace, reviewerWorkspace } = reviewReady;
return { return {
task: getPairedTaskById(task.id) ?? task, status: 'ready',
task: latestTask,
ownerWorkspace, ownerWorkspace,
reviewerWorkspace, reviewerWorkspace,
}; };
} }
export function formatRoomReviewReadyMessage(
result: MarkRoomReviewReadyResult | null,
): string | null {
if (!result) {
return null;
}
if (result.status === 'pending') {
return [
'Review request recorded, but the owner workspace is not ready yet.',
`- Task: ${result.task.id}`,
'The task stays review_pending until the owner workspace is prepared.',
].join('\n');
}
return [
'Review snapshot updated.',
`- Task: ${result.task.id}`,
`- Owner workspace: ${result.ownerWorkspace.workspace_dir}`,
`- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`,
].join('\n');
}

View File

@@ -124,17 +124,74 @@ describe('paired /review command path', () => {
'owner', 'owner',
); );
expect(result).not.toBeNull(); expect(result?.status).toBe('ready');
expect(result!.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir); if (result?.status !== 'ready') {
expect(result!.reviewerWorkspace.snapshot_source_dir).toBe( throw new Error('expected ready review result');
}
expect(result.ownerWorkspace.workspace_dir).toBe(ownerWorkspaceDir);
expect(result.reviewerWorkspace.snapshot_source_dir).toBe(
ownerWorkspaceDir, ownerWorkspaceDir,
); );
expect( expect(
fs.readFileSync( fs.readFileSync(
path.join(result!.reviewerWorkspace.workspace_dir, 'README.md'), path.join(result.reviewerWorkspace.workspace_dir, 'README.md'),
'utf-8', 'utf-8',
), ),
).toBe('owner change\n'); ).toBe('owner change\n');
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 () => {
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-review');
process.env.SERVICE_ID = 'codex-review';
vi.resetModules();
const { 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',
result?.task.id ?? 'missing-task',
'owner',
);
expect(result).toEqual(
expect.objectContaining({
status: 'pending',
pendingReason: 'owner-workspace-not-ready',
task: expect.objectContaining({
status: 'review_pending',
}),
}),
);
expect(result?.task.review_requested_at).toBeTruthy();
expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false);
});
}); });

View File

@@ -132,6 +132,53 @@ describe('paired workspace manager', () => {
).toBeTruthy(); ).toBeTruthy();
}); });
it('keeps review_pending when /review is requested before an owner workspace exists', 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'), 'original\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-pending',
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 result = manager.markPairedTaskReviewReady('paired-task-pending');
expect(result).toBeNull();
expect(db.getPairedTaskById('paired-task-pending')?.status).toBe(
'review_pending',
);
expect(
db.getPairedTaskById('paired-task-pending')?.review_requested_at,
).toBeTruthy();
});
it('uses the shared DB owner workspace across service-local data dirs', async () => { it('uses the shared DB owner workspace across service-local data dirs', async () => {
const canonicalDir = path.join(tempRoot, 'canonical'); const canonicalDir = path.join(tempRoot, 'canonical');
fs.mkdirSync(canonicalDir, { recursive: true }); fs.mkdirSync(canonicalDir, { recursive: true });
@@ -602,6 +649,69 @@ describe('paired workspace manager', () => {
); );
}); });
it('auto-refreshes once the owner workspace appears after review_pending was already recorded', 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-5c',
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,
});
expect(manager.markPairedTaskReviewReady('paired-task-5c')).toBeNull();
expect(db.getPairedTaskById('paired-task-5c')?.status).toBe(
'review_pending',
);
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-5c');
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
'owner ready now\n',
);
const result = manager.prepareReviewerWorkspaceForExecution(
db.getPairedTaskById('paired-task-5c')!,
);
expect(result.autoRefreshed).toBe(true);
expect(result.blockMessage).toBeUndefined();
expect(
fs.readFileSync(
path.join(result.workspace!.workspace_dir, 'README.md'),
'utf-8',
),
).toBe('owner ready now\n');
expect(db.getPairedTaskById('paired-task-5c')?.status).toBe('review_ready');
});
it('blocks once when an in-review snapshot becomes stale, then refreshes on retry', async () => { it('blocks once when an in-review snapshot becomes stale, then refreshes on retry', async () => {
const { db, manager } = await loadModules(); const { db, manager } = await loadModules();
db._initTestDatabase(); db._initTestDatabase();

View File

@@ -91,6 +91,22 @@ describe('isSessionCommandControlMessage', () => {
).toBe(true); ).toBe(true);
}); });
it('matches multiline review-updated output', () => {
expect(
isSessionCommandControlMessage(
'Review snapshot updated.\n- Task: paired-task-1',
),
).toBe(true);
});
it('matches pending review 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.',
),
).toBe(true);
});
it('does not match regular bot conversation', () => { it('does not match regular bot conversation', () => {
expect( expect(
isSessionCommandControlMessage( isSessionCommandControlMessage(
@@ -237,6 +253,31 @@ describe('handleSessionCommand', () => {
); );
}); });
it('sends a 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.',
),
});
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.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.',
);
});
it('sends denial to interactable sender in non-main group', async () => { it('sends denial to interactable sender in non-main group', async () => {
const deps = makeDeps(); const deps = makeDeps();
const result = await handleSessionCommand({ const result = await handleSessionCommand({

View File

@@ -10,7 +10,8 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Failed to process messages before \/compact\. Try again\.$/, /^Failed to process messages before \/compact\. Try again\.$/,
/^\/compact failed\. The session is unchanged\.$/, /^\/compact failed\. The session is unchanged\.$/,
/^Conversation compacted\.$/, /^Conversation compacted\.$/,
/^Review snapshot updated\.$/, /^Review snapshot updated\.(?:\n|$)/,
/^Review request recorded, but the owner workspace is not ready yet\.(?:\n|$)/,
/^Review is unavailable for this room\./, /^Review is unavailable for this room\./,
]; ];