feat: add paired review workspace flow

This commit is contained in:
Eyejoker
2026-03-28 21:14:25 +09:00
parent 79a8a2639e
commit e7f49d77da
19 changed files with 2719 additions and 20 deletions

View File

@@ -25,6 +25,11 @@ import {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
import {
buildReviewerGitGuardEnv,
isReviewerMutatingShellCommand,
isReviewerRuntime,
} from './reviewer-runtime.js';
interface ContainerInput {
prompt: string;
@@ -466,6 +471,25 @@ function createSanitizeBashHook(): HookCallback {
};
}
function createReviewerBashGuardHook(): HookCallback {
return async (input, _toolUseId, _context) => {
const preInput = input as PreToolUseHookInput;
const command = (preInput.tool_input as { command?: string })?.command;
if (!command || !isReviewerMutatingShellCommand(command)) {
return {};
}
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'EJClaw reviewer runtime blocks mutating shell commands in paired review mode.',
},
};
};
}
function sanitizeFilename(summary: string): string {
return summary
.toLowerCase()
@@ -595,6 +619,7 @@ async function runQuery(
mcpServerPath: string,
containerInput: ContainerInput,
sdkEnv: Record<string, string | undefined>,
reviewerRuntime: boolean,
): Promise<{
newSessionId?: string;
closedDuringQuery: boolean;
@@ -658,6 +683,28 @@ async function runQuery(
if (model) log(`Using model: ${model}`);
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
if (effort) log(`Effort: ${effort}`);
if (reviewerRuntime) log('Reviewer runtime restrictions enabled');
const allowedTools = reviewerRuntime
? [
'Bash',
'Read', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'mcp__ejclaw__*',
]
: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__ejclaw__*'
];
for await (const message of query({
prompt: stream,
@@ -668,16 +715,7 @@ async function runQuery(
effort,
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
allowedTools: [
'Bash',
'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch',
'Task', 'TaskOutput', 'TaskStop',
'TeamCreate', 'TeamDelete', 'SendMessage',
'TodoWrite', 'ToolSearch', 'Skill',
'NotebookEdit',
'mcp__ejclaw__*'
],
allowedTools,
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
@@ -715,7 +753,14 @@ async function runQuery(
},
hooks: {
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
PreToolUse: [
{
matcher: 'Bash',
hooks: reviewerRuntime
? [createReviewerBashGuardHook(), createSanitizeBashHook()]
: [createSanitizeBashHook()],
},
],
},
agentProgressSummaries: true,
}
@@ -954,6 +999,10 @@ async function main(): Promise<void> {
for (const [key, value] of Object.entries(containerInput.secrets || {})) {
sdkEnv[key] = value;
}
const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntime(containerInput.roomRoleContext);
const guardedSdkEnv = buildReviewerGitGuardEnv(sdkEnv, reviewerRuntime);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
@@ -1001,7 +1050,7 @@ async function main(): Promise<void> {
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],
env: sdkEnv,
env: guardedSdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'] as const,
@@ -1088,7 +1137,8 @@ async function main(): Promise<void> {
sessionId,
mcpServerPath,
containerInput,
sdkEnv,
guardedSdkEnv,
reviewerRuntime,
);
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;

View File

@@ -0,0 +1,220 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
const MUTATING_SHELL_PATTERNS = [
/\bsed\s+-i\b/i,
/\bperl\s+-i\b/i,
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
];
export function isReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
return roomRoleContext?.role === 'reviewer';
}
function findGitSubcommand(args: string[]): string {
let skipNext = false;
for (const arg of args) {
if (skipNext) {
skipNext = false;
continue;
}
switch (arg) {
case '-c':
case '-C':
case '--git-dir':
case '--work-tree':
case '--namespace':
case '--exec-path':
case '--config-env':
skipNext = true;
continue;
default:
break;
}
if (
arg.startsWith('-c') ||
arg.startsWith('-C') ||
arg.startsWith('--git-dir=') ||
arg.startsWith('--work-tree=') ||
arg.startsWith('--namespace=') ||
arg.startsWith('--exec-path=') ||
arg.startsWith('--config-env=')
) {
continue;
}
if (arg.startsWith('-')) {
continue;
}
return arg;
}
return '';
}
function tokenizeShellWords(segment: string): string[] {
const tokens: string[] = [];
let current = '';
let quote: '"' | "'" | null = null;
let escaped = false;
for (const char of segment) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\' && quote !== "'") {
escaped = true;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
} else {
current += char;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (/\s/.test(char)) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function isBlockedGitCommand(command: string): boolean {
const segments = command
.split(/&&|\|\||[;\n]/)
.map((segment) => segment.trim())
.filter(Boolean);
for (const segment of segments) {
const tokens = tokenizeShellWords(segment);
if (tokens[0] !== 'git') {
continue;
}
const subcommand = findGitSubcommand(tokens.slice(1));
if (BLOCKED_GIT_SUBCOMMANDS.has(subcommand)) {
return true;
}
}
return false;
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const wrapperDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
for arg in "$@"; do
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}
export function isReviewerMutatingShellCommand(command: string): boolean {
const normalized = command.trim();
return (
isBlockedGitCommand(normalized) ||
MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized))
);
}

View File

@@ -0,0 +1,67 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
buildReviewerGitGuardEnv,
isReviewerMutatingShellCommand,
isReviewerRuntime,
} from '../src/reviewer-runtime.js';
describe('claude reviewer runtime guard', () => {
it('detects reviewer room metadata', () => {
expect(
isReviewerRuntime({
serviceId: 'codex-review',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
}),
).toBe(true);
});
it('flags mutating shell commands', () => {
expect(isReviewerMutatingShellCommand('git commit -m "x"')).toBe(true);
expect(
isReviewerMutatingShellCommand('git -c color.ui=false commit -m "x"'),
).toBe(true);
expect(isReviewerMutatingShellCommand('sed -i s/a/b/ file.ts')).toBe(
true,
);
expect(isReviewerMutatingShellCommand('git status')).toBe(false);
expect(isReviewerMutatingShellCommand('npm test')).toBe(false);
});
it('prepends a git wrapper to PATH for reviewer runtimes', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
expect(env.PATH).toContain('ejclaw-reviewer-git-');
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
});
it('blocks mutating git subcommands even when git options come first', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
try {
execFileSync('git', ['-c', 'color.ui=false', 'commit', '-m', 'x'], {
cwd,
env,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
throw new Error('expected git wrapper to block commit');
} catch (error) {
const stderr =
error instanceof Error && 'stderr' in error
? String((error as Error & { stderr?: string | Buffer }).stderr ?? '')
: '';
expect(stderr).toContain(
'EJClaw reviewer runtime blocks mutating git subcommands: commit',
);
}
});
});

View File

@@ -23,6 +23,10 @@ import {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
import {
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from './reviewer-runtime.js';
// ── Types ──────────────────────────────────────────────────────────
@@ -358,9 +362,13 @@ async function runAppServerSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const reviewerRuntime =
process.env.EJCLAW_REVIEWER_RUNTIME === '1' ||
isReviewerRuntime(containerInput.roomRoleContext);
const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime);
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: process.env,
env: clientEnv,
log,
});

View File

@@ -0,0 +1,105 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { RoomRoleContext } from './room-role-context.js';
const BLOCKED_GIT_SUBCOMMANDS = new Set([
'add',
'am',
'apply',
'branch',
'checkout',
'cherry-pick',
'clean',
'commit',
'merge',
'push',
'rebase',
'reset',
'restore',
'stash',
'switch',
'tag',
'worktree',
]);
export function isReviewerRuntime(
roomRoleContext?: RoomRoleContext,
): boolean {
return roomRoleContext?.role === 'reviewer';
}
function resolveGitBinary(baseEnv: NodeJS.ProcessEnv): string {
return execFileSync('bash', ['-lc', 'command -v git'], {
encoding: 'utf-8',
env: baseEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
export function buildReviewerGitGuardEnv(
baseEnv: NodeJS.ProcessEnv,
reviewerRuntime: boolean,
): NodeJS.ProcessEnv {
if (!reviewerRuntime) {
return baseEnv;
}
const realGitPath = resolveGitBinary(baseEnv);
const wrapperDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-reviewer-git-'),
);
const wrapperPath = path.join(wrapperDir, 'git');
const blocked = [...BLOCKED_GIT_SUBCOMMANDS]
.map((value) => `'${value}'`)
.join(' ');
const script = `#!/usr/bin/env bash
set -euo pipefail
real_git=${JSON.stringify(realGitPath)}
blocked_subcommands=(${blocked})
subcmd=""
skip_next=0
for arg in "$@"; do
if [[ "$skip_next" == "1" ]]; then
skip_next=0
continue
fi
case "$arg" in
-c|-C|--git-dir|--work-tree|--namespace|--exec-path|--config-env)
skip_next=1
continue
;;
-c*|-C*|--git-dir=*|--work-tree=*|--namespace=*|--exec-path=*|--config-env=*)
continue
;;
--*)
continue
;;
-*)
continue
;;
*)
subcmd="$arg"
break
;;
esac
done
for blocked in "\${blocked_subcommands[@]}"; do
if [[ "$subcmd" == "$blocked" ]]; then
echo "EJClaw reviewer runtime blocks mutating git subcommands: $subcmd" >&2
exit 1
fi
done
exec "$real_git" "$@"
`;
fs.writeFileSync(wrapperPath, script, { mode: 0o755 });
return {
...baseEnv,
EJCLAW_REAL_GIT: realGitPath,
PATH: `${wrapperDir}:${baseEnv.PATH || ''}`,
};
}

View File

@@ -0,0 +1,54 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it } from 'vitest';
import {
buildReviewerGitGuardEnv,
isReviewerRuntime,
} from '../src/reviewer-runtime.js';
describe('codex reviewer runtime guard', () => {
it('detects reviewer room metadata', () => {
expect(
isReviewerRuntime({
serviceId: 'codex-review',
role: 'reviewer',
ownerServiceId: 'codex-main',
reviewerServiceId: 'codex-review',
failoverOwner: false,
}),
).toBe(true);
});
it('prepends a git wrapper to PATH for reviewer runtimes', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
expect(env.PATH).toContain('ejclaw-reviewer-git-');
expect(env.EJCLAW_REAL_GIT).toBeTruthy();
});
it('blocks mutating git subcommands even when git options come first', () => {
const env = buildReviewerGitGuardEnv({ PATH: process.env.PATH }, true);
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-reviewer-test-'));
try {
execFileSync('git', ['-c', 'color.ui=false', 'commit', '-m', 'x'], {
cwd,
env,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
throw new Error('expected git wrapper to block commit');
} catch (error) {
const stderr =
error instanceof Error && 'stderr' in error
? String((error as Error & { stderr?: string | Buffer }).stderr ?? '')
: '';
expect(stderr).toContain(
'EJClaw reviewer runtime blocks mutating git subcommands: commit',
);
}
});
});

View File

@@ -6,6 +6,10 @@ import {
_initTestDatabase,
claimServiceHandoff,
completeServiceHandoffAndAdvanceTargetCursor,
createPairedApproval,
createPairedArtifact,
createPairedExecution,
createPairedTask,
createTask,
createServiceHandoff,
createProducedWorkItem,
@@ -22,10 +26,17 @@ import {
getRegisteredAgentTypesForJid,
getMessagesSince,
getNewMessages,
getPairedExecutionById,
getPairedProject,
getPairedTaskById,
getPairedWorkspace,
getRouterStateForService,
isPairedRoomJid,
getSession,
getTaskById,
listPairedApprovalsForTask,
listPairedArtifactsForTask,
listPairedWorkspacesForTask,
markWorkItemDelivered,
markWorkItemDeliveryRetry,
setSession,
@@ -33,6 +44,10 @@ import {
setRouterStateForService,
storeChatMetadata,
storeMessage,
updatePairedExecution,
updatePairedTask,
upsertPairedProject,
upsertPairedWorkspace,
updateTask,
} from './db.js';
import {
@@ -529,6 +544,156 @@ Check the run.
});
});
describe('paired task state', () => {
it('stores project, task, execution, workspace, approval, and artifact state', () => {
upsertPairedProject({
chat_jid: 'dc:paired',
group_folder: 'paired-room',
canonical_work_dir: '/tmp/paired-room',
workspace_topology: 'shadow-snapshot',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
createPairedTask({
id: 'paired-task-1',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: 'wire up workspaces',
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',
});
createPairedExecution({
id: 'paired-exec-1',
task_id: 'paired-task-1',
service_id: 'codex-main',
role: 'owner',
workspace_id: null,
status: 'pending',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: null,
completed_at: null,
});
upsertPairedWorkspace({
id: 'paired-task-1:owner',
task_id: 'paired-task-1',
role: 'owner',
workspace_dir: '/tmp/paired-room/owner',
snapshot_source_dir: null,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
const approvalId = createPairedApproval({
task_id: 'paired-task-1',
service_id: 'codex-review',
role: 'reviewer',
status: 'pending',
note: 'needs snapshot',
created_at: '2026-03-28T00:00:01.000Z',
});
const artifactId = createPairedArtifact({
task_id: 'paired-task-1',
execution_id: 'paired-exec-1',
service_id: 'codex-review',
artifact_type: 'report',
title: 'review notes',
content: 'looks fine',
file_path: null,
created_at: '2026-03-28T00:00:02.000Z',
});
expect(getPairedProject('dc:paired')?.canonical_work_dir).toBe(
'/tmp/paired-room',
);
expect(getPairedTaskById('paired-task-1')?.status).toBe('draft');
expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending');
expect(getPairedWorkspace('paired-task-1', 'owner')?.workspace_dir).toBe(
'/tmp/paired-room/owner',
);
expect(listPairedApprovalsForTask('paired-task-1')[0]?.id).toBe(approvalId);
expect(listPairedArtifactsForTask('paired-task-1')[0]?.id).toBe(artifactId);
});
it('updates task and execution state and keeps one workspace per role', () => {
createPairedTask({
id: 'paired-task-2',
chat_jid: 'dc:paired',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: null,
source_ref: null,
review_requested_at: null,
status: 'draft',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
createPairedExecution({
id: 'paired-exec-2',
task_id: 'paired-task-2',
service_id: 'codex-review',
role: 'reviewer',
workspace_id: null,
status: 'pending',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: null,
completed_at: null,
});
updatePairedTask('paired-task-2', {
status: 'review_ready',
review_requested_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:10:00.000Z',
});
updatePairedExecution('paired-exec-2', {
status: 'running',
started_at: '2026-03-28T00:11:00.000Z',
});
upsertPairedWorkspace({
id: 'paired-task-2:reviewer',
task_id: 'paired-task-2',
role: 'reviewer',
workspace_dir: '/tmp/reviewer-v1',
snapshot_source_dir: '/tmp/owner',
status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:10:00.000Z',
created_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:10:00.000Z',
});
upsertPairedWorkspace({
id: 'paired-task-2:reviewer',
task_id: 'paired-task-2',
role: 'reviewer',
workspace_dir: '/tmp/reviewer-v2',
snapshot_source_dir: '/tmp/owner',
status: 'ready',
snapshot_refreshed_at: '2026-03-28T00:12:00.000Z',
created_at: '2026-03-28T00:10:00.000Z',
updated_at: '2026-03-28T00:12:00.000Z',
});
expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready');
expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running');
expect(
listPairedWorkspacesForTask('paired-task-2').map((workspace) => workspace.workspace_dir),
).toEqual(['/tmp/reviewer-v2']);
});
});
// --- LIMIT behavior ---
describe('message query LIMIT', () => {

441
src/db.ts
View File

@@ -25,6 +25,12 @@ import { getTaskRuntimeTaskId } from './task-watch-status.js';
import {
NewMessage,
AgentType,
PairedApproval,
PairedArtifact,
PairedExecution,
PairedProject,
PairedTask,
PairedWorkspace,
RegisteredGroup,
ScheduledTask,
TaskRunLog,
@@ -230,6 +236,100 @@ function createSchema(database: Database.Database): void {
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
);
CREATE TABLE IF NOT EXISTS paired_projects (
chat_jid TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
canonical_work_dir TEXT NOT NULL,
workspace_topology TEXT NOT NULL DEFAULT 'shadow-snapshot',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (workspace_topology IN ('shadow-snapshot', 'reviewer-cow'))
);
CREATE TABLE IF NOT EXISTS paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_service_id TEXT NOT NULL,
reviewer_service_id TEXT NOT NULL,
title TEXT,
source_ref TEXT,
review_requested_at TEXT,
status TEXT NOT NULL DEFAULT 'draft',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (
status IN (
'draft',
'review_ready',
'in_review',
'changes_requested',
'merge_ready',
'merged',
'discarded',
'failed'
)
)
);
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
CREATE TABLE IF NOT EXISTS paired_executions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
service_id TEXT NOT NULL,
role TEXT NOT NULL,
workspace_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
summary TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_paired_executions_task
ON paired_executions(task_id, created_at);
CREATE TABLE IF NOT EXISTS paired_workspaces (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
role TEXT NOT NULL,
workspace_dir TEXT NOT NULL,
snapshot_source_dir TEXT,
status TEXT NOT NULL DEFAULT 'provisioning',
snapshot_refreshed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('provisioning', 'ready', 'stale', 'failed', 'archived'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
ON paired_workspaces(task_id, role);
CREATE TABLE IF NOT EXISTS paired_approvals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
service_id TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('pending', 'approved', 'rejected'))
);
CREATE INDEX IF NOT EXISTS idx_paired_approvals_task
ON paired_approvals(task_id, created_at);
CREATE TABLE IF NOT EXISTS paired_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
execution_id TEXT,
service_id TEXT NOT NULL,
artifact_type TEXT NOT NULL,
title TEXT,
content TEXT,
file_path TEXT,
created_at TEXT NOT NULL,
CHECK (artifact_type IN ('comment', 'report', 'patch'))
);
CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task
ON paired_artifacts(task_id, created_at);
CREATE TABLE IF NOT EXISTS channel_owner (
chat_jid TEXT PRIMARY KEY,
owner_service_id TEXT NOT NULL,
@@ -1638,6 +1738,347 @@ export function isPairedRoomJid(jid: string): boolean {
return types.includes('claude-code') && types.includes('codex');
}
// --- Paired task/project/workspace state ---
export function upsertPairedProject(project: PairedProject): void {
db.prepare(
`
INSERT INTO paired_projects (
chat_jid,
group_folder,
canonical_work_dir,
workspace_topology,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(chat_jid) DO UPDATE SET
group_folder = excluded.group_folder,
canonical_work_dir = excluded.canonical_work_dir,
workspace_topology = excluded.workspace_topology,
updated_at = excluded.updated_at
`,
).run(
project.chat_jid,
project.group_folder,
project.canonical_work_dir,
project.workspace_topology,
project.created_at,
project.updated_at,
);
}
export function getPairedProject(chatJid: string): PairedProject | undefined {
return db
.prepare('SELECT * FROM paired_projects WHERE chat_jid = ?')
.get(chatJid) as PairedProject | undefined;
}
export function createPairedTask(task: PairedTask): void {
db.prepare(
`
INSERT INTO paired_tasks (
id,
chat_jid,
group_folder,
owner_service_id,
reviewer_service_id,
title,
source_ref,
review_requested_at,
status,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
task.chat_jid,
task.group_folder,
task.owner_service_id,
task.reviewer_service_id,
task.title,
task.source_ref,
task.review_requested_at,
task.status,
task.created_at,
task.updated_at,
);
}
export function getPairedTaskById(id: string): PairedTask | undefined {
return db
.prepare('SELECT * FROM paired_tasks WHERE id = ?')
.get(id) as PairedTask | undefined;
}
export function getLatestOpenPairedTaskForChat(
chatJid: string,
): PairedTask | undefined {
return db
.prepare(
`
SELECT *
FROM paired_tasks
WHERE chat_jid = ?
AND status NOT IN ('merged', 'discarded', 'failed')
ORDER BY updated_at DESC
LIMIT 1
`,
)
.get(chatJid) as PairedTask | undefined;
}
export function updatePairedTask(
id: string,
updates: Partial<
Pick<
PairedTask,
'title' | 'source_ref' | 'review_requested_at' | 'status' | 'updated_at'
>
>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.title !== undefined) {
fields.push('title = ?');
values.push(updates.title);
}
if (updates.source_ref !== undefined) {
fields.push('source_ref = ?');
values.push(updates.source_ref);
}
if (updates.review_requested_at !== undefined) {
fields.push('review_requested_at = ?');
values.push(updates.review_requested_at);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);
}
if (updates.updated_at !== undefined) {
fields.push('updated_at = ?');
values.push(updates.updated_at);
}
if (fields.length === 0) return;
values.push(id);
db.prepare(`UPDATE paired_tasks SET ${fields.join(', ')} WHERE id = ?`).run(
...values,
);
}
export function createPairedExecution(execution: PairedExecution): void {
db.prepare(
`
INSERT INTO paired_executions (
id,
task_id,
service_id,
role,
workspace_id,
status,
summary,
created_at,
started_at,
completed_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
execution.id,
execution.task_id,
execution.service_id,
execution.role,
execution.workspace_id,
execution.status,
execution.summary,
execution.created_at,
execution.started_at,
execution.completed_at,
);
}
export function getPairedExecutionById(
id: string,
): PairedExecution | undefined {
return db
.prepare('SELECT * FROM paired_executions WHERE id = ?')
.get(id) as PairedExecution | undefined;
}
export function updatePairedExecution(
id: string,
updates: Partial<
Pick<
PairedExecution,
'workspace_id' | 'status' | 'summary' | 'started_at' | 'completed_at'
>
>,
): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.workspace_id !== undefined) {
fields.push('workspace_id = ?');
values.push(updates.workspace_id);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);
}
if (updates.summary !== undefined) {
fields.push('summary = ?');
values.push(updates.summary);
}
if (updates.started_at !== undefined) {
fields.push('started_at = ?');
values.push(updates.started_at);
}
if (updates.completed_at !== undefined) {
fields.push('completed_at = ?');
values.push(updates.completed_at);
}
if (fields.length === 0) return;
values.push(id);
db.prepare(
`UPDATE paired_executions SET ${fields.join(', ')} WHERE id = ?`,
).run(...values);
}
export function upsertPairedWorkspace(workspace: PairedWorkspace): void {
db.prepare(
`
INSERT INTO paired_workspaces (
id,
task_id,
role,
workspace_dir,
snapshot_source_dir,
status,
snapshot_refreshed_at,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
workspace_dir = excluded.workspace_dir,
snapshot_source_dir = excluded.snapshot_source_dir,
status = excluded.status,
snapshot_refreshed_at = excluded.snapshot_refreshed_at,
updated_at = excluded.updated_at
`,
).run(
workspace.id,
workspace.task_id,
workspace.role,
workspace.workspace_dir,
workspace.snapshot_source_dir,
workspace.status,
workspace.snapshot_refreshed_at,
workspace.created_at,
workspace.updated_at,
);
}
export function getPairedWorkspace(
taskId: string,
role: PairedWorkspace['role'],
): PairedWorkspace | undefined {
return db
.prepare('SELECT * FROM paired_workspaces WHERE task_id = ? AND role = ?')
.get(taskId, role) as PairedWorkspace | undefined;
}
export function listPairedWorkspacesForTask(taskId: string): PairedWorkspace[] {
return db
.prepare(
'SELECT * FROM paired_workspaces WHERE task_id = ? ORDER BY created_at',
)
.all(taskId) as PairedWorkspace[];
}
export function createPairedApproval(
approval: Omit<PairedApproval, 'id'>,
): number {
const result = db
.prepare(
`
INSERT INTO paired_approvals (
task_id,
service_id,
role,
status,
note,
created_at
)
VALUES (?, ?, ?, ?, ?, ?)
`,
)
.run(
approval.task_id,
approval.service_id,
approval.role,
approval.status,
approval.note,
approval.created_at,
);
return Number(result.lastInsertRowid);
}
export function listPairedApprovalsForTask(taskId: string): PairedApproval[] {
return db
.prepare(
'SELECT * FROM paired_approvals WHERE task_id = ? ORDER BY created_at, id',
)
.all(taskId) as PairedApproval[];
}
export function createPairedArtifact(
artifact: Omit<PairedArtifact, 'id'>,
): number {
const result = db
.prepare(
`
INSERT INTO paired_artifacts (
task_id,
execution_id,
service_id,
artifact_type,
title,
content,
file_path,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
)
.run(
artifact.task_id,
artifact.execution_id,
artifact.service_id,
artifact.artifact_type,
artifact.title,
artifact.content,
artifact.file_path,
artifact.created_at,
);
return Number(result.lastInsertRowid);
}
export function listPairedArtifactsForTask(taskId: string): PairedArtifact[] {
return db
.prepare(
'SELECT * FROM paired_artifacts WHERE task_id = ? ORDER BY created_at, id',
)
.all(taskId) as PairedArtifact[];
}
/**
* Get the most recent bot message (is_bot_message=1) in a chat, regardless of which bot sent it.
* Used for duplicate detection in pair rooms.

View File

@@ -129,3 +129,32 @@ export function resolveServiceTaskSessionsPath(
ensureWithinBase(sessionsBaseDir, sessionsPath);
return sessionsPath;
}
export function resolvePairedTaskWorkspaceRoot(
folder: string,
taskId: string,
): string {
assertValidGroupFolder(folder);
assertValidRuntimeSegment(taskId, 'task ID');
const workspacesBaseDir = path.resolve(DATA_DIR, 'workspaces');
const workspacesPath = path.resolve(
workspacesBaseDir,
folder,
'tasks',
taskId,
);
ensureWithinBase(workspacesBaseDir, workspacesPath);
return workspacesPath;
}
export function resolvePairedTaskWorkspacePath(
folder: string,
taskId: string,
role: 'owner' | 'reviewer',
): string {
assertValidRuntimeSegment(role, 'workspace role');
const workspaceRoot = resolvePairedTaskWorkspaceRoot(folder, taskId);
const workspacePath = path.resolve(workspaceRoot, role);
ensureWithinBase(workspaceRoot, workspacePath);
return workspacePath;
}

View File

@@ -108,11 +108,18 @@ vi.mock('./memento-client.js', () => ({
buildRoomMemoryBriefing: vi.fn(),
}));
vi.mock('./paired-execution-context.js', () => ({
completePairedExecutionContext: vi.fn(),
markRoomReviewReady: vi.fn(),
preparePairedExecutionContext: vi.fn(() => undefined),
}));
import * as agentRunner from './agent-runner.js';
import * as codexTokenRotation from './codex-token-rotation.js';
import * as db from './db.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import { runAgentForGroup } from './message-agent-executor.js';
import * as pairedExecutionContext from './paired-execution-context.js';
import * as sessionRecovery from './session-recovery.js';
import * as serviceRouting from './service-routing.js';
import * as tokenRotation from './token-rotation.js';
@@ -180,6 +187,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -208,6 +216,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -231,6 +240,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -257,6 +267,7 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
@@ -288,8 +299,179 @@ describe('runAgentForGroup room memory', () => {
}),
expect.any(Function),
undefined,
undefined,
);
});
it('passes paired workspace env overrides into the runner when execution metadata exists', async () => {
const group = {
...makeGroup(),
folder: 'test-group',
workDir: '/repo/canonical',
};
vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue(
{
task: {
id: 'paired-task-1',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'claude',
reviewer_service_id: 'codex-main',
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',
},
execution: {
id: 'run-room-role:claude',
task_id: 'paired-task-1',
service_id: 'claude',
role: 'owner',
workspace_id: 'paired-task-1:owner',
status: 'running',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: '2026-03-28T00:00:00.000Z',
completed_at: null,
},
workspace: {
id: 'paired-task-1:owner',
task_id: 'paired-task-1',
role: 'owner',
workspace_dir: '/tmp/paired/owner',
snapshot_source_dir: null,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
envOverrides: {
EJCLAW_WORK_DIR: '/tmp/paired/owner',
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
EJCLAW_PAIRED_ROLE: 'owner',
},
},
);
await runAgentForGroup(makeDeps(), {
group,
prompt: 'hello',
chatJid: 'group@test',
runId: 'run-room-role',
});
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
group,
expect.objectContaining({
roomRoleContext: expect.any(Object),
}),
expect.any(Function),
undefined,
expect.objectContaining({
EJCLAW_WORK_DIR: '/tmp/paired/owner',
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
EJCLAW_PAIRED_EXECUTION_ID: 'run-room-role:claude',
EJCLAW_PAIRED_ROLE: 'owner',
}),
);
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith({
executionId: 'run-room-role:claude',
status: 'succeeded',
summary: 'ok',
});
});
it('blocks reviewer execution before review-ready and does not spawn the runner', async () => {
const group = {
...makeGroup(),
folder: 'test-group',
workDir: '/repo/canonical',
};
const outputs: Array<{ text?: string; result?: string | null }> = [];
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
chat_jid: 'group@test',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
activated_at: null,
reason: null,
explicit: false,
});
vi.mocked(pairedExecutionContext.preparePairedExecutionContext).mockReturnValue(
{
task: {
id: 'paired-task-1',
chat_jid: 'group@test',
group_folder: 'test-group',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude',
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',
},
execution: {
id: 'run-blocked-reviewer:claude',
task_id: 'paired-task-1',
service_id: 'claude',
role: 'reviewer',
workspace_id: null,
status: 'running',
summary: null,
created_at: '2026-03-28T00:00:00.000Z',
started_at: '2026-03-28T00:00:00.000Z',
completed_at: null,
},
workspace: null,
envOverrides: {
EJCLAW_PAIRED_TASK_ID: 'paired-task-1',
EJCLAW_PAIRED_EXECUTION_ID: 'run-blocked-reviewer:claude',
EJCLAW_PAIRED_ROLE: 'reviewer',
EJCLAW_REVIEWER_RUNTIME: '1',
},
blockMessage:
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
},
);
const result = await runAgentForGroup(makeDeps(), {
group,
prompt: 'please review',
chatJid: 'group@test',
runId: 'run-blocked-reviewer',
onOutput: async (output) => {
outputs.push({
text: output.output && 'text' in output.output ? output.output.text : undefined,
result: output.result,
});
},
});
expect(result).toBe('success');
expect(agentRunner.runAgentProcess).not.toHaveBeenCalled();
expect(outputs).toEqual([
{
text: 'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
result: null,
},
]);
expect(
pairedExecutionContext.completePairedExecutionContext,
).toHaveBeenCalledWith({
executionId: 'run-blocked-reviewer:claude',
status: 'failed',
summary:
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
});
});
});
describe('runAgentForGroup Claude rotation', () => {

View File

@@ -12,6 +12,10 @@ import { createServiceHandoff, getAllTasks } from './db.js';
import { GroupQueue } from './group-queue.js';
import { logger } from './logger.js';
import { buildRoomMemoryBriefing } from './memento-client.js';
import {
completePairedExecutionContext,
preparePairedExecutionContext,
} from './paired-execution-context.js';
import { buildRoomRoleContext } from './room-role-context.js';
import {
classifyRotationTrigger,
@@ -126,9 +130,17 @@ export async function runAgentForGroup(
currentLease,
SERVICE_SESSION_SCOPE,
);
const pairedExecutionContext = preparePairedExecutionContext({
group,
chatJid,
runId,
roomRoleContext,
});
const effectivePrompt = buildStructuredOutputPrompt(prompt, {
reviewerMode,
});
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null;
const shouldHandoffToCodex = (
reason: AgentTriggerReason,
@@ -188,6 +200,36 @@ export async function runAgentForGroup(
roomRoleContext,
};
if (pairedExecutionContext?.blockMessage) {
pairedExecutionSummary = pairedExecutionContext.blockMessage.slice(0, 500);
logger.warn(
{
chatJid,
group: group.name,
groupFolder: group.folder,
runId,
serviceId: roomRoleContext?.serviceId,
role: roomRoleContext?.role,
},
'Blocked reviewer execution before review-ready snapshot was available',
);
await onOutput?.({
status: 'success',
result: null,
output: {
visibility: 'public',
text: pairedExecutionContext.blockMessage,
},
phase: 'final',
});
completePairedExecutionContext({
executionId: pairedExecutionContext.execution.id,
status: pairedExecutionStatus,
summary: pairedExecutionSummary,
});
return 'success';
}
const runAttempt = async (
provider: string,
): Promise<{
@@ -237,6 +279,11 @@ export async function runAgentForGroup(
streamedState = evaluation.state;
const outputText = getAgentOutputText(output);
if (typeof outputText === 'string' && outputText.length > 0) {
pairedExecutionSummary = outputText.slice(0, 500);
} else if (typeof output.error === 'string' && output.error.length > 0) {
pairedExecutionSummary = output.error.slice(0, 500);
}
if (
evaluation.newTrigger &&
typeof outputText === 'string' &&
@@ -345,6 +392,7 @@ export async function runAgentForGroup(
(proc, processName, ipcDir) =>
deps.queue.registerProcess(chatJid, proc, processName, ipcDir),
wrappedOnOutput,
pairedExecutionContext?.envOverrides,
);
if (provider === 'claude' && output.newSessionId) {
@@ -545,7 +593,8 @@ export async function runAgentForGroup(
const provider = 'claude';
let primaryAttempt = await runAttempt(provider);
try {
let primaryAttempt = await runAttempt(provider);
const isRetryableClaudeSessionFailure = (
attempt: Awaited<ReturnType<typeof runAttempt>>,
@@ -605,6 +654,9 @@ export async function runAgentForGroup(
if (result === 'error') {
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
}
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
}
@@ -613,7 +665,14 @@ export async function runAgentForGroup(
const errMsg = getErrorMessage(primaryAttempt.error);
const trigger = detectCodexRotationTrigger(errMsg);
if (trigger.shouldRotate && getCodexAccountCount() > 1) {
return retryCodexWithRotation({ reason: trigger.reason }, errMsg);
const result = await retryCodexWithRotation(
{ reason: trigger.reason },
errMsg,
);
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
}
@@ -646,6 +705,17 @@ export async function runAgentForGroup(
return 'error';
}
if (!pairedExecutionSummary) {
const finalOutputText = getAgentOutputText(output);
pairedExecutionSummary =
(typeof finalOutputText === 'string' && finalOutputText.length > 0
? finalOutputText.slice(0, 500)
: null) ??
(typeof output.error === 'string' && output.error.length > 0
? output.error.slice(0, 500)
: null);
}
if (
canRotateToken &&
provider === 'claude' &&
@@ -697,6 +767,9 @@ export async function runAgentForGroup(
if (result === 'error') {
return maybeHandoffAfterError(trigger.reason, primaryAttempt);
}
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
}
@@ -704,10 +777,14 @@ export async function runAgentForGroup(
if (!isClaudeCodeAgent && getCodexAccountCount() > 1) {
const trigger = detectCodexRotationTrigger(output.error);
if (trigger.shouldRotate) {
return retryCodexWithRotation(
const result = await retryCodexWithRotation(
{ reason: trigger.reason },
output.error ?? undefined,
);
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
}
@@ -729,13 +806,17 @@ export async function runAgentForGroup(
primaryAttempt.streamedTriggerReason &&
getCodexAccountCount() > 1
) {
return retryCodexWithRotation(
const result = await retryCodexWithRotation(
{
reason: primaryAttempt.streamedTriggerReason
.reason as CodexRotationReason,
},
output.error ?? output.result ?? undefined,
);
if (result === 'success') {
pairedExecutionStatus = 'succeeded';
}
return result;
}
// Unresolved streamed trigger — rotation was unavailable or output was
@@ -771,5 +852,15 @@ export async function runAgentForGroup(
return 'error';
}
return 'success';
pairedExecutionStatus = 'succeeded';
return 'success';
} finally {
if (pairedExecutionContext) {
completePairedExecutionContext({
executionId: pairedExecutionContext.execution.id,
status: pairedExecutionStatus,
summary: pairedExecutionSummary,
});
}
}
}

View File

@@ -41,6 +41,8 @@ import {
import { runAgentForGroup } from './message-agent-executor.js';
import { MessageTurnController } from './message-turn-controller.js';
import { createSuppressToken } from './output-suppression.js';
import { markRoomReviewReady } from './paired-execution-context.js';
import { buildRoomRoleContext } from './room-role-context.js';
import {
extractSessionCommand,
handleSessionCommand,
@@ -50,7 +52,10 @@ import {
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
import { resolveGroupIpcPath } from './group-folder.js';
import { shouldServiceProcessChat } from './service-routing.js';
import {
getEffectiveChannelLease,
shouldServiceProcessChat,
} from './service-routing.js';
/**
* Check if a message is a duplicate of the last bot final message in a paired room.
@@ -557,6 +562,27 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
isTriggerAllowed(chatJid, msg.sender, loadSenderAllowlist())))
);
},
markReviewReady: async () => {
const lease = getEffectiveChannelLease(chatJid);
const roomRoleContext = buildRoomRoleContext(
lease,
lease.owner_service_id,
);
const result = markRoomReviewReady({
group,
chatJid,
roomRoleContext,
});
if (!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');
},
},
});
if (cmdResult.handled) return cmdResult.success;

View File

@@ -0,0 +1,244 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./db.js', () => ({
createPairedExecution: vi.fn(),
createPairedTask: vi.fn(),
getLatestOpenPairedTaskForChat: vi.fn(),
getPairedExecutionById: vi.fn(),
getPairedTaskById: vi.fn(),
getPairedWorkspace: vi.fn(),
updatePairedExecution: vi.fn(),
updatePairedTask: vi.fn(),
upsertPairedProject: vi.fn(),
}));
vi.mock('./paired-workspace-manager.js', () => ({
markPairedTaskReviewReady: vi.fn(),
provisionOwnerWorkspaceForPairedTask: vi.fn(),
}));
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import * as db from './db.js';
import {
markRoomReviewReady,
preparePairedExecutionContext,
} from './paired-execution-context.js';
import * as pairedWorkspaceManager from './paired-workspace-manager.js';
import type { 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: '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 execution context', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
vi.mocked(db.getPairedExecutionById).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,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
});
it('creates an owner execution with a worktree override', () => {
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-1',
roomRoleContext: ownerContext,
});
expect(db.upsertPairedProject).toHaveBeenCalled();
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
expect(db.createPairedExecution).toHaveBeenCalledWith(
expect.objectContaining({
id: 'run-1:codex-main',
role: 'owner',
workspace_id: 'task-1:owner',
}),
);
expect(result?.envOverrides).toMatchObject({
EJCLAW_WORK_DIR: '/tmp/paired/task-1/owner',
EJCLAW_PAIRED_ROLE: 'owner',
});
});
it('uses the reviewer snapshot only after review_ready 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',
review_requested_at: '2026-03-28T00:00:00.000Z',
status: 'review_ready',
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(db.getPairedWorkspace).mockReturnValue({
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',
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',
});
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('blocks reviewer execution before review_ready instead of falling back to the canonical repo', () => {
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',
});
const result = preparePairedExecutionContext({
group,
chatJid: 'dc:test',
runId: 'run-blocked-reviewer',
roomRoleContext: reviewerContext,
});
expect(result?.blockMessage).toBe(
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.',
);
expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined();
expect(db.getPairedWorkspace).not.toHaveBeenCalled();
});
it('marks the active room task review-ready through the workspace manager', () => {
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(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',
updated_at: '2026-03-28T00:00:00.000Z',
});
vi.mocked(pairedWorkspaceManager.markPairedTaskReviewReady).mockReturnValue(
{
ownerWorkspace: {
id: 'task-1:owner',
task_id: 'task-1',
role: 'owner',
workspace_dir: '/tmp/paired/task-1/owner',
snapshot_source_dir: null,
status: 'ready',
snapshot_refreshed_at: null,
created_at: '2026-03-28T00:00:00.000Z',
updated_at: '2026-03-28T00:00:00.000Z',
},
reviewerWorkspace: {
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',
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',
},
},
);
const result = markRoomReviewReady({
group,
chatJid: 'dc:test',
roomRoleContext: ownerContext,
});
expect(pairedWorkspaceManager.markPairedTaskReviewReady).toHaveBeenCalledWith(
'task-1',
);
expect(result?.task.status).toBe('review_ready');
});
});

View File

@@ -0,0 +1,258 @@
import { execFileSync } from 'child_process';
import crypto from 'crypto';
import {
createPairedExecution,
createPairedTask,
getLatestOpenPairedTaskForChat,
getPairedExecutionById,
getPairedTaskById,
getPairedWorkspace,
updatePairedExecution,
updatePairedTask,
upsertPairedProject,
} from './db.js';
import { logger } from './logger.js';
import {
markPairedTaskReviewReady,
provisionOwnerWorkspaceForPairedTask,
} from './paired-workspace-manager.js';
import type {
PairedExecution,
PairedTask,
PairedWorkspace,
RegisteredGroup,
RoomRoleContext,
} from './types.js';
function resolveCanonicalSourceRef(workDir: string): string {
try {
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: workDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
return head || 'HEAD';
} catch {
return 'HEAD';
}
}
function ensurePairedProject(
group: RegisteredGroup,
chatJid: string,
): string | null {
if (!group.workDir) {
return null;
}
const now = new Date().toISOString();
upsertPairedProject({
chat_jid: chatJid,
group_folder: group.folder,
canonical_work_dir: group.workDir,
workspace_topology: 'shadow-snapshot',
created_at: now,
updated_at: now,
});
return group.workDir;
}
function ensureActiveTask(
group: RegisteredGroup,
chatJid: string,
roomRoleContext: RoomRoleContext,
): PairedTask | null {
const canonicalWorkDir = ensurePairedProject(group, chatJid);
if (!canonicalWorkDir) {
return null;
}
const existing = getLatestOpenPairedTaskForChat(chatJid);
if (existing) {
return existing;
}
const now = new Date().toISOString();
const task: PairedTask = {
id: crypto.randomUUID(),
chat_jid: chatJid,
group_folder: group.folder,
owner_service_id: roomRoleContext.ownerServiceId,
reviewer_service_id: roomRoleContext.reviewerServiceId,
title: null,
source_ref: resolveCanonicalSourceRef(canonicalWorkDir),
review_requested_at: null,
status: 'draft',
created_at: now,
updated_at: now,
};
createPairedTask(task);
logger.info(
{
chatJid,
groupFolder: group.folder,
taskId: task.id,
sourceRef: task.source_ref,
},
'Created active paired task for room',
);
return task;
}
function ensureExecutionRecord(args: {
runId: string;
roomRoleContext: RoomRoleContext;
task: PairedTask;
workspace?: PairedWorkspace;
}): PairedExecution {
const executionId = `${args.runId}:${args.roomRoleContext.serviceId}`;
const existing = getPairedExecutionById(executionId);
const now = new Date().toISOString();
if (existing) {
updatePairedExecution(existing.id, {
workspace_id: args.workspace?.id ?? existing.workspace_id,
status: 'running',
started_at: existing.started_at ?? now,
});
return {
...existing,
workspace_id: args.workspace?.id ?? existing.workspace_id,
status: 'running',
started_at: existing.started_at ?? now,
};
}
const execution: PairedExecution = {
id: executionId,
task_id: args.task.id,
service_id: args.roomRoleContext.serviceId,
role: args.roomRoleContext.role,
workspace_id: args.workspace?.id ?? null,
status: 'running',
summary: null,
created_at: now,
started_at: now,
completed_at: null,
};
createPairedExecution(execution);
return execution;
}
export interface PreparedPairedExecutionContext {
task: PairedTask;
execution: PairedExecution;
workspace: PairedWorkspace | null;
envOverrides: Record<string, string>;
blockMessage?: string;
}
export function preparePairedExecutionContext(args: {
group: RegisteredGroup;
chatJid: string;
runId: string;
roomRoleContext?: RoomRoleContext;
}): PreparedPairedExecutionContext | undefined {
const { group, chatJid, runId, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) {
return undefined;
}
const task = ensureActiveTask(group, chatJid, roomRoleContext);
if (!task) {
return undefined;
}
let workspace: PairedWorkspace | null = null;
let blockMessage: string | undefined;
const now = new Date().toISOString();
if (roomRoleContext.role === 'owner') {
workspace = provisionOwnerWorkspaceForPairedTask(task.id);
} else if (
task.status === 'review_ready' ||
task.status === 'in_review' ||
task.status === 'merge_ready' ||
task.status === 'changes_requested'
) {
workspace = getPairedWorkspace(task.id, 'reviewer') ?? null;
if (workspace && task.status === 'review_ready') {
updatePairedTask(task.id, {
status: 'in_review',
updated_at: now,
});
}
} else {
blockMessage =
'Review snapshot is not ready yet. Ask the owner to run /review-ready after preparing changes.';
}
const execution = ensureExecutionRecord({
runId,
roomRoleContext,
task,
workspace: workspace ?? undefined,
});
const envOverrides: Record<string, string> = {
EJCLAW_PAIRED_TASK_ID: task.id,
EJCLAW_PAIRED_EXECUTION_ID: execution.id,
EJCLAW_PAIRED_ROLE: roomRoleContext.role,
};
if (workspace?.workspace_dir) {
envOverrides.EJCLAW_WORK_DIR = workspace.workspace_dir;
}
if (roomRoleContext.role === 'reviewer') {
envOverrides.EJCLAW_REVIEWER_RUNTIME = '1';
}
return {
task: getPairedTaskById(task.id) ?? task,
execution,
workspace,
envOverrides,
blockMessage,
};
}
export function completePairedExecutionContext(args: {
executionId: string;
status: 'succeeded' | 'failed';
summary?: string | null;
}): void {
updatePairedExecution(args.executionId, {
status: args.status,
summary: args.summary ?? null,
completed_at: new Date().toISOString(),
});
}
export function markRoomReviewReady(args: {
group: RegisteredGroup;
chatJid: string;
roomRoleContext?: RoomRoleContext;
}): {
task: PairedTask;
ownerWorkspace: PairedWorkspace;
reviewerWorkspace: PairedWorkspace;
} | null {
const { group, chatJid, roomRoleContext } = args;
if (!roomRoleContext || !group.workDir) {
return null;
}
const task = ensureActiveTask(group, chatJid, roomRoleContext);
if (!task) {
return null;
}
const { ownerWorkspace, reviewerWorkspace } = markPairedTaskReviewReady(
task.id,
);
return {
task: getPairedTaskById(task.id) ?? task,
ownerWorkspace,
reviewerWorkspace,
};
}

View File

@@ -0,0 +1,274 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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 manager = await import('./paired-workspace-manager.js');
return { db, manager };
}
describe('paired workspace manager', () => {
let tempRoot: string;
let previousDataDir: string | undefined;
let previousGroupsDir: string | undefined;
let previousStoreDir: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-paired-workspace-'));
previousDataDir = process.env.EJCLAW_DATA_DIR;
previousGroupsDir = process.env.EJCLAW_GROUPS_DIR;
previousStoreDir = process.env.EJCLAW_STORE_DIR;
process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data');
process.env.EJCLAW_GROUPS_DIR = path.join(tempRoot, 'groups');
process.env.EJCLAW_STORE_DIR = path.join(tempRoot, 'store');
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;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('provisions an owner worktree and refreshes a reviewer shadow snapshot', 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-1',
chat_jid: 'dc:test',
group_folder: 'paired-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'codex-review',
title: 'review the owner changes',
source_ref: 'HEAD',
review_requested_at: null,
status: 'draft',
created_at: now,
updated_at: now,
});
const ownerWorkspace =
manager.provisionOwnerWorkspaceForPairedTask('paired-task-1');
expect(fs.existsSync(path.join(ownerWorkspace.workspace_dir, '.git'))).toBe(
true,
);
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'README.md'),
'owner modified\n',
);
fs.writeFileSync(
path.join(ownerWorkspace.workspace_dir, 'NEW_FILE.txt'),
'review me\n',
);
const { reviewerWorkspace } =
manager.markPairedTaskReviewReady('paired-task-1');
expect(
fs.readFileSync(path.join(reviewerWorkspace.workspace_dir, 'README.md'), 'utf-8'),
).toBe('owner modified\n');
expect(
fs.readFileSync(
path.join(reviewerWorkspace.workspace_dir, 'NEW_FILE.txt'),
'utf-8',
),
).toBe('review me\n');
expect(
runGit(['config', '--local', '--get', 'remote.origin.pushurl'], reviewerWorkspace.workspace_dir),
).toBe('DISABLED_BY_EJCLAW');
expect(
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
).toContain('M README.md');
expect(
db.getPairedTaskById('paired-task-1')?.status,
).toBe('review_ready');
expect(
db.getPairedWorkspace('paired-task-1', 'reviewer')?.snapshot_refreshed_at,
).toBeTruthy();
});
it('replaces stale reviewer files on snapshot refresh', 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, 'keep.txt'), 'keep\n');
fs.writeFileSync(path.join(canonicalDir, 'remove.txt'), 'remove\n');
runGit(['add', 'keep.txt', 'remove.txt'], 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-2',
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-2');
manager.refreshReviewerSnapshotForPairedTask('paired-task-2');
fs.rmSync(path.join(ownerWorkspace.workspace_dir, 'remove.txt'));
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'keep.txt'), 'updated\n');
const reviewerWorkspace =
manager.refreshReviewerSnapshotForPairedTask('paired-task-2');
expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'remove.txt')),
).toBe(false);
expect(
fs.readFileSync(path.join(reviewerWorkspace.workspace_dir, 'keep.txt'), 'utf-8'),
).toBe('updated\n');
expect(
runGit(['status', '--short'], reviewerWorkspace.workspace_dir),
).toContain('D remove.txt');
});
it('filters secrets, caches, and build outputs out of reviewer snapshots', 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, 'tracked.ts'), 'export const ok = 1;\n');
fs.writeFileSync(path.join(canonicalDir, '.env.production'), 'TRACKED_SECRET=1\n');
fs.writeFileSync(path.join(canonicalDir, '.env.example'), 'EXAMPLE=1\n');
runGit(['add', 'tracked.ts', '.env.production', '.env.example'], 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-3',
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-3');
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'src'), {
recursive: true,
});
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache'), {
recursive: true,
});
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'dist'), {
recursive: true,
});
fs.mkdirSync(path.join(ownerWorkspace.workspace_dir, 'logs'), {
recursive: true,
});
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'src', 'draft.ts'), 'export const draft = true;\n');
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, '.env.local'), 'SECRET=1\n');
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'), 'cache\n');
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'dist', 'bundle.js'), 'dist\n');
fs.writeFileSync(path.join(ownerWorkspace.workspace_dir, 'logs', 'debug.log'), 'log\n');
const reviewerWorkspace =
manager.refreshReviewerSnapshotForPairedTask('paired-task-3');
expect(
fs.readFileSync(
path.join(reviewerWorkspace.workspace_dir, 'src', 'draft.ts'),
'utf-8',
),
).toBe('export const draft = true;\n');
expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, '.env.production')),
).toBe(false);
expect(
fs.readFileSync(
path.join(reviewerWorkspace.workspace_dir, '.env.example'),
'utf-8',
),
).toBe('EXAMPLE=1\n');
expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, '.env.local')),
).toBe(false);
expect(
fs.existsSync(
path.join(reviewerWorkspace.workspace_dir, 'node_modules', '.cache', 'x'),
),
).toBe(false);
expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'dist', 'bundle.js')),
).toBe(false);
expect(
fs.existsSync(path.join(reviewerWorkspace.workspace_dir, 'logs', 'debug.log')),
).toBe(false);
});
});

View File

@@ -0,0 +1,337 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
getPairedProject,
getPairedTaskById,
getPairedWorkspace,
updatePairedTask,
upsertPairedWorkspace,
} from './db.js';
import { resolvePairedTaskWorkspacePath } from './group-folder.js';
import { logger } from './logger.js';
import type { PairedTask, PairedWorkspace } from './types.js';
const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([
'.git',
'.claude',
'.codex',
'.next',
'.turbo',
'.cache',
'node_modules',
'dist',
'build',
'coverage',
'logs',
]);
const REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_EXTENSIONS = new Set([
'.c',
'.cc',
'.cpp',
'.css',
'.csv',
'.go',
'.graphql',
'.h',
'.hpp',
'.html',
'.java',
'.js',
'.json',
'.jsx',
'.kt',
'.md',
'.mjs',
'.prisma',
'.proto',
'.py',
'.rb',
'.rs',
'.scss',
'.sh',
'.sql',
'.svg',
'.swift',
'.toml',
'.ts',
'.tsx',
'.txt',
'.xml',
'.yaml',
'.yml',
]);
const REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_BASENAMES = new Set([
'.editorconfig',
'.eslintignore',
'.eslintrc',
'.gitattributes',
'.gitignore',
'.npmrc',
'.nvmrc',
'.prettierignore',
'.prettierrc',
'Dockerfile',
'Makefile',
'README',
'README.md',
'package-lock.json',
'pnpm-lock.yaml',
'tsconfig.json',
'yarn.lock',
]);
function runGit(args: string[], cwd?: string): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
function ensureGitRepository(repoDir: string): void {
const insideWorkTree = runGit(['rev-parse', '--is-inside-work-tree'], repoDir);
if (insideWorkTree !== 'true') {
throw new Error(`Not a git repository: ${repoDir}`);
}
}
function ensureCleanDirectory(targetDir: string): void {
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[] {
const output = execFileSync('git', args, {
cwd: repoDir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
return output
.split('\0')
.map((value) => value.trim())
.filter(Boolean);
}
function isReviewerSnapshotDeniedPath(relativePath: string): boolean {
const segments = relativePath.split(/[\\/]+/).filter(Boolean);
if (segments.some((segment) => REVIEWER_SNAPSHOT_DENY_SEGMENTS.has(segment))) {
return true;
}
const basename = path.basename(relativePath);
if (basename === '.env') {
return true;
}
if (
basename.startsWith('.env.') &&
basename !== '.env.example' &&
basename !== '.env.sample'
) {
return true;
}
if (basename.endsWith('.log')) {
return true;
}
return false;
}
function shouldIncludeUntrackedReviewerPath(relativePath: string): boolean {
if (isReviewerSnapshotDeniedPath(relativePath)) {
return false;
}
const basename = path.basename(relativePath);
if (REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_BASENAMES.has(basename)) {
return true;
}
return REVIEWER_SNAPSHOT_ALLOWED_UNTRACKED_EXTENSIONS.has(
path.extname(basename).toLowerCase(),
);
}
function copySelectedSnapshotTree(sourceDir: string, targetDir: string): void {
resetDirectoryExceptGit(targetDir);
const trackedFiles = listGitPaths(sourceDir, [
'ls-files',
'--cached',
'-z',
]).filter((relativePath) => !isReviewerSnapshotDeniedPath(relativePath));
const untrackedFiles = listGitPaths(sourceDir, [
'ls-files',
'--others',
'--exclude-standard',
'-z',
]).filter(shouldIncludeUntrackedReviewerPath);
const filesToCopy = [...new Set([...trackedFiles, ...untrackedFiles])].sort();
for (const relativePath of filesToCopy) {
const sourcePath = path.join(sourceDir, relativePath);
if (!fs.existsSync(sourcePath)) continue;
const targetPath = path.join(targetDir, relativePath);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.cpSync(sourcePath, targetPath, { force: true, recursive: true });
}
}
function configureReviewerGitIsolation(workspaceDir: string): void {
try {
runGit(['config', '--local', 'push.default', 'nothing'], workspaceDir);
runGit(['config', '--local', 'credential.helper', ''], workspaceDir);
runGit(
['config', '--local', 'remote.origin.pushurl', 'DISABLED_BY_EJCLAW'],
workspaceDir,
);
} catch (error) {
logger.warn(
{ workspaceDir, error },
'Failed to apply reviewer git isolation settings',
);
}
}
function getTaskAndProject(taskId: string): {
task: PairedTask;
canonicalWorkDir: string;
} {
const task = getPairedTaskById(taskId);
if (!task) {
throw new Error(`Paired task not found: ${taskId}`);
}
const project = getPairedProject(task.chat_jid);
if (!project) {
throw new Error(`Paired project not found for chat: ${task.chat_jid}`);
}
return { task, canonicalWorkDir: project.canonical_work_dir };
}
function makeWorkspaceRecord(args: {
taskId: string;
role: PairedWorkspace['role'];
workspaceDir: string;
snapshotSourceDir?: string | null;
snapshotRefreshedAt?: string | null;
createdAt?: string;
}): PairedWorkspace {
const existing = getPairedWorkspace(args.taskId, args.role);
const now = new Date().toISOString();
return {
id: existing?.id || `${args.taskId}:${args.role}`,
task_id: args.taskId,
role: args.role,
workspace_dir: args.workspaceDir,
snapshot_source_dir: args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null,
status: 'ready',
snapshot_refreshed_at:
args.snapshotRefreshedAt ?? existing?.snapshot_refreshed_at ?? null,
created_at: existing?.created_at || args.createdAt || now,
updated_at: now,
};
}
export function provisionOwnerWorkspaceForPairedTask(
taskId: string,
): PairedWorkspace {
const { task, canonicalWorkDir } = getTaskAndProject(taskId);
ensureGitRepository(canonicalWorkDir);
const sourceRef = task.source_ref || 'HEAD';
const workspaceDir = resolvePairedTaskWorkspacePath(
task.group_folder,
task.id,
'owner',
);
const parentDir = path.dirname(workspaceDir);
fs.mkdirSync(parentDir, { recursive: true });
if (!fs.existsSync(path.join(workspaceDir, '.git'))) {
ensureCleanDirectory(parentDir);
runGit(['worktree', 'add', '--detach', workspaceDir, sourceRef], canonicalWorkDir);
logger.info(
{ taskId, workspaceDir, sourceRef },
'Provisioned owner workspace for paired task',
);
}
const workspace = makeWorkspaceRecord({
taskId,
role: 'owner',
workspaceDir,
});
upsertPairedWorkspace(workspace);
return workspace;
}
export function refreshReviewerSnapshotForPairedTask(
taskId: string,
): PairedWorkspace {
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId);
ensureGitRepository(ownerWorkspace.workspace_dir);
const { task } = getTaskAndProject(taskId);
const reviewerDir = resolvePairedTaskWorkspacePath(
task.group_folder,
task.id,
'reviewer',
);
fs.mkdirSync(path.dirname(reviewerDir), { recursive: true });
if (!fs.existsSync(path.join(reviewerDir, '.git'))) {
fs.rmSync(reviewerDir, { recursive: true, force: true });
runGit(['clone', '--shared', ownerWorkspace.workspace_dir, reviewerDir]);
}
runGit(['reset', '--hard', 'HEAD'], reviewerDir);
runGit(['clean', '-fdx'], reviewerDir);
copySelectedSnapshotTree(ownerWorkspace.workspace_dir, reviewerDir);
configureReviewerGitIsolation(reviewerDir);
const refreshedAt = new Date().toISOString();
const workspace = makeWorkspaceRecord({
taskId,
role: 'reviewer',
workspaceDir: reviewerDir,
snapshotSourceDir: ownerWorkspace.workspace_dir,
snapshotRefreshedAt: refreshedAt,
});
upsertPairedWorkspace(workspace);
logger.info(
{ taskId, reviewerDir, snapshotSourceDir: ownerWorkspace.workspace_dir },
'Refreshed reviewer snapshot for paired task',
);
return workspace;
}
export function markPairedTaskReviewReady(taskId: string): {
ownerWorkspace: PairedWorkspace;
reviewerWorkspace: PairedWorkspace;
} {
const ownerWorkspace = provisionOwnerWorkspaceForPairedTask(taskId);
const reviewerWorkspace = refreshReviewerSnapshotForPairedTask(taskId);
const now = new Date().toISOString();
updatePairedTask(taskId, {
status: 'review_ready',
review_requested_at: now,
updated_at: now,
});
return { ownerWorkspace, reviewerWorkspace };
}

View File

@@ -23,6 +23,12 @@ describe('extractSessionCommand', () => {
expect(extractSessionCommand('/clear', trigger)).toBe('/clear');
});
it('detects bare /review-ready', () => {
expect(extractSessionCommand('/review-ready', trigger)).toBe(
'/review-ready',
);
});
it('rejects /compact with extra text', () => {
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
});
@@ -120,6 +126,7 @@ function makeDeps(
formatMessages: vi.fn().mockReturnValue('<formatted>'),
isAdminSender: vi.fn().mockReturnValue(false),
canSenderInteract: vi.fn().mockReturnValue(true),
markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'),
...overrides,
};
}
@@ -178,6 +185,31 @@ describe('handleSessionCommand', () => {
);
});
it('handles authorized /review-ready 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-ready')],
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('sends denial to interactable sender in non-main group', async () => {
const deps = makeDeps();
const result = await handleSessionCommand({

View File

@@ -10,6 +10,8 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Failed to process messages before \/compact\. Try again\.$/,
/^\/compact failed\. The session is unchanged\.$/,
/^Conversation compacted\.$/,
/^Review snapshot updated\.$/,
/^Review-ready is unavailable for this room\./,
];
/**
@@ -24,6 +26,7 @@ export function extractSessionCommand(
text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear';
if (text === '/review-ready') return '/review-ready';
return null;
}
@@ -68,6 +71,7 @@ export interface SessionCommandDeps {
isAdminSender: (msg: NewMessage) => boolean;
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean;
markReviewReady: () => Promise<string | null>;
}
function resultToText(result: string | object | null | undefined): string {
@@ -149,6 +153,16 @@ export async function handleSessionCommand(opts: {
return { handled: true, success: true };
}
if (command === '/review-ready') {
const result = await deps.markReviewReady();
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
result ??
'Review-ready is unavailable for this room. Paired workspaces require a configured project workDir.',
);
return { handled: true, success: true };
}
const cmdIndex = missedMessages.indexOf(cmdMsg);
const preCompactMsgs = missedMessages.slice(0, cmdIndex);

View File

@@ -25,6 +25,38 @@ export type AgentVisibility = 'public' | 'silent';
export type PairedRoomRole = 'owner' | 'reviewer';
export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow';
export type PairedTaskStatus =
| 'draft'
| 'review_ready'
| 'in_review'
| 'changes_requested'
| 'merge_ready'
| 'merged'
| 'discarded'
| 'failed';
export type PairedExecutionStatus =
| 'pending'
| 'running'
| 'succeeded'
| 'failed'
| 'cancelled';
export type PairedWorkspaceRole = 'owner' | 'reviewer';
export type PairedWorkspaceStatus =
| 'provisioning'
| 'ready'
| 'stale'
| 'failed'
| 'archived';
export type PairedApprovalStatus = 'pending' | 'approved' | 'rejected';
export type PairedArtifactType = 'comment' | 'report' | 'patch';
export interface RoomRoleContext {
serviceId: string;
role: PairedRoomRole;
@@ -33,6 +65,76 @@ export interface RoomRoleContext {
failoverOwner: boolean;
}
export interface PairedProject {
chat_jid: string;
group_folder: string;
canonical_work_dir: string;
workspace_topology: PairedWorkspaceTopology;
created_at: string;
updated_at: string;
}
export interface PairedTask {
id: string;
chat_jid: string;
group_folder: string;
owner_service_id: string;
reviewer_service_id: string;
title: string | null;
source_ref: string | null;
review_requested_at: string | null;
status: PairedTaskStatus;
created_at: string;
updated_at: string;
}
export interface PairedExecution {
id: string;
task_id: string;
service_id: string;
role: PairedWorkspaceRole;
workspace_id: string | null;
status: PairedExecutionStatus;
summary: string | null;
created_at: string;
started_at: string | null;
completed_at: string | null;
}
export interface PairedWorkspace {
id: string;
task_id: string;
role: PairedWorkspaceRole;
workspace_dir: string;
snapshot_source_dir: string | null;
status: PairedWorkspaceStatus;
snapshot_refreshed_at: string | null;
created_at: string;
updated_at: string;
}
export interface PairedApproval {
id: number;
task_id: string;
service_id: string;
role: PairedWorkspaceRole;
status: PairedApprovalStatus;
note: string | null;
created_at: string;
}
export interface PairedArtifact {
id: number;
task_id: string;
execution_id: string | null;
service_id: string;
artifact_type: PairedArtifactType;
title: string | null;
content: string | null;
file_path: string | null;
created_at: string;
}
export type StructuredAgentOutput =
| {
visibility: 'public';