Files
EJClaw/src/paired-execution-context.test.ts
Eyejoker a74ea4bad0 fix: preserve paired owner workspace branch invariant
Safely re-anchor drifted paired owner workspaces, guard owner completion handling, and tighten generated attachment temp-dir allowlisting.
2026-04-25 12:02:02 +09:00

1449 lines
43 KiB
TypeScript

import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
CLAUDE_REVIEWER_READONLY_ENV,
REVIEWER_RUNTIME_ENV,
} from 'ejclaw-runners-shared';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => {
const updatePairedTask = vi.fn();
return {
cancelPairedTurn: vi.fn(),
createPairedTask: vi.fn(),
getLatestPairedTaskForChat: vi.fn(),
getLatestOpenPairedTaskForChat: vi.fn(),
getPairedTaskById: vi.fn(),
getPairedTurnById: vi.fn(),
getPairedTurnOutputs: vi.fn(() => []),
getPairedWorkspace: vi.fn(),
insertPairedTurnOutput: vi.fn(),
updatePairedTask,
updatePairedTaskIfUnchanged: vi.fn((id, _expectedUpdatedAt, updates) => {
updatePairedTask(id, updates);
return true;
}),
upsertPairedProject: vi.fn(),
hasActiveCiWatcherForChat: vi.fn(() => false),
releasePairedTaskExecutionLease: vi.fn(),
};
});
vi.mock('./paired-workspace-manager.js', () => ({
isOwnerWorkspaceRepairNeededError: vi.fn(() => false),
markPairedTaskReviewReady: vi.fn(),
prepareReviewerWorkspaceForExecution: vi.fn(),
provisionOwnerWorkspaceForPairedTask: vi.fn(),
}));
vi.mock('./logger.js', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import * as db from './db.js';
import * as config from './config.js';
import {
completePairedExecutionContext,
preparePairedExecutionContext,
resolveOwnerTaskForHumanMessage,
} from './paired-execution-context.js';
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
import type {
PairedTask,
PairedWorkspace,
RegisteredGroup,
RoomRoleContext,
} from './types.js';
const group: RegisteredGroup = {
name: 'Paired Room',
folder: 'paired-room',
trigger: '@codex',
added_at: '2026-03-28T00:00:00.000Z',
agentType: 'codex',
workDir: '/repo/canonical',
};
const ownerContext: RoomRoleContext = {
serviceId: config.CODEX_MAIN_SERVICE_ID,
role: 'owner',
ownerServiceId: config.CODEX_MAIN_SERVICE_ID,
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
failoverOwner: false,
};
const reviewerContext: RoomRoleContext = {
serviceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
role: 'reviewer',
ownerServiceId: config.CODEX_MAIN_SERVICE_ID,
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
failoverOwner: false,
};
const failoverOwnerContext: RoomRoleContext = {
serviceId: config.CODEX_REVIEW_SERVICE_ID,
role: 'owner',
ownerServiceId: config.CODEX_REVIEW_SERVICE_ID,
reviewerServiceId: config.REVIEWER_SERVICE_ID_FOR_TYPE,
failoverOwner: true,
};
const ORIGINAL_UNSAFE_HOST_PAIRED_MODE =
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
const ORIGINAL_REVIEWER_RUNTIME = process.env[REVIEWER_RUNTIME_ENV];
const ORIGINAL_CLAUDE_REVIEWER_READONLY =
process.env[CLAUDE_REVIEWER_READONLY_ENV];
function createCanonicalRepoWithCommit(commitMessage: string): string {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-finalize-'));
execFileSync('git', ['init'], { cwd: repoDir, stdio: 'ignore' });
execFileSync('git', ['config', 'user.name', 'Test User'], {
cwd: repoDir,
stdio: 'ignore',
});
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
cwd: repoDir,
stdio: 'ignore',
});
fs.writeFileSync(path.join(repoDir, 'README.md'), `${commitMessage}\n`);
execFileSync('git', ['add', 'README.md'], { cwd: repoDir, stdio: 'ignore' });
execFileSync('git', ['commit', '-m', commitMessage], {
cwd: repoDir,
stdio: 'ignore',
});
return repoDir;
}
function resolveTreeRef(repoDir: string): string {
return execFileSync('git', ['rev-parse', 'HEAD^{tree}'], {
cwd: repoDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function buildPairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
return {
id: 'task-1',
chat_jid: 'dc:test',
group_folder: group.folder,
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
title: null,
source_ref: 'HEAD',
plan_notes: null,
review_requested_at: null,
round_trip_count: 0,
owner_failure_count: 0,
owner_step_done_streak: 0,
finalize_step_done_count: 0,
task_done_then_user_reopen_count: 0,
empty_step_done_streak: 0,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
...overrides,
};
}
function buildWorkspace(
role: 'owner' | 'reviewer',
workspaceDir: string,
): PairedWorkspace {
return {
id: `task-1:${role}`,
task_id: 'task-1',
role,
workspace_dir: workspaceDir,
snapshot_source_dir:
role === 'reviewer' ? '/tmp/paired/task-1/owner' : null,
snapshot_ref: role === 'reviewer' ? 'fingerprint-1' : null,
status: 'ready',
snapshot_refreshed_at:
role === 'reviewer' ? '2026-03-28T00:00:00.000Z' : null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
};
}
describe('paired execution context', () => {
beforeEach(() => {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
delete process.env[REVIEWER_RUNTIME_ENV];
delete process.env[CLAUDE_REVIEWER_READONLY_ENV];
vi.resetAllMocks();
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getPairedTaskById).mockReturnValue(undefined);
vi.mocked(db.getPairedWorkspace).mockReturnValue(undefined);
vi.mocked(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).mockReturnValue({
id: 'task-1:owner',
task_id: 'task-1',
role: 'owner',
workspace_dir: '/tmp/paired/task-1/owner',
snapshot_source_dir: null,
snapshot_ref: null,
status: 'ready',
snapshot_refreshed_at: null,
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,
});
});
afterAll(() => {
if (ORIGINAL_UNSAFE_HOST_PAIRED_MODE == null) {
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
} else {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE =
ORIGINAL_UNSAFE_HOST_PAIRED_MODE;
}
if (ORIGINAL_REVIEWER_RUNTIME == null) {
delete process.env[REVIEWER_RUNTIME_ENV];
} else {
process.env[REVIEWER_RUNTIME_ENV] = ORIGINAL_REVIEWER_RUNTIME;
}
if (ORIGINAL_CLAUDE_REVIEWER_READONLY == null) {
delete process.env[CLAUDE_REVIEWER_READONLY_ENV];
} else {
process.env[CLAUDE_REVIEWER_READONLY_ENV] =
ORIGINAL_CLAUDE_REVIEWER_READONLY;
}
});
it('creates an owner execution with a worktree override', () => {
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-1',
roomRoleContext: ownerContext,
hasHumanMessage: true,
});
expect(db.upsertPairedProject).toHaveBeenCalled();
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(db.createPairedTask).toHaveBeenCalledWith(
expect.objectContaining({
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
status: 'active',
owner_agent_type: 'codex',
reviewer_agent_type: config.REVIEWER_AGENT_TYPE,
arbiter_agent_type: config.ARBITER_AGENT_TYPE ?? null,
}),
);
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/owner',
EJCLAW_PAIRED_ROLE: 'owner',
});
});
it('does not carry forward the latest owner final by default when a merge_ready task is superseded by new human input', () => {
const supersededTask = buildPairedTask({
id: 'task-superseded',
status: 'merge_ready',
updated_at: '2026-03-28T00:05:00.000Z',
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
supersededTask,
);
vi.mocked(db.getPairedTurnOutputs).mockReturnValue([
{
id: 1,
task_id: 'task-superseded',
turn_number: 1,
role: 'owner',
output_text: 'DONE_WITH_CONCERNS\n이전 task owner final',
created_at: '2026-03-28T00:01:00.000Z',
},
{
id: 2,
task_id: 'task-superseded',
turn_number: 2,
role: 'reviewer',
output_text: 'DONE\nreview approved',
created_at: '2026-03-28T00:02:00.000Z',
},
]);
const result = resolveOwnerTaskForHumanMessage({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
existingTask: supersededTask,
});
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(result.supersededTask).toEqual(supersededTask);
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
});
it('records a quick reopen when a new owner task starts shortly after TASK_DONE completion', () => {
const previousTask = buildPairedTask({
id: 'task-completed',
status: 'completed',
completion_reason: 'done',
updated_at: new Date(Date.now() - 60_000).toISOString(),
task_done_then_user_reopen_count: 0,
});
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(previousTask);
const result = resolveOwnerTaskForHumanMessage({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
existingTask: null,
});
expect(result.task).not.toBeNull();
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-completed',
expect.objectContaining({
task_done_then_user_reopen_count: 1,
}),
);
});
it('resets active STEP_DONE loop counters when a new human message continues an active task', () => {
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
buildPairedTask({
status: 'active',
owner_failure_count: 1,
owner_step_done_streak: 2,
empty_step_done_streak: 2,
}),
);
preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-human-reset-step-done-loop',
roomRoleContext: ownerContext,
hasHumanMessage: true,
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 0,
owner_failure_count: 0,
owner_step_done_streak: 0,
empty_step_done_streak: 0,
}),
);
});
it('uses room role context agent overrides when creating a paired task', () => {
preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-room-overrides',
roomRoleContext: {
...ownerContext,
ownerAgentType: 'codex',
reviewerAgentType: 'codex',
reviewerServiceId: config.CODEX_REVIEW_SERVICE_ID,
arbiterAgentType: 'claude-code',
},
hasHumanMessage: true,
});
expect(db.createPairedTask).toHaveBeenCalledWith(
expect.objectContaining({
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.CODEX_REVIEW_SERVICE_ID,
owner_agent_type: 'codex',
reviewer_agent_type: 'codex',
arbiter_agent_type: 'claude-code',
}),
);
});
it('persists stable role-slot service shadow instead of the transient failover owner lease', () => {
preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-failover-owner',
roomRoleContext: failoverOwnerContext,
hasHumanMessage: true,
});
expect(db.createPairedTask).toHaveBeenCalledWith(
expect.objectContaining({
owner_service_id: config.CODEX_MAIN_SERVICE_ID,
reviewer_service_id: config.REVIEWER_SERVICE_ID_FOR_TYPE,
owner_agent_type: 'codex',
reviewer_agent_type: config.REVIEWER_AGENT_TYPE,
}),
);
});
it('uses the reviewer snapshot after lazy auto-refresh and marks the task in_review', () => {
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',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: {
id: 'task-1:reviewer',
task_id: 'task-1',
role: 'reviewer',
workspace_dir: '/tmp/paired/task-1/reviewer',
snapshot_source_dir: '/tmp/paired/task-1/owner',
snapshot_ref: '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',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-2',
roomRoleContext: reviewerContext,
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({ status: 'in_review' }),
);
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
EJCLAW_REVIEWER_RUNTIME: '1',
EJCLAW_PAIRED_ROLE: 'reviewer',
});
});
it('preserves the claimed reviewer turn revision for in-review continuations and requires a visible verdict', () => {
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',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:05: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',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:05:00.000Z',
});
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
autoRefreshed: false,
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-review-continuation',
roomRoleContext: reviewerContext,
pairedTurnIdentity: {
turnId: 'task-1:2026-03-28T00:00:00.000Z:reviewer-turn',
taskId: 'task-1',
taskUpdatedAt: '2026-03-28T00:00:00.000Z',
intentKind: 'reviewer-turn',
role: 'reviewer',
},
});
expect(result?.claimedTaskUpdatedAt).toBe('2026-03-28T00:00:00.000Z');
expect(result?.requiresVisibleVerdict).toBe(true);
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({ status: 'in_review' }),
);
});
it('does not change task state when the reviewer snapshot is not ready', () => {
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',
plan_notes: null,
round_trip_count: 0,
review_requested_at: null,
status: 'active',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
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 not ready yet. Wait for the owner to complete a turn so the reviewer snapshot can be prepared.',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-general-reviewer',
roomRoleContext: reviewerContext,
});
expect(result?.blockMessage).toBe(
'Review snapshot is not ready yet. Wait for the owner to complete a turn so the reviewer snapshot can be prepared.',
);
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({ status: 'in_review' }),
);
});
it('blocks owner execution when the owner workspace needs branch repair', () => {
const repairError = new Error(
'BLOCKED\nOwner workspace needs repair.\nOwner workspace branch mismatch: `codex/owner/paired-room-sync` -> expected `codex/owner/paired-room`.',
);
vi.mocked(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).mockImplementation(() => {
throw repairError;
});
vi.mocked(
pairedWorkspaceManager.isOwnerWorkspaceRepairNeededError,
).mockReturnValue(true);
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-owner-repair-needed',
roomRoleContext: ownerContext,
hasHumanMessage: true,
});
expect(result?.blockMessage).toContain('Owner workspace needs repair.');
expect(result?.blockMessage).toContain(
'`codex/owner/paired-room-sync` -> expected `codex/owner/paired-room`',
);
expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined();
});
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',
plan_notes: null,
round_trip_count: 0,
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
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();
});
it('routes reviewer to host mode when unsafe host paired mode is enabled', () => {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
buildPairedTask({
status: 'review_ready',
review_requested_at: '2026-03-28T00:00:00.000Z',
}),
);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'review_ready',
review_requested_at: '2026-03-28T00:00:00.000Z',
}),
);
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
autoRefreshed: false,
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-host-reviewer',
roomRoleContext: reviewerContext,
});
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
});
expect(result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY).toBe('1');
expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined();
expect(result?.envOverrides.CLAUDE_CONFIG_DIR).toContain(
'/data/sessions/paired-room-reviewer',
);
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
});
it('honors room-level reviewer agent overrides for unsafe host reviewer runtime env', () => {
process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE = '1';
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(
buildPairedTask({
status: 'review_ready',
review_requested_at: '2026-03-28T00:00:00.000Z',
reviewer_agent_type: 'codex',
}),
);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'review_ready',
review_requested_at: '2026-03-28T00:00:00.000Z',
reviewer_agent_type: 'codex',
}),
);
vi.mocked(
pairedWorkspaceManager.prepareReviewerWorkspaceForExecution,
).mockReturnValue({
workspace: buildWorkspace('reviewer', '/tmp/paired/task-1/reviewer'),
autoRefreshed: false,
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-host-reviewer-room-override',
roomRoleContext: {
...reviewerContext,
reviewerAgentType: 'codex',
},
});
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/reviewer',
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
});
expect(
result?.envOverrides.EJCLAW_CLAUDE_REVIEWER_READONLY,
).toBeUndefined();
expect(result?.envOverrides.EJCLAW_REVIEWER_RUNTIME).toBeUndefined();
delete process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE;
});
it('completePairedExecutionContext logs without error', () => {
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'done',
});
// Should not throw; just logs.
});
it('ignores late completions after the paired task is already completed', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'completed',
completion_reason: 'done',
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'DONE',
});
expect(db.updatePairedTask).not.toHaveBeenCalled();
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
});
it('completes owner finalize when only the commit object changed after approval', () => {
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
execFileSync('git', ['commit', '--allow-empty', '-m', 'metadata only'], {
cwd: repoDir,
stdio: 'ignore',
});
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
source_ref: approvedSourceRef,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'DONE',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({ status: 'completed' }),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
});
it('re-anchors the owner workspace before handling a successful owner completion', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
round_trip_count: 1,
}),
);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n1단계 완료, 후속 작업 계속',
});
expect(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).toHaveBeenCalledWith('task-1');
const reanchorCallOrder = vi.mocked(
pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask,
).mock.invocationCallOrder[0];
const reviewReadyCallOrder = vi.mocked(
pairedWorkspaceManager.markPairedTaskReviewReady,
).mock.invocationCallOrder[0];
expect(reanchorCallOrder).toBeLessThan(reviewReadyCallOrder);
});
it('requests review when the owner reports STEP_DONE in active mode', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
round_trip_count: 1,
}),
);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n1단계 완료, 후속 작업 계속',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 2,
owner_step_done_streak: 0,
empty_step_done_streak: 0,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1');
});
it('requests review instead of arbiter when active STEP_DONE repeats without code changes', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
const repoDir = createCanonicalRepoWithCommit('active step done loop');
const sourceRef = resolveTreeRef(repoDir);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
source_ref: sourceRef,
owner_step_done_streak: 2,
empty_step_done_streak: 2,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', repoDir),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n요약만 반복되고 코드 변경은 없음',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 1,
owner_step_done_streak: 0,
empty_step_done_streak: 0,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1');
expect(db.updatePairedTask).not.toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
}),
);
});
it('re-triggers review when the owner reports STEP_DONE during finalize', () => {
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
source_ref: approvedSourceRef,
round_trip_count: 1,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', repoDir),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n남은 범위가 있어서 계속 진행',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'active',
owner_failure_count: 0,
finalize_step_done_count: 1,
empty_step_done_streak: 1,
}),
);
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 2,
finalize_step_done_count: 1,
empty_step_done_streak: 1,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1');
});
it('requests arbiter when finalize STEP_DONE repeats without code changes', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
source_ref: approvedSourceRef,
empty_step_done_streak: 1,
finalize_step_done_count: 1,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'STEP_DONE\n아직 남았지만 코드 변경은 없음',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
empty_step_done_streak: 2,
finalize_step_done_count: 2,
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
});
it('re-triggers review once when owner reports DONE_WITH_CONCERNS during finalize', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
round_trip_count: 1,
}),
);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'DONE_WITH_CONCERNS\n\nneeds another reviewer pass',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'active',
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledTimes(1);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1');
const returnToActiveOrder = vi
.mocked(db.updatePairedTask)
.mock.calls.findIndex(
([id, patch]) => id === 'task-1' && patch.status === 'active',
);
const reopenReviewOrder = vi.mocked(
pairedWorkspaceManager.markPairedTaskReviewReady,
).mock.invocationCallOrder[0];
expect(returnToActiveOrder).toBeGreaterThanOrEqual(0);
expect(
vi.mocked(db.updatePairedTask).mock.invocationCallOrder[
returnToActiveOrder
],
).toBeLessThan(reopenReviewOrder);
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 2,
}),
);
});
it('re-triggers review when owner changed code after approval', () => {
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
fs.writeFileSync(path.join(repoDir, 'README.md'), 'changed\n');
execFileSync('git', ['add', 'README.md'], {
cwd: repoDir,
stdio: 'ignore',
});
execFileSync('git', ['commit', '-m', 'code change'], {
cwd: repoDir,
stdio: 'ignore',
});
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
source_ref: approvedSourceRef,
round_trip_count: 1,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', repoDir),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'DONE',
});
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1');
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledTimes(1);
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'active',
}),
);
const returnToActiveOrder = vi
.mocked(db.updatePairedTask)
.mock.calls.findIndex(
([id, patch]) => id === 'task-1' && patch.status === 'active',
);
const reopenReviewOrder = vi.mocked(
pairedWorkspaceManager.markPairedTaskReviewReady,
).mock.invocationCallOrder[0];
expect(returnToActiveOrder).toBeGreaterThanOrEqual(0);
expect(
vi.mocked(db.updatePairedTask).mock.invocationCallOrder[
returnToActiveOrder
],
).toBeLessThan(reopenReviewOrder);
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 2,
}),
);
});
it('marks review_ready but defers reviewer enqueue when an active CI watcher exists', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
round_trip_count: 0,
}),
);
vi.mocked(db.hasActiveCiWatcherForChat).mockReturnValue(true);
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: buildWorkspace('owner', '/tmp/paired/task-1/owner'),
reviewerWorkspace: buildWorkspace(
'reviewer',
'/tmp/paired/task-1/reviewer',
),
},
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'DONE',
});
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).toHaveBeenCalledWith('task-1');
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
round_trip_count: 1,
}),
);
});
it('requests arbiter instead of re-reviewing when repeated DONE finalize loops exceed the threshold', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
fs.writeFileSync(path.join(repoDir, 'README.md'), 'changed again\n');
execFileSync('git', ['add', 'README.md'], {
cwd: repoDir,
stdio: 'ignore',
});
execFileSync('git', ['commit', '-m', 'code change'], {
cwd: repoDir,
stdio: 'ignore',
});
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
source_ref: approvedSourceRef,
round_trip_count: config.ARBITER_DEADLOCK_THRESHOLD,
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary: 'DONE',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
arbiter_requested_at: expect.any(String),
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
});
it.each(['BLOCKED', 'NEEDS_CONTEXT'])(
'escalates immediately when owner reports %s during finalize without arbiter',
(summary) => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(false);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
round_trip_count: 1,
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary,
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'completed',
completion_reason: 'escalated',
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
},
);
it.each(['BLOCKED', 'NEEDS_CONTEXT'])(
'escalates immediately when owner reports %s during a normal turn without arbiter',
(summary) => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(false);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
round_trip_count: 0,
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'succeeded',
summary,
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'completed',
completion_reason: 'escalated',
}),
);
expect(
pairedWorkspaceManager.markPairedTaskReviewReady,
).not.toHaveBeenCalled();
},
);
it('records source_ref when reviewer verdict DONE arrives via failed fallback', () => {
const repoDir = createCanonicalRepoWithCommit('reviewed');
const approvedSourceRef = resolveTreeRef(repoDir);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'in_review',
source_ref: 'stale-ref',
}),
);
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'reviewer',
status: 'failed',
summary: 'DONE',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'merge_ready',
source_ref: approvedSourceRef,
}),
);
});
it('keeps reviewer tasks review_ready when reviewer execution fails without a terminal verdict', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'in_review',
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'reviewer',
status: 'failed',
summary: 'runtime exploded before verdict',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'review_ready',
updated_at: expect.any(String),
}),
);
});
it('keeps arbiter tasks arbiter_requested when arbiter execution fails without a terminal verdict', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'in_arbitration',
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'arbiter',
status: 'failed',
summary: 'runtime exploded before verdict',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
updated_at: expect.any(String),
}),
);
});
it('increments owner failure count and resets owner tasks to active after failed execution', () => {
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'failed',
summary: 'push failed',
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'active',
owner_failure_count: 1,
updated_at: expect.any(String),
}),
);
});
it('requests arbiter after repeated owner execution failures without a visible verdict', () => {
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'active',
owner_failure_count: 1,
}),
);
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'failed',
summary:
"Error running remote compact task: Unknown parameter: 'prompt_cache_retention'",
});
expect(db.updatePairedTask).toHaveBeenCalledWith(
'task-1',
expect.objectContaining({
status: 'arbiter_requested',
owner_failure_count: 2,
arbiter_requested_at: expect.any(String),
}),
);
});
it('releases the execution lease even when a completion handler throws', () => {
const transitionError = new Error('transition failed');
vi.mocked(db.getPairedTaskById).mockReturnValue(
buildPairedTask({
status: 'merge_ready',
}),
);
vi.mocked(db.updatePairedTaskIfUnchanged).mockImplementationOnce(() => {
throw transitionError;
});
expect(() =>
completePairedExecutionContext({
taskId: 'task-1',
role: 'owner',
status: 'failed',
runId: 'run-lease-release',
summary: 'push failed',
}),
).toThrow('transition failed');
expect(db.releasePairedTaskExecutionLease).toHaveBeenCalledWith({
taskId: 'task-1',
runId: 'run-lease-release',
});
});
});