From fc9f2867b924d45d0eccd788802b318a9e9b13cd Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 29 Mar 2026 18:16:28 +0900 Subject: [PATCH] refactor: simplify paired review system and add reviewer container isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — Strip over-engineering (-3,665 lines): - DB tables: 7 → 3 (remove executions, approvals, artifacts, events) - Task statuses: 11 → 5 (active, review_ready, in_review, merge_ready, completed) - Remove plan governance, event sourcing, gate/verdict, freshness guards - paired-execution-context: 980 → 299 lines - Session commands: remove /risk, /plan, /approve-plan, /request-plan-changes Phase 2 — Container isolation for reviewers: - Add container-runtime.ts (Docker abstraction) - Add credential-proxy.ts (API key injection without container exposure) - Add container-runner.ts (reviewer-specific read-only mount + tmpfs) - Add container/Dockerfile + agent-runner (Chromium, Claude Code, Codex) - agent-runner.ts: auto-route reviewer execution to container mode --- container/Dockerfile | 63 + container/agent-runner/package.json | 17 + container/agent-runner/src/index.ts | 139 +++ container/agent-runner/tsconfig.json | 14 + container/build.sh | 10 + src/agent-runner.ts | 32 + src/config.ts | 8 + src/container-runner.ts | 381 ++++++ src/container-runtime.ts | 103 ++ src/credential-proxy.ts | 172 +++ src/db.test.ts | 702 +----------- src/db.ts | 774 ++----------- src/message-agent-executor.test.ts | 55 +- src/message-agent-executor.ts | 4 +- src/message-runtime.test.ts | 244 +--- src/message-runtime.ts | 76 +- src/paired-execution-context.test.ts | 1588 +------------------------- src/paired-execution-context.ts | 1057 +---------------- src/paired-review-command.test.ts | 139 +-- src/paired-workspace-manager.test.ts | 101 +- src/paired-workspace-manager.ts | 23 +- src/session-commands.test.ts | 148 --- src/session-commands.ts | 107 +- src/types.ts | 117 +- 24 files changed, 1174 insertions(+), 4900 deletions(-) create mode 100644 container/Dockerfile create mode 100644 container/agent-runner/package.json create mode 100644 container/agent-runner/src/index.ts create mode 100644 container/agent-runner/tsconfig.json create mode 100755 container/build.sh create mode 100644 src/container-runner.ts create mode 100644 src/container-runtime.ts create mode 100644 src/credential-proxy.ts diff --git a/container/Dockerfile b/container/Dockerfile new file mode 100644 index 0000000..637b2a1 --- /dev/null +++ b/container/Dockerfile @@ -0,0 +1,63 @@ +# EJClaw Reviewer Container +# Runs Claude/Codex agent in read-only isolated environment for code review. + +FROM node:22-slim + +# System dependencies for Chromium (E2E tests, screenshots) +RUN apt-get update && apt-get install -y \ + chromium \ + fonts-liberation \ + fonts-noto-cjk \ + fonts-noto-color-emoji \ + libgbm1 \ + libnss3 \ + libatk-bridge2.0-0 \ + libgtk-3-0 \ + libx11-xcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libasound2 \ + libpangocairo-1.0-0 \ + libcups2 \ + libdrm2 \ + libxshmfence1 \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Chromium paths for Playwright/agent-browser +ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium +ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium + +# Install Claude Code and Codex CLI globally +RUN npm install -g @anthropic-ai/claude-code @openai/codex + +# Create app directory +WORKDIR /app + +# Copy and install agent runner +COPY agent-runner/package*.json ./ +RUN npm install +COPY agent-runner/ ./ +RUN npm run build + +# Create workspace directories +RUN mkdir -p /workspace/project /workspace/group /workspace/global \ + /workspace/ipc/messages /workspace/ipc/input + +# Create entrypoint script +RUN printf '#!/bin/bash\nset -e\ncat > /tmp/input.json\nnode /app/dist/index.js < /tmp/input.json\n' \ + > /app/entrypoint.sh && chmod +x /app/entrypoint.sh + +# Set ownership to node user (non-root) +RUN chown -R node:node /workspace /home/node + +# Switch to non-root user +USER node + +# Working directory is the read-only project mount +WORKDIR /workspace/project + +# Entry point reads JSON from stdin, outputs JSON to stdout +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json new file mode 100644 index 0000000..047a87f --- /dev/null +++ b/container/agent-runner/package.json @@ -0,0 +1,17 @@ +{ + "name": "ejclaw-reviewer-runner", + "version": "1.0.0", + "type": "module", + "description": "Reviewer agent runner for EJClaw containers", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "@anthropic-ai/claude-code": "^1.0.0" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts new file mode 100644 index 0000000..c8d6894 --- /dev/null +++ b/container/agent-runner/src/index.ts @@ -0,0 +1,139 @@ +/** + * EJClaw Reviewer Container Agent Runner + * + * Runs inside a Docker container. Reads input from stdin, + * invokes Claude Code or Codex, writes output to stdout + * via marker-delimited JSON. + * + * The project directory is mounted read-only — the reviewer + * can read/test code but cannot modify it. + */ +import { claude } from '@anthropic-ai/claude-code'; +import fs from 'fs'; +import path from 'path'; + +// ── Protocol markers (must match host's agent-protocol.ts) ──────── +const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; +const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; + +// ── Types ───────────────────────────────────────────────────────── +interface ContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + runId: string; + isMain: boolean; + assistantName?: string; +} + +interface ContainerOutput { + status: 'success' | 'error'; + result: string | null; + newSessionId?: string; + error?: string; + phase: 'final'; +} + +// ── Helpers ─────────────────────────────────────────────────────── + +function emitOutput(output: ContainerOutput): void { + const json = JSON.stringify(output); + process.stdout.write(`${OUTPUT_START_MARKER}${json}${OUTPUT_END_MARKER}\n`); +} + +function readStdinJson(): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString())); + } catch (err) { + reject(new Error(`Failed to parse stdin JSON: ${err}`)); + } + }); + process.stdin.on('error', reject); + }); +} + +// ── Main ────────────────────────────────────────────────────────── + +async function main(): Promise { + const input = await readStdinJson(); + + // Load group CLAUDE.md if present + const groupClaudeMd = path.join('/workspace/group', 'CLAUDE.md'); + const systemPromptParts: string[] = []; + if (fs.existsSync(groupClaudeMd)) { + systemPromptParts.push(fs.readFileSync(groupClaudeMd, 'utf-8')); + } + + // Reviewer-specific instructions + systemPromptParts.push( + 'You are a code reviewer. The project directory is mounted read-only.', + 'You can read files, run tests, and analyze code, but you cannot modify any source files.', + 'Focus on: correctness, security, performance, and test coverage.', + ); + + const systemPrompt = systemPromptParts.join('\n\n'); + + try { + const result = await claude({ + prompt: input.prompt, + systemPrompt, + options: { + allowedTools: [ + 'Read', + 'Bash', + 'Glob', + 'Grep', + 'LS', + 'Agent', + 'WebSearch', + 'WebFetch', + ], + maxTurns: 30, + cwd: '/workspace/project', + }, + }); + + // Extract text from result + const text = + typeof result === 'string' + ? result + : Array.isArray(result) + ? result + .filter( + (b: { type: string; text?: string }) => b.type === 'text', + ) + .map((b: { text?: string }) => b.text || '') + .join('\n') + : String(result); + + emitOutput({ + status: 'success', + result: text, + phase: 'final', + }); + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + emitOutput({ + status: 'error', + result: null, + error: errorMsg, + phase: 'final', + }); + } +} + +main().catch((err) => { + console.error('Fatal container error:', err); + emitOutput({ + status: 'error', + result: null, + error: `Fatal: ${err instanceof Error ? err.message : String(err)}`, + phase: 'final', + }); + process.exit(1); +}); diff --git a/container/agent-runner/tsconfig.json b/container/agent-runner/tsconfig.json new file mode 100644 index 0000000..8d4e7cc --- /dev/null +++ b/container/agent-runner/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} diff --git a/container/build.sh b/container/build.sh new file mode 100755 index 0000000..9b70785 --- /dev/null +++ b/container/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Build the EJClaw reviewer container image +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Building ejclaw-reviewer container image..." +docker build -t ejclaw-reviewer:latest . +echo "Done. Image: ejclaw-reviewer:latest" diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 895cfa2..5f4ca88 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -20,6 +20,7 @@ export { } from './agent-runner-snapshot.js'; import { logger } from './logger.js'; import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js'; +import { runReviewerContainer } from './container-runner.js'; import { AgentOutputPhase, AgentType, @@ -67,6 +68,37 @@ export async function runAgentProcess( onOutput?: (output: AgentOutput) => Promise, envOverrides?: Record, ): Promise { + // ── Reviewer container mode ───────────────────────────────────── + // When EJCLAW_REVIEWER_RUNTIME is set in envOverrides, run the reviewer + // inside a Docker container with read-only source mount instead of + // as a host process. This provides kernel-level write protection. + const isReviewerContainer = + envOverrides?.EJCLAW_REVIEWER_RUNTIME === '1' && + process.env.REVIEWER_CONTAINER_ENABLED !== '0'; + if (isReviewerContainer) { + const ownerWorkspaceDir = + envOverrides?.EJCLAW_WORK_DIR || group.workDir || process.cwd(); + return runReviewerContainer({ + group, + input: { + prompt: input.prompt, + sessionId: input.sessionId, + groupFolder: input.groupFolder, + chatJid: input.chatJid, + runId: input.runId || `${Date.now()}`, + isMain: input.isMain, + assistantName: input.assistantName, + }, + ownerWorkspaceDir, + envOverrides, + onOutput, + onProcess: (proc, containerName) => { + onProcess(proc, containerName, ''); + }, + }); + } + + // ── Host process mode (owner) ─────────────────────────────────── const startTime = Date.now(); const { env, groupDir, runnerDir } = prepareGroupEnvironment( group, diff --git a/src/config.ts b/src/config.ts index 8794ce2..af62a85 100644 --- a/src/config.ts +++ b/src/config.ts @@ -87,6 +87,14 @@ export const RECOVERY_CONCURRENT_AGENTS = parseInt( getEnv('RECOVERY_CONCURRENT_AGENTS') || '3', 10, ); + +// ── Container isolation ─────────────────────────────────────────── +export const REVIEWER_CONTAINER_IMAGE = + getEnv('REVIEWER_CONTAINER_IMAGE') || 'ejclaw-reviewer:latest'; +export const CREDENTIAL_PROXY_PORT = parseInt( + getEnv('CREDENTIAL_PROXY_PORT') || '3001', + 10, +); export const RECOVERY_STAGGER_MS = parseInt( getEnv('RECOVERY_STAGGER_MS') || '2000', 10, diff --git a/src/container-runner.ts b/src/container-runner.ts new file mode 100644 index 0000000..9c44989 --- /dev/null +++ b/src/container-runner.ts @@ -0,0 +1,381 @@ +/** + * Container runner for EJClaw reviewer agents. + * + * Spawns reviewer execution inside a Docker container with: + * - Source code mounted read-only (kernel-level write protection) + * - tmpfs overlays for test runner caches (vitest, coverage) + * - IPC via filesystem (input/ directory for follow-up messages) + * - Credentials injected by the credential proxy (never exposed to container) + */ +import { ChildProcess, spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js'; +import { + AGENT_MAX_OUTPUT_SIZE, + AGENT_TIMEOUT, + DATA_DIR, + GROUPS_DIR, + IDLE_TIMEOUT, + TIMEZONE, +} from './config.js'; +import { + CONTAINER_HOST_GATEWAY, + CONTAINER_RUNTIME_BIN, + hostGatewayArgs, + readonlyMountArgs, + stopContainer, + tmpfsMountArgs, + writableMountArgs, +} from './container-runtime.js'; +import { detectAuthMode } from './credential-proxy.js'; +import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; +import { logger } from './logger.js'; +import type { AgentOutput } from './agent-runner.js'; +import type { RegisteredGroup } from './types.js'; + +// ── Config ──────────────────────────────────────────────────────── + +const CONTAINER_IMAGE = + process.env.REVIEWER_CONTAINER_IMAGE || 'ejclaw-reviewer:latest'; +const CREDENTIAL_PROXY_PORT = parseInt( + process.env.CREDENTIAL_PROXY_PORT || '3001', + 10, +); + +// ── Types ───────────────────────────────────────────────────────── + +export interface ReviewerContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + runId: string; + isMain: boolean; + assistantName?: string; +} + +interface VolumeMount { + hostPath: string; + containerPath: string; + readonly: boolean; +} + +// ── Mount builder ───────────────────────────────────────────────── + +export function buildReviewerMounts( + group: RegisteredGroup, + ownerWorkspaceDir: string, +): VolumeMount[] { + const mounts: VolumeMount[] = []; + const groupDir = resolveGroupFolderPath(group.folder); + const groupIpcDir = resolveGroupIpcPath(group.folder); + + // Source code: READ-ONLY (kernel-level protection) + mounts.push({ + hostPath: ownerWorkspaceDir, + containerPath: '/workspace/project', + readonly: true, + }); + + // Shadow .env so reviewer cannot read secrets from mounted project + const envFile = path.join(ownerWorkspaceDir, '.env'); + if (fs.existsSync(envFile)) { + mounts.push({ + hostPath: '/dev/null', + containerPath: '/workspace/project/.env', + readonly: true, + }); + } + + // Group folder: writable (logs, session data) + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + readonly: false, + }); + + // IPC directory: writable (output messages, task results) + fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); + mounts.push({ + hostPath: groupIpcDir, + containerPath: '/workspace/ipc', + readonly: false, + }); + + // Global memory: read-only + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + mounts.push({ + hostPath: globalDir, + containerPath: '/workspace/global', + readonly: true, + }); + } + + // Session directory for Claude/Codex state + const groupSessionsDir = path.join(DATA_DIR, 'sessions', group.folder); + fs.mkdirSync(groupSessionsDir, { recursive: true }); + mounts.push({ + hostPath: groupSessionsDir, + containerPath: '/home/node/.claude', + readonly: false, + }); + + return mounts; +} + +// ── Container args builder ──────────────────────────────────────── + +function buildContainerArgs( + mounts: VolumeMount[], + containerName: string, + envOverrides?: Record, +): string[] { + const args: string[] = ['run', '-i', '--rm', '--name', containerName]; + + // Timezone + args.push('-e', `TZ=${TIMEZONE}`); + + // Credential proxy — containers never see real API keys + args.push( + '-e', + `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, + ); + + const authMode = detectAuthMode(); + if (authMode === 'api-key') { + args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); + } else { + args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); + } + + // Reviewer runtime flag + args.push('-e', 'EJCLAW_REVIEWER_RUNTIME=1'); + + // Extra env overrides from paired-execution-context + if (envOverrides) { + for (const [key, value] of Object.entries(envOverrides)) { + if ( + key === 'ANTHROPIC_BASE_URL' || + key === 'ANTHROPIC_API_KEY' || + key === 'CLAUDE_CODE_OAUTH_TOKEN' + ) + continue; + args.push('-e', `${key}=${value}`); + } + } + + // Host gateway + args.push(...hostGatewayArgs()); + + // Run as host user for bind-mount compatibility + const hostUid = process.getuid?.(); + const hostGid = process.getgid?.(); + if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { + args.push('--user', `${hostUid}:${hostGid}`); + args.push('-e', 'HOME=/home/node'); + } + + // Volume mounts + for (const mount of mounts) { + if (mount.readonly) { + args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); + } else { + args.push(...writableMountArgs(mount.hostPath, mount.containerPath)); + } + } + + // tmpfs overlays for test runner caches (writable, ephemeral) + args.push(...tmpfsMountArgs('/workspace/project/node_modules/.vitest')); + args.push(...tmpfsMountArgs('/workspace/project/node_modules/.cache')); + args.push(...tmpfsMountArgs('/workspace/project/coverage')); + args.push(...tmpfsMountArgs('/tmp')); + + args.push(CONTAINER_IMAGE); + + return args; +} + +// ── Main runner ─────────────────────────────────────────────────── + +export async function runReviewerContainer(args: { + group: RegisteredGroup; + input: ReviewerContainerInput; + ownerWorkspaceDir: string; + envOverrides?: Record; + onOutput?: (output: AgentOutput) => Promise; + onProcess?: (proc: ChildProcess, containerName: string) => void; +}): Promise { + const { group, input, ownerWorkspaceDir, envOverrides, onOutput, onProcess } = + args; + const startTime = Date.now(); + const containerName = `ejclaw-reviewer-${group.folder}-${Date.now()}`; + + const mounts = buildReviewerMounts(group, ownerWorkspaceDir); + const containerArgs = buildContainerArgs( + mounts, + containerName, + envOverrides, + ); + + logger.info( + { + containerName, + group: group.name, + groupFolder: group.folder, + chatJid: input.chatJid, + runId: input.runId, + image: CONTAINER_IMAGE, + mountCount: mounts.length, + }, + 'Spawning reviewer container', + ); + + return new Promise((resolve) => { + const proc = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + onProcess?.(proc, containerName); + + // Send input via stdin + const stdinPayload = JSON.stringify(input); + proc.stdin.write(stdinPayload); + proc.stdin.end(); + + let stdout = ''; + let stderr = ''; + let lastOutput: AgentOutput | null = null; + let totalOutputSize = 0; + let parseBuffer = ''; + + // Streaming output: parse OUTPUT_START/END marker pairs + proc.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + totalOutputSize += text.length; + + if (totalOutputSize > AGENT_MAX_OUTPUT_SIZE) { + logger.warn( + { containerName, size: totalOutputSize }, + 'Container output exceeds max size, killing', + ); + try { + proc.kill('SIGKILL'); + } catch { + /* ignore */ + } + return; + } + + stdout += text; + parseBuffer += text; + + // Parse streamed output markers + let startIdx: number; + while ( + (startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1 + ) { + const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); + if (endIdx === -1) break; + + const json = parseBuffer + .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) + .trim(); + parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); + + try { + const parsed = JSON.parse(json) as AgentOutput; + lastOutput = parsed; + void onOutput?.(parsed); + } catch (err) { + logger.warn( + { containerName, err }, + 'Failed to parse container output JSON', + ); + } + } + }); + + proc.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + // Timeout + const timeout = setTimeout(() => { + logger.warn( + { containerName, timeoutMs: AGENT_TIMEOUT }, + 'Reviewer container timed out, stopping', + ); + try { + proc.kill('SIGTERM'); + } catch { + /* ignore */ + } + }, AGENT_TIMEOUT); + + proc.on('close', (code, signal) => { + clearTimeout(timeout); + const durationMs = Date.now() - startTime; + + logger.info( + { + containerName, + group: group.name, + runId: input.runId, + exitCode: code, + signal, + durationMs, + stdoutLen: stdout.length, + stderrLen: stderr.length, + }, + 'Reviewer container exited', + ); + + if (lastOutput) { + resolve(lastOutput); + return; + } + + // Fallback: try to parse from full stdout + const startIdx = stdout.indexOf(OUTPUT_START_MARKER); + const endIdx = stdout.indexOf(OUTPUT_END_MARKER); + if (startIdx !== -1 && endIdx !== -1) { + try { + const json = stdout + .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) + .trim(); + resolve(JSON.parse(json) as AgentOutput); + return; + } catch { + /* fall through */ + } + } + + // No valid output + const errorMsg = stderr.trim() || `Container exited with code ${code}`; + resolve({ + status: code === 0 ? 'success' : 'error', + result: null, + error: errorMsg, + phase: 'final', + }); + }); + + proc.on('error', (err) => { + clearTimeout(timeout); + logger.error( + { containerName, err }, + 'Failed to spawn reviewer container', + ); + resolve({ + status: 'error', + result: null, + error: `Container spawn error: ${err.message}`, + phase: 'final', + }); + }); + }); +} diff --git a/src/container-runtime.ts b/src/container-runtime.ts new file mode 100644 index 0000000..ade0ddf --- /dev/null +++ b/src/container-runtime.ts @@ -0,0 +1,103 @@ +/** + * Container runtime abstraction for EJClaw. + * Reviewer agents run inside Docker containers for read-only isolation. + * All runtime-specific logic lives here so swapping runtimes means changing one file. + */ +import { execSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; + +import { logger } from './logger.js'; + +export const CONTAINER_RUNTIME_BIN = 'docker'; +export const CONTAINER_HOST_GATEWAY = 'host.docker.internal'; + +/** + * Address the credential proxy binds to. + * Docker Desktop (macOS): 127.0.0.1 + * Docker (Linux): docker0 bridge IP (containers can reach it via host.docker.internal) + */ +export const PROXY_BIND_HOST = + process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost(); + +function detectProxyBindHost(): string { + if (os.platform() === 'darwin') return '127.0.0.1'; + if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1'; + + const ifaces = os.networkInterfaces(); + const docker0 = ifaces['docker0']; + if (docker0) { + const ipv4 = docker0.find((a) => a.family === 'IPv4'); + if (ipv4) return ipv4.address; + } + return '0.0.0.0'; +} + +export function hostGatewayArgs(): string[] { + if (os.platform() === 'linux') { + return ['--add-host=host.docker.internal:host-gateway']; + } + return []; +} + +export function readonlyMountArgs( + hostPath: string, + containerPath: string, +): string[] { + return ['-v', `${hostPath}:${containerPath}:ro`]; +} + +export function writableMountArgs( + hostPath: string, + containerPath: string, +): string[] { + return ['-v', `${hostPath}:${containerPath}`]; +} + +export function tmpfsMountArgs(containerPath: string): string[] { + return ['--tmpfs', containerPath]; +} + +export function stopContainer(name: string): string { + return `${CONTAINER_RUNTIME_BIN} stop ${name}`; +} + +export function ensureContainerRuntimeRunning(): void { + try { + execSync(`${CONTAINER_RUNTIME_BIN} info`, { + stdio: 'pipe', + timeout: 10000, + }); + logger.debug('Container runtime already running'); + } catch (err) { + logger.error({ err }, 'Failed to reach container runtime'); + throw new Error( + 'Container runtime (Docker) is required for reviewer isolation but failed to start. Run: docker info', + ); + } +} + +export function cleanupOrphans(): void { + try { + const output = execSync( + `${CONTAINER_RUNTIME_BIN} ps --filter name=ejclaw-reviewer- --format '{{.Names}}'`, + { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, + ); + const orphans = output.trim().split('\n').filter(Boolean); + for (const name of orphans) { + try { + execSync(stopContainer(name), { stdio: 'pipe' }); + } catch { + /* already stopped */ + } + } + if (orphans.length > 0) { + logger.info( + { count: orphans.length, names: orphans }, + 'Stopped orphaned reviewer containers', + ); + } + } catch (err) { + logger.warn({ err }, 'Failed to clean up orphaned containers'); + } +} diff --git a/src/credential-proxy.ts b/src/credential-proxy.ts new file mode 100644 index 0000000..3cf84af --- /dev/null +++ b/src/credential-proxy.ts @@ -0,0 +1,172 @@ +/** + * Credential proxy for reviewer container isolation. + * Containers connect here instead of directly to AI APIs. + * The proxy injects real credentials so containers never see them. + * + * Supports both Claude (Anthropic) and Codex (OpenAI) APIs: + * Claude API key: Proxy injects x-api-key on every request. + * Claude OAuth: Proxy replaces placeholder Bearer token with real one. + * Codex: Proxy injects Authorization: Bearer {OPENAI_API_KEY}. + */ +import { createServer, Server } from 'http'; +import { request as httpsRequest } from 'https'; +import { request as httpRequest, RequestOptions } from 'http'; + +import { getEnv } from './env.js'; +import { logger } from './logger.js'; + +export type AuthMode = 'api-key' | 'oauth'; + +export function startCredentialProxy( + port: number, + host = '127.0.0.1', +): Promise { + const anthropicApiKey = getEnv('ANTHROPIC_API_KEY') || ''; + const oauthToken = + getEnv('CLAUDE_CODE_OAUTH_TOKEN') || getEnv('ANTHROPIC_AUTH_TOKEN') || ''; + const anthropicBaseUrl = + getEnv('ANTHROPIC_BASE_URL') || 'https://api.anthropic.com'; + const openaiApiKey = getEnv('OPENAI_API_KEY') || ''; + + const authMode: AuthMode = anthropicApiKey ? 'api-key' : 'oauth'; + + const anthropicUrl = new URL(anthropicBaseUrl); + const anthropicIsHttps = anthropicUrl.protocol === 'https:'; + const makeAnthropicRequest = anthropicIsHttps ? httpsRequest : httpRequest; + + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const body = Buffer.concat(chunks); + + // Route: Codex/OpenAI requests go to OpenAI API + const isOpenAI = + req.headers['x-ejclaw-provider'] === 'openai' || + (req.url || '').startsWith('/v1/chat/completions'); + + if (isOpenAI) { + proxyToOpenAI(req, res, body, openaiApiKey); + return; + } + + // Default: Anthropic/Claude API + const headers: Record< + string, + string | number | string[] | undefined + > = { + ...(req.headers as Record), + host: anthropicUrl.host, + 'content-length': body.length, + }; + + delete headers['connection']; + delete headers['keep-alive']; + delete headers['transfer-encoding']; + delete headers['x-ejclaw-provider']; + + if (authMode === 'api-key') { + delete headers['x-api-key']; + headers['x-api-key'] = anthropicApiKey; + } else if (headers['authorization']) { + delete headers['authorization']; + if (oauthToken) { + headers['authorization'] = `Bearer ${oauthToken}`; + } + } + + const upstream = makeAnthropicRequest( + { + hostname: anthropicUrl.hostname, + port: anthropicUrl.port || (anthropicIsHttps ? 443 : 80), + path: req.url, + method: req.method, + headers, + } as RequestOptions, + (upRes) => { + res.writeHead(upRes.statusCode!, upRes.headers); + upRes.pipe(res); + }, + ); + + upstream.on('error', (err) => { + logger.error( + { err, url: req.url }, + 'Credential proxy upstream error (Anthropic)', + ); + if (!res.headersSent) { + res.writeHead(502); + res.end('Bad Gateway'); + } + }); + + upstream.write(body); + upstream.end(); + }); + }); + + server.listen(port, host, () => { + logger.info({ port, host, authMode }, 'Credential proxy started'); + resolve(server); + }); + + server.on('error', reject); + }); +} + +function proxyToOpenAI( + req: import('http').IncomingMessage, + res: import('http').ServerResponse, + body: Buffer, + apiKey: string, +): void { + const headers: Record = { + ...(req.headers as Record), + host: 'api.openai.com', + 'content-length': body.length, + }; + + delete headers['connection']; + delete headers['keep-alive']; + delete headers['transfer-encoding']; + delete headers['x-ejclaw-provider']; + + // Inject real OpenAI API key + delete headers['authorization']; + if (apiKey) { + headers['authorization'] = `Bearer ${apiKey}`; + } + + const upstream = httpsRequest( + { + hostname: 'api.openai.com', + port: 443, + path: req.url, + method: req.method, + headers, + } as RequestOptions, + (upRes) => { + res.writeHead(upRes.statusCode!, upRes.headers); + upRes.pipe(res); + }, + ); + + upstream.on('error', (err) => { + logger.error( + { err, url: req.url }, + 'Credential proxy upstream error (OpenAI)', + ); + if (!res.headersSent) { + res.writeHead(502); + res.end('Bad Gateway'); + } + }); + + upstream.write(body); + upstream.end(); +} + +export function detectAuthMode(): AuthMode { + return getEnv('ANTHROPIC_API_KEY') ? 'api-key' : 'oauth'; +} diff --git a/src/db.test.ts b/src/db.test.ts index 5e74ea0..dee379d 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -1,19 +1,11 @@ -import Database from 'better-sqlite3'; import fs from 'fs'; -import path from 'path'; import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, - _initTestDatabaseFromFile, - applyPairedEvent, - cancelSupersededPairedExecutions, claimServiceHandoff, completeServiceHandoffAndAdvanceTargetCursor, - createPairedApproval, - createPairedArtifact, - createPairedExecution, createPairedTask, createTask, createServiceHandoff, @@ -32,8 +24,6 @@ import { getRegisteredAgentTypesForJid, getMessagesSince, getNewMessages, - getPairedExecutionById, - getPairedEventByDedupeKey, getPairedProject, getPairedTaskById, getPairedWorkspace, @@ -41,10 +31,6 @@ import { isPairedRoomJid, getSession, getTaskById, - listPairedApprovalsForTask, - listPairedArtifactsForTask, - listPairedEventsForTask, - listPairedExecutionsForTask, listPairedWorkspacesForTask, markWorkItemDelivered, markWorkItemDeliveryRetry, @@ -53,7 +39,6 @@ import { setRouterStateForService, storeChatMetadata, storeMessage, - updatePairedExecution, updatePairedTask, upsertPairedProject, upsertPairedWorkspace, @@ -554,12 +539,11 @@ Check the run. }); describe('paired task state', () => { - it('stores project, task, execution, workspace, approval, and artifact state', () => { + it('stores project, task, and workspace 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', }); @@ -572,604 +556,36 @@ describe('paired task state', () => { reviewer_service_id: 'codex-review', title: 'wire up workspaces', source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', 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, - snapshot_source_fingerprint: 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', }); - 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: 'plan_brief', - 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(getPairedTaskById('paired-task-1')?.task_policy).toBe('autonomous'); - expect(getPairedTaskById('paired-task-1')?.risk_level).toBe('low'); - expect(getPairedTaskById('paired-task-1')?.plan_status).toBe( - 'not_requested', - ); - expect(getPairedTaskById('paired-task-1')?.gate_turn_kind ?? null).toBe( - null, - ); - expect( - getPairedTaskById('paired-task-1')?.last_finalized_checkpoint ?? null, - ).toBe(null); - expect(getPairedTaskById('paired-task-1')?.reviewer_verdict ?? null).toBe( - null, - ); - expect(getPairedExecutionById('paired-exec-1')?.status).toBe('pending'); + expect(getPairedTaskById('paired-task-1')?.status).toBe('active'); 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('dedupes paired events and keeps request_review replay-safe', () => { - createPairedTask({ - id: 'paired-task-events-1', - chat_jid: 'dc:paired', - group_folder: 'paired-room', - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - const first = applyPairedEvent({ - event: { - task_id: 'paired-task-events-1', - event_type: 'request_review', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'fingerprint-v1', - dedupe_key: 'msg-review-1', - payload_json: '{"request":"review"}', - created_at: '2026-03-29T00:01:00.000Z', - }, - onApply: () => { - updatePairedTask('paired-task-events-1', { - status: 'review_pending', - review_requested_at: '2026-03-29T00:01:00.000Z', - updated_at: '2026-03-29T00:01:00.000Z', - }); - }, - }); - - const second = applyPairedEvent({ - event: { - task_id: 'paired-task-events-1', - event_type: 'request_review', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'fingerprint-v2', - dedupe_key: 'msg-review-1', - payload_json: '{"request":"review-again"}', - created_at: '2026-03-29T00:02:00.000Z', - }, - onApply: () => { - updatePairedTask('paired-task-events-1', { - status: 'review_ready', - review_requested_at: '2026-03-29T00:02:00.000Z', - updated_at: '2026-03-29T00:02:00.000Z', - }); - }, - }); - - expect(first.applied).toBe(true); - expect(second.applied).toBe(false); - expect(listPairedEventsForTask('paired-task-events-1')).toHaveLength(1); - expect( - getPairedEventByDedupeKey({ - taskId: 'paired-task-events-1', - eventType: 'request_review', - dedupeKey: 'msg-review-1', - })?.source_fingerprint, - ).toBe('fingerprint-v1'); - expect(getPairedTaskById('paired-task-events-1')?.status).toBe( - 'review_pending', - ); - expect(getPairedTaskById('paired-task-events-1')?.review_requested_at).toBe( - '2026-03-29T00:01:00.000Z', - ); - }); - - it('dedupes deploy_complete and atomically finalizes the checkpoint once', () => { - createPairedTask({ - id: 'paired-task-events-deploy', - chat_jid: 'dc:paired', - group_folder: 'paired-room', - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'approved', - review_requested_at: '2026-03-29T00:00:00.000Z', - last_finalized_checkpoint: null, - gate_turn_kind: null, - reviewer_verdict: 'done', - reviewer_verdict_at: '2026-03-29T00:00:00.000Z', - reviewer_verdict_note: '**DONE** ready', - status: 'merge_ready', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - const first = applyPairedEvent({ - event: { - task_id: 'paired-task-events-deploy', - event_type: 'deploy_complete', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'canonical-v1', - dedupe_key: 'deploy-complete:canonical-v1', - payload_json: '{"checkpoint":"canonical-v1"}', - created_at: '2026-03-29T00:01:00.000Z', - }, - onApply: () => { - updatePairedTask('paired-task-events-deploy', { - status: 'merged', - last_finalized_checkpoint: 'canonical-v1', - updated_at: '2026-03-29T00:01:00.000Z', - }); - }, - }); - - const second = applyPairedEvent({ - event: { - task_id: 'paired-task-events-deploy', - event_type: 'deploy_complete', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'canonical-v1', - dedupe_key: 'deploy-complete:canonical-v1', - payload_json: '{"checkpoint":"canonical-v1"}', - created_at: '2026-03-29T00:02:00.000Z', - }, - onApply: () => { - updatePairedTask('paired-task-events-deploy', { - status: 'failed', - last_finalized_checkpoint: 'should-not-change', - updated_at: '2026-03-29T00:02:00.000Z', - }); - }, - }); - - expect(first.applied).toBe(true); - expect(second.applied).toBe(false); - expect( - listPairedEventsForTask('paired-task-events-deploy').filter( - (event) => event.event_type === 'deploy_complete', - ), - ).toHaveLength(1); - expect( - getPairedTaskById('paired-task-events-deploy')?.last_finalized_checkpoint, - ).toBe('canonical-v1'); - expect(getLatestPairedTaskForChat('dc:paired')?.status).toBe('merged'); - expect(getPairedTaskById('paired-task-events-deploy')?.status).toBe( - 'merged', - ); - }); - - it('rolls back paired event insert when the coupled state update fails', () => { - createPairedTask({ - id: 'paired-task-events-2', - chat_jid: 'dc:paired', - group_folder: 'paired-room', - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - expect(() => - applyPairedEvent({ - event: { - task_id: 'paired-task-events-2', - event_type: 'set_risk', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'fingerprint-v1', - dedupe_key: 'msg-risk-1', - payload_json: '{"riskLevel":"high"}', - created_at: '2026-03-29T00:01:00.000Z', - }, - onApply: () => { - updatePairedTask('paired-task-events-2', { - risk_level: 'high', - updated_at: '2026-03-29T00:01:00.000Z', - }); - throw new Error('boom'); - }, - }), - ).toThrow('boom'); - - expect(listPairedEventsForTask('paired-task-events-2')).toHaveLength(0); - expect(getPairedTaskById('paired-task-events-2')?.risk_level).toBe('low'); - }); - - it('keeps paired event audit history after reopening the database', () => { - const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-events-')); - const dbPath = path.join(tempRoot, 'messages.db'); - - _initTestDatabaseFromFile(dbPath); - createPairedTask({ - id: 'paired-task-events-3', - chat_jid: 'dc:paired', - group_folder: 'paired-room', - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - applyPairedEvent({ - event: { - task_id: 'paired-task-events-3', - event_type: 'request_review', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'fingerprint-v1', - dedupe_key: 'msg-review-3', - payload_json: '{"request":"review"}', - created_at: '2026-03-29T00:01:00.000Z', - }, - onApply: () => { - updatePairedTask('paired-task-events-3', { - status: 'review_pending', - review_requested_at: '2026-03-29T00:01:00.000Z', - updated_at: '2026-03-29T00:01:00.000Z', - }); - }, - }); - - _initTestDatabaseFromFile(dbPath); - - expect(listPairedEventsForTask('paired-task-events-3')).toHaveLength(1); - expect(listPairedEventsForTask('paired-task-events-3')[0]?.dedupe_key).toBe( - 'msg-review-3', - ); - expect( - listPairedEventsForTask('paired-task-events-3')[0]?.source_fingerprint, - ).toBe('fingerprint-v1'); - - fs.rmSync(tempRoot, { recursive: true, force: true }); - }); - - it('backfills governance plan_status by legacy task status during migration', () => { - const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-migration-')); - const dbPath = path.join(tempRoot, 'messages.db'); - const legacyDb = new Database(dbPath); - - legacyDb.exec(` - CREATE TABLE paired_tasks ( - id TEXT PRIMARY KEY, - chat_jid TEXT NOT NULL, - group_folder TEXT NOT NULL, - owner_service_id TEXT NOT NULL, - reviewer_service_id TEXT NOT NULL, - title TEXT, - source_ref TEXT, - review_requested_at TEXT, - status TEXT NOT NULL DEFAULT 'draft', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - `); - - const insertLegacyTask = legacyDb.prepare(` - INSERT INTO paired_tasks ( - id, - chat_jid, - group_folder, - owner_service_id, - reviewer_service_id, - title, - source_ref, - review_requested_at, - status, - created_at, - updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - insertLegacyTask.run( - 'paired-active', - 'dc:paired', - 'paired-room', - 'codex-main', - 'codex-review', - null, - 'HEAD', - null, - 'active', - '2026-03-29T00:00:00.000Z', - '2026-03-29T00:00:00.000Z', - ); - insertLegacyTask.run( - 'paired-draft', - 'dc:paired', - 'paired-room', - 'codex-main', - 'codex-review', - null, - 'HEAD', - null, - 'draft', - '2026-03-29T00:00:00.000Z', - '2026-03-29T00:00:00.000Z', - ); - insertLegacyTask.run( - 'paired-in-review', - 'dc:paired', - 'paired-room', - 'codex-main', - 'codex-review', - null, - 'HEAD', - '2026-03-29T00:01:00.000Z', - 'in_review', - '2026-03-29T00:00:00.000Z', - '2026-03-29T00:01:00.000Z', - ); - insertLegacyTask.run( - 'paired-merge-ready', - 'dc:paired', - 'paired-room', - 'codex-main', - 'codex-review', - null, - 'HEAD', - '2026-03-29T00:02:00.000Z', - 'merge_ready', - '2026-03-29T00:00:00.000Z', - '2026-03-29T00:02:00.000Z', - ); - legacyDb.close(); - - _initTestDatabaseFromFile(dbPath); - - expect(getPairedTaskById('paired-in-review')?.plan_status).toBe('approved'); - expect(getPairedTaskById('paired-merge-ready')?.plan_status).toBe( - 'approved', - ); - expect(getPairedTaskById('paired-active')?.plan_status).toBe( - 'not_requested', - ); - expect(getPairedTaskById('paired-draft')?.plan_status).toBe( - 'not_requested', - ); - - fs.rmSync(tempRoot, { recursive: true, force: true }); - }); - - it('migrates paired task finalization fields and deploy_complete events', () => { - const tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-db-finalize-')); - const dbPath = path.join(tempRoot, 'messages.db'); - const legacyDb = new Database(dbPath); - - legacyDb.exec(` - CREATE TABLE paired_tasks ( - id TEXT PRIMARY KEY, - chat_jid TEXT NOT NULL, - group_folder TEXT NOT NULL, - owner_service_id TEXT NOT NULL, - reviewer_service_id TEXT NOT NULL, - title TEXT, - source_ref TEXT, - task_policy TEXT NOT NULL DEFAULT 'autonomous', - risk_level TEXT NOT NULL DEFAULT 'low', - plan_status TEXT NOT NULL DEFAULT 'not_requested', - review_requested_at TEXT, - gate_turn_kind TEXT, - reviewer_verdict TEXT, - reviewer_verdict_at TEXT, - reviewer_verdict_note TEXT, - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE TABLE paired_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - event_type TEXT NOT NULL, - actor_role TEXT NOT NULL, - source_service_id TEXT NOT NULL, - source_fingerprint TEXT, - dedupe_key TEXT NOT NULL, - payload_json TEXT, - created_at TEXT NOT NULL - ); - `); - - legacyDb - .prepare( - ` - INSERT INTO paired_tasks ( - id, - chat_jid, - group_folder, - owner_service_id, - reviewer_service_id, - title, - source_ref, - task_policy, - risk_level, - plan_status, - review_requested_at, - gate_turn_kind, - reviewer_verdict, - reviewer_verdict_at, - reviewer_verdict_note, - status, - created_at, - updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - 'paired-finalized', - 'dc:paired', - 'paired-room', - 'codex-main', - 'codex-review', - null, - 'HEAD', - 'autonomous', - 'low', - 'approved', - '2026-03-29T00:00:00.000Z', - null, - 'done', - '2026-03-29T00:00:00.000Z', - '**DONE**', - 'merge_ready', - '2026-03-29T00:00:00.000Z', - '2026-03-29T00:00:00.000Z', - ); - legacyDb - .prepare( - ` - INSERT INTO paired_events ( - task_id, - event_type, - actor_role, - source_service_id, - source_fingerprint, - dedupe_key, - payload_json, - created_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - 'paired-finalized', - 'request_review', - 'owner', - 'codex-main', - 'fingerprint-v1', - 'msg-review-legacy', - null, - '2026-03-29T00:00:00.000Z', - ); - legacyDb.close(); - - _initTestDatabaseFromFile(dbPath); - - expect( - getPairedTaskById('paired-finalized')?.last_finalized_checkpoint ?? null, - ).toBe(null); - - applyPairedEvent({ - event: { - task_id: 'paired-finalized', - event_type: 'deploy_complete', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'canonical-v1', - dedupe_key: 'deploy-complete:canonical-v1', - payload_json: '{"checkpoint":"canonical-v1"}', - created_at: '2026-03-29T00:01:00.000Z', - }, - onApply: () => { - updatePairedTask('paired-finalized', { - status: 'merged', - last_finalized_checkpoint: 'canonical-v1', - updated_at: '2026-03-29T00:01:00.000Z', - }); - }, - }); - - expect(getPairedTaskById('paired-finalized')?.status).toBe('merged'); - expect( - getPairedTaskById('paired-finalized')?.last_finalized_checkpoint, - ).toBe('canonical-v1'); - expect( - getPairedEventByDedupeKey({ - taskId: 'paired-finalized', - eventType: 'deploy_complete', - dedupeKey: 'deploy-complete:canonical-v1', - })?.source_fingerprint, - ).toBe('canonical-v1'); - - fs.rmSync(tempRoot, { recursive: true, force: true }); - }); - - it('updates task and execution state and keeps one workspace per role', () => { + it('updates task state and keeps one workspace per role', () => { createPairedTask({ id: 'paired-task-2', chat_jid: 'dc:paired', @@ -1178,43 +594,18 @@ describe('paired task state', () => { reviewer_service_id: 'codex-review', title: null, source_ref: null, - task_policy: 'user_signoff_required', - risk_level: 'high', - plan_status: 'pending', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', 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', { - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'approved', - gate_turn_kind: 'implementation_start', - reviewer_verdict: 'done_with_concerns', - reviewer_verdict_at: '2026-03-28T00:09:00.000Z', - reviewer_verdict_note: '**DONE_WITH_CONCERNS** okay to proceed', - status: 'review_pending', + 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', - }); expect(getPairedTaskById('paired-task-2')?.gate_turn_kind).toBe( 'implementation_start', @@ -1229,7 +620,7 @@ describe('paired task state', () => { role: 'reviewer', workspace_dir: '/tmp/reviewer-v1', snapshot_source_dir: '/tmp/owner', - snapshot_source_fingerprint: 'fingerprint-v1', + snapshot_ref: 'fingerprint-v1', status: 'ready', snapshot_refreshed_at: '2026-03-28T00:10:00.000Z', created_at: '2026-03-28T00:10:00.000Z', @@ -1241,89 +632,20 @@ describe('paired task state', () => { role: 'reviewer', workspace_dir: '/tmp/reviewer-v2', snapshot_source_dir: '/tmp/owner', - snapshot_source_fingerprint: 'fingerprint-v2', + snapshot_ref: 'fingerprint-v2', 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_pending'); - expect(getPairedTaskById('paired-task-2')?.task_policy).toBe('autonomous'); - expect(getPairedTaskById('paired-task-2')?.risk_level).toBe('low'); - expect(getPairedTaskById('paired-task-2')?.plan_status).toBe('approved'); - expect(getPairedExecutionById('paired-exec-2')?.status).toBe('running'); + expect(getPairedTaskById('paired-task-2')?.status).toBe('review_ready'); expect( listPairedWorkspacesForTask('paired-task-2').map( (workspace) => workspace.workspace_dir, ), ).toEqual(['/tmp/reviewer-v2']); }); - - it('persists execution checkpoints and cancels only superseded running executions', () => { - createPairedTask({ - id: 'paired-task-3', - chat_jid: 'dc:paired', - group_folder: 'paired-room', - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'approved', - review_requested_at: null, - status: 'active', - created_at: '2026-03-29T01:00:00.000Z', - updated_at: '2026-03-29T01:00:00.000Z', - }); - createPairedExecution({ - id: 'paired-exec-3a', - task_id: 'paired-task-3', - service_id: 'codex-review', - role: 'reviewer', - workspace_id: 'paired-task-3:reviewer', - checkpoint_fingerprint: 'fingerprint-v1', - status: 'running', - summary: null, - created_at: '2026-03-29T01:01:00.000Z', - started_at: '2026-03-29T01:01:00.000Z', - completed_at: null, - }); - createPairedExecution({ - id: 'paired-exec-3b', - task_id: 'paired-task-3', - service_id: 'codex-review', - role: 'reviewer', - workspace_id: 'paired-task-3:reviewer', - checkpoint_fingerprint: 'fingerprint-v2', - status: 'running', - summary: null, - created_at: '2026-03-29T01:02:00.000Z', - started_at: '2026-03-29T01:02:00.000Z', - completed_at: null, - }); - - const cancelled = cancelSupersededPairedExecutions({ - taskId: 'paired-task-3', - role: 'reviewer', - exceptExecutionId: 'paired-exec-3b', - note: 'Superseded by a newer review checkpoint.', - }); - - expect(cancelled).toBe(1); - expect(getPairedExecutionById('paired-exec-3a')).toMatchObject({ - status: 'cancelled', - checkpoint_fingerprint: 'fingerprint-v1', - }); - expect(getPairedExecutionById('paired-exec-3b')).toMatchObject({ - status: 'running', - checkpoint_fingerprint: 'fingerprint-v2', - }); - expect( - listPairedExecutionsForTask('paired-task-3').map((execution) => execution.id), - ).toEqual(['paired-exec-3a', 'paired-exec-3b']); - }); }); // --- LIMIT behavior --- diff --git a/src/db.ts b/src/db.ts index 35de23e..46fbfa6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -25,10 +25,6 @@ import { getTaskRuntimeTaskId } from './task-watch-status.js'; import { NewMessage, AgentType, - PairedApproval, - PairedArtifact, - PairedEvent, - PairedExecution, PairedProject, PairedTask, PairedWorkspace, @@ -241,10 +237,8 @@ function createSchema(database: Database.Database): void { 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')) + updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS paired_tasks ( id TEXT PRIMARY KEY, @@ -254,9 +248,7 @@ function createSchema(database: Database.Database): void { reviewer_service_id TEXT NOT NULL, title TEXT, source_ref TEXT, - task_policy TEXT NOT NULL DEFAULT 'autonomous', - risk_level TEXT NOT NULL DEFAULT 'low', - plan_status TEXT NOT NULL DEFAULT 'not_requested', + plan_notes TEXT, review_requested_at TEXT, last_finalized_checkpoint TEXT, gate_turn_kind TEXT, @@ -266,137 +258,26 @@ function createSchema(database: Database.Database): void { status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - CHECK (task_policy IN ('autonomous', 'user_signoff_required')), - CHECK (risk_level IN ('low', 'high')), - CHECK ( - plan_status IN ( - 'not_requested', - 'pending', - 'approved', - 'changes_requested' - ) - ), - CHECK ( - gate_turn_kind IS NULL OR - gate_turn_kind IN ('implementation_start', 'commit', 'push') - ), - CHECK ( - reviewer_verdict IS NULL OR - reviewer_verdict IN ('done', 'done_with_concerns', 'blocked', 'silent') - ), - CHECK ( - status IN ( - 'active', - 'draft', - 'plan_review_pending', - 'review_pending', - 'review_ready', - 'in_review', - 'changes_requested', - 'merge_ready', - 'merged', - 'discarded', - 'failed' - ) - ) + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed')) ); 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, - checkpoint_fingerprint 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, - snapshot_source_fingerprint TEXT, - status TEXT NOT NULL DEFAULT 'provisioning', + snapshot_ref TEXT, + status TEXT NOT NULL DEFAULT 'ready', 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')) + CHECK (status IN ('ready', 'stale')) ); 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', - 'plan_brief', - 'acceptance_criteria', - 'risk_summary' - ) - ) - ); - CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task - ON paired_artifacts(task_id, created_at); - CREATE TABLE IF NOT EXISTS paired_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - event_type TEXT NOT NULL, - actor_role TEXT NOT NULL, - source_service_id TEXT NOT NULL, - source_fingerprint TEXT, - dedupe_key TEXT NOT NULL, - payload_json TEXT, - created_at TEXT NOT NULL, - CHECK ( - event_type IN ( - 'set_risk', - 'submit_plan', - 'approve_plan', - 'request_plan_changes', - 'request_review', - 'deploy_complete' - ) - ), - CHECK (actor_role IN ('owner', 'reviewer', 'system')) - ); - CREATE INDEX IF NOT EXISTS idx_paired_events_task - ON paired_events(task_id, created_at, id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_events_dedupe - ON paired_events(task_id, event_type, dedupe_key); CREATE TABLE IF NOT EXISTS channel_owner ( chat_jid TEXT PRIMARY KEY, owner_service_id TEXT NOT NULL, @@ -485,22 +366,6 @@ function createSchema(database: Database.Database): void { /* column already exists */ } - try { - database.exec( - `ALTER TABLE paired_workspaces ADD COLUMN snapshot_source_fingerprint TEXT`, - ); - } catch { - /* column already exists */ - } - - try { - database.exec( - `ALTER TABLE paired_executions ADD COLUMN checkpoint_fingerprint TEXT`, - ); - } catch { - /* column already exists */ - } - database.exec(` UPDATE scheduled_tasks SET agent_type = COALESCE( @@ -691,26 +556,21 @@ function createSchema(database: Database.Database): void { ); } + // Migration: drop legacy paired tables and rebuild simplified schema. + // Old tables (paired_executions, paired_approvals, paired_artifacts, paired_events) + // are no longer used. paired_tasks is rebuilt with simplified columns. const pairedTasksSqlRow = database .prepare( `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`, ) .get() as { sql?: string } | undefined; const pairedTasksSql = pairedTasksSqlRow?.sql || ''; - const pairedTasksNeedsMigration = - pairedTasksSql && - (!pairedTasksSql.includes("'review_pending'") || - !pairedTasksSql.includes("'active'") || - !pairedTasksSql.includes("'plan_review_pending'") || - !pairedTasksSql.includes('task_policy TEXT') || - !pairedTasksSql.includes('risk_level TEXT') || - !pairedTasksSql.includes('plan_status TEXT') || - !pairedTasksSql.includes('last_finalized_checkpoint TEXT') || - !pairedTasksSql.includes('gate_turn_kind TEXT') || - !pairedTasksSql.includes('reviewer_verdict TEXT')); - if (pairedTasksNeedsMigration) { + const pairedTasksNeedsRebuild = + pairedTasksSql && pairedTasksSql.includes('task_policy'); + if (pairedTasksNeedsRebuild) { + database.exec(`DROP TABLE IF EXISTS paired_tasks`); database.exec(` - CREATE TABLE paired_tasks_new ( + CREATE TABLE IF NOT EXISTS paired_tasks ( id TEXT PRIMARY KEY, chat_jid TEXT NOT NULL, group_folder TEXT NOT NULL, @@ -718,9 +578,7 @@ function createSchema(database: Database.Database): void { reviewer_service_id TEXT NOT NULL, title TEXT, source_ref TEXT, - task_policy TEXT NOT NULL DEFAULT 'autonomous', - risk_level TEXT NOT NULL DEFAULT 'low', - plan_status TEXT NOT NULL DEFAULT 'not_requested', + plan_notes TEXT, review_requested_at TEXT, last_finalized_checkpoint TEXT, gate_turn_kind TEXT, @@ -730,240 +588,70 @@ function createSchema(database: Database.Database): void { status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, - CHECK (task_policy IN ('autonomous', 'user_signoff_required')), - CHECK (risk_level IN ('low', 'high')), - CHECK ( - plan_status IN ( - 'not_requested', - 'pending', - 'approved', - 'changes_requested' - ) - ), - CHECK ( - gate_turn_kind IS NULL OR - gate_turn_kind IN ('implementation_start', 'commit', 'push') - ), - CHECK ( - reviewer_verdict IS NULL OR - reviewer_verdict IN ('done', 'done_with_concerns', 'blocked', 'silent') - ), - CHECK ( - status IN ( - 'active', - 'draft', - 'plan_review_pending', - 'review_pending', - 'review_ready', - 'in_review', - 'changes_requested', - 'merge_ready', - 'merged', - 'discarded', - 'failed' - ) - ) + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed')) ); - `); - database.exec(` - INSERT INTO paired_tasks_new ( - id, - chat_jid, - group_folder, - owner_service_id, - reviewer_service_id, - title, - source_ref, - task_policy, - risk_level, - plan_status, - review_requested_at, - last_finalized_checkpoint, - gate_turn_kind, - reviewer_verdict, - reviewer_verdict_at, - reviewer_verdict_note, - status, - created_at, - updated_at - ) - SELECT - id, - chat_jid, - group_folder, - owner_service_id, - reviewer_service_id, - title, - source_ref, - 'autonomous', - 'low', - CASE - WHEN status IN ( - 'review_pending', - 'review_ready', - 'in_review', - 'changes_requested', - 'merge_ready', - 'merged' - ) THEN 'approved' - ELSE 'not_requested' - END, - review_requested_at, - NULL, - NULL, - NULL, - NULL, - NULL, - status, - created_at, - updated_at - FROM paired_tasks; - `); - database.exec(` - DROP TABLE paired_tasks; - ALTER TABLE paired_tasks_new RENAME TO paired_tasks; - `); - database.exec(` CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status ON paired_tasks(chat_jid, status, updated_at); `); } - const pairedEventsSqlRow = database + // Drop legacy tables that are no longer used + for (const table of [ + 'paired_executions', + 'paired_approvals', + 'paired_artifacts', + 'paired_events', + ]) { + database.exec(`DROP TABLE IF EXISTS ${table}`); + } + + // Rebuild paired_workspaces if it has old schema + const pairedWsSqlRow = database .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_events'`, + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`, ) .get() as { sql?: string } | undefined; - const pairedEventsSql = pairedEventsSqlRow?.sql || ''; - const pairedEventsNeedsMigration = - pairedEventsSql && !pairedEventsSql.includes("'deploy_complete'"); - if (pairedEventsNeedsMigration) { + const pairedWsSql = pairedWsSqlRow?.sql || ''; + if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) { + database.exec(`DROP TABLE IF EXISTS paired_workspaces`); database.exec(` - CREATE TABLE paired_events_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + CREATE TABLE IF NOT EXISTS paired_workspaces ( + id TEXT PRIMARY KEY, task_id TEXT NOT NULL, - event_type TEXT NOT NULL, - actor_role TEXT NOT NULL, - source_service_id TEXT NOT NULL, - source_fingerprint TEXT, - dedupe_key TEXT NOT NULL, - payload_json TEXT, + role TEXT NOT NULL, + workspace_dir TEXT NOT NULL, + snapshot_source_dir TEXT, + snapshot_ref TEXT, + status TEXT NOT NULL DEFAULT 'ready', + snapshot_refreshed_at TEXT, created_at TEXT NOT NULL, - CHECK ( - event_type IN ( - 'set_risk', - 'submit_plan', - 'approve_plan', - 'request_plan_changes', - 'request_review', - 'deploy_complete' - ) - ), - CHECK (actor_role IN ('owner', 'reviewer', 'system')) + updated_at TEXT NOT NULL, + CHECK (role IN ('owner', 'reviewer')), + CHECK (status IN ('ready', 'stale')) ); - `); - database.exec(` - INSERT INTO paired_events_new ( - id, - task_id, - event_type, - actor_role, - source_service_id, - source_fingerprint, - dedupe_key, - payload_json, - created_at - ) - SELECT - id, - task_id, - event_type, - actor_role, - source_service_id, - source_fingerprint, - dedupe_key, - payload_json, - created_at - FROM paired_events; - `); - database.exec(` - DROP TABLE paired_events; - ALTER TABLE paired_events_new RENAME TO paired_events; - `); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_paired_events_task - ON paired_events(task_id, created_at, id); - `); - database.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_events_dedupe - ON paired_events(task_id, event_type, dedupe_key); + CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role + ON paired_workspaces(task_id, role); `); } - const pairedArtifactsSqlRow = database + // Rebuild paired_projects if it has old schema + const pairedProjSqlRow = database .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_artifacts'`, + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`, ) .get() as { sql?: string } | undefined; - const pairedArtifactsSql = pairedArtifactsSqlRow?.sql || ''; - const pairedArtifactsNeedsMigration = - pairedArtifactsSql && !pairedArtifactsSql.includes("'plan_brief'"); - if (pairedArtifactsNeedsMigration) { + const pairedProjSql = pairedProjSqlRow?.sql || ''; + if (pairedProjSql && pairedProjSql.includes('workspace_topology')) { + database.exec(`DROP TABLE IF EXISTS paired_projects`); database.exec(` - CREATE TABLE paired_artifacts_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL, - execution_id TEXT, - service_id TEXT NOT NULL, - artifact_type TEXT NOT NULL, - title TEXT, - content TEXT, - file_path TEXT, + CREATE TABLE IF NOT EXISTS paired_projects ( + chat_jid TEXT PRIMARY KEY, + group_folder TEXT NOT NULL, + canonical_work_dir TEXT NOT NULL, created_at TEXT NOT NULL, - CHECK ( - artifact_type IN ( - 'comment', - 'report', - 'patch', - 'plan_brief', - 'acceptance_criteria', - 'risk_summary' - ) - ) + updated_at TEXT NOT NULL ); `); - database.exec(` - INSERT INTO paired_artifacts_new ( - id, - task_id, - execution_id, - service_id, - artifact_type, - title, - content, - file_path, - created_at - ) - SELECT - id, - task_id, - execution_id, - service_id, - artifact_type, - title, - content, - file_path, - created_at - FROM paired_artifacts; - `); - database.exec(` - DROP TABLE paired_artifacts; - ALTER TABLE paired_artifacts_new RENAME TO paired_artifacts; - `); - database.exec(` - CREATE INDEX IF NOT EXISTS idx_paired_artifacts_task - ON paired_artifacts(task_id, created_at); - `); } // Migrate sessions table to composite PK (group_folder, agent_type) @@ -2111,22 +1799,19 @@ export function upsertPairedProject(project: PairedProject): void { chat_jid, group_folder, canonical_work_dir, - workspace_topology, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?) + 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, ); @@ -2149,9 +1834,7 @@ export function createPairedTask(task: PairedTask): void { reviewer_service_id, title, source_ref, - task_policy, - risk_level, - plan_status, + plan_notes, review_requested_at, last_finalized_checkpoint, gate_turn_kind, @@ -2162,7 +1845,7 @@ export function createPairedTask(task: PairedTask): void { created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, @@ -2172,9 +1855,7 @@ export function createPairedTask(task: PairedTask): void { task.reviewer_service_id, task.title, task.source_ref, - task.task_policy, - task.risk_level, - task.plan_status, + task.plan_notes, task.review_requested_at, task.last_finalized_checkpoint ?? null, task.gate_turn_kind ?? null, @@ -2218,7 +1899,7 @@ export function getLatestOpenPairedTaskForChat( SELECT * FROM paired_tasks WHERE chat_jid = ? - AND status NOT IN ('merged', 'discarded', 'failed') + AND status NOT IN ('completed') ORDER BY updated_at DESC LIMIT 1 `, @@ -2233,9 +1914,7 @@ export function updatePairedTask( PairedTask, | 'title' | 'source_ref' - | 'task_policy' - | 'risk_level' - | 'plan_status' + | 'plan_notes' | 'review_requested_at' | 'last_finalized_checkpoint' | 'gate_turn_kind' @@ -2258,17 +1937,9 @@ export function updatePairedTask( fields.push('source_ref = ?'); values.push(updates.source_ref); } - if (updates.task_policy !== undefined) { - fields.push('task_policy = ?'); - values.push(updates.task_policy); - } - if (updates.risk_level !== undefined) { - fields.push('risk_level = ?'); - values.push(updates.risk_level); - } - if (updates.plan_status !== undefined) { - fields.push('plan_status = ?'); - values.push(updates.plan_status); + if (updates.plan_notes !== undefined) { + fields.push('plan_notes = ?'); + values.push(updates.plan_notes); } if (updates.review_requested_at !== undefined) { fields.push('review_requested_at = ?'); @@ -2311,97 +1982,6 @@ export function updatePairedTask( ); } -export function createPairedExecution(execution: PairedExecution): void { - db.prepare( - ` - INSERT INTO paired_executions ( - id, - task_id, - service_id, - role, - workspace_id, - checkpoint_fingerprint, - status, - summary, - created_at, - started_at, - completed_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - execution.id, - execution.task_id, - execution.service_id, - execution.role, - execution.workspace_id, - execution.checkpoint_fingerprint ?? null, - 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' - | 'checkpoint_fingerprint' - | '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.checkpoint_fingerprint !== undefined) { - fields.push('checkpoint_fingerprint = ?'); - values.push(updates.checkpoint_fingerprint); - } - 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( ` @@ -2411,7 +1991,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void { role, workspace_dir, snapshot_source_dir, - snapshot_source_fingerprint, + snapshot_ref, status, snapshot_refreshed_at, created_at, @@ -2421,7 +2001,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void { ON CONFLICT(id) DO UPDATE SET workspace_dir = excluded.workspace_dir, snapshot_source_dir = excluded.snapshot_source_dir, - snapshot_source_fingerprint = excluded.snapshot_source_fingerprint, + snapshot_ref = excluded.snapshot_ref, status = excluded.status, snapshot_refreshed_at = excluded.snapshot_refreshed_at, updated_at = excluded.updated_at @@ -2432,7 +2012,7 @@ export function upsertPairedWorkspace(workspace: PairedWorkspace): void { workspace.role, workspace.workspace_dir, workspace.snapshot_source_dir, - workspace.snapshot_source_fingerprint, + workspace.snapshot_ref, workspace.status, workspace.snapshot_refreshed_at, workspace.created_at, @@ -2457,224 +2037,6 @@ export function listPairedWorkspacesForTask(taskId: string): PairedWorkspace[] { .all(taskId) as PairedWorkspace[]; } -export function listPairedExecutionsForTask(taskId: string): PairedExecution[] { - return db - .prepare( - 'SELECT * FROM paired_executions WHERE task_id = ? ORDER BY created_at, id', - ) - .all(taskId) as PairedExecution[]; -} - -export function cancelSupersededPairedExecutions(args: { - taskId: string; - role: PairedExecution['role']; - exceptExecutionId?: string; - note?: string | null; -}): number { - const now = new Date().toISOString(); - const result = db - .prepare( - ` - UPDATE paired_executions - SET status = 'cancelled', - summary = COALESCE(summary, ?), - completed_at = COALESCE(completed_at, ?) - WHERE task_id = ? - AND role = ? - AND status = 'running' - AND (? IS NULL OR id <> ?) - `, - ) - .run( - args.note ?? null, - now, - args.taskId, - args.role, - args.exceptExecutionId ?? null, - args.exceptExecutionId ?? null, - ); - return result.changes; -} - -export function createPairedApproval( - approval: Omit, -): 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, -): 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[]; -} - -export function getPairedEventById(id: number): PairedEvent | undefined { - return db.prepare('SELECT * FROM paired_events WHERE id = ?').get(id) as - | PairedEvent - | undefined; -} - -export function getPairedEventByDedupeKey(args: { - taskId: string; - eventType: PairedEvent['event_type']; - dedupeKey: string; -}): PairedEvent | undefined { - return db - .prepare( - ` - SELECT * - FROM paired_events - WHERE task_id = ? - AND event_type = ? - AND dedupe_key = ? - `, - ) - .get(args.taskId, args.eventType, args.dedupeKey) as - | PairedEvent - | undefined; -} - -export function listPairedEventsForTask(taskId: string): PairedEvent[] { - return db - .prepare( - 'SELECT * FROM paired_events WHERE task_id = ? ORDER BY created_at, id', - ) - .all(taskId) as PairedEvent[]; -} - -export function applyPairedEvent(args: { - event: Omit; - onApply?: () => T; -}): { - applied: boolean; - event: PairedEvent; - result: T | null; -} { - return db.transaction(() => { - const insertResult = db - .prepare( - ` - INSERT OR IGNORE INTO paired_events ( - task_id, - event_type, - actor_role, - source_service_id, - source_fingerprint, - dedupe_key, - payload_json, - created_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - args.event.task_id, - args.event.event_type, - args.event.actor_role, - args.event.source_service_id, - args.event.source_fingerprint, - args.event.dedupe_key, - args.event.payload_json, - args.event.created_at, - ); - - if (insertResult.changes === 0) { - const existing = getPairedEventByDedupeKey({ - taskId: args.event.task_id, - eventType: args.event.event_type, - dedupeKey: args.event.dedupe_key, - }); - if (!existing) { - throw new Error( - `Paired event dedupe lookup failed for ${args.event.task_id}:${args.event.event_type}:${args.event.dedupe_key}`, - ); - } - return { - applied: false, - event: existing, - result: null as T | null, - }; - } - - const inserted = getPairedEventById(Number(insertResult.lastInsertRowid)); - if (!inserted) { - throw new Error( - `Paired event insert lookup failed for row ${insertResult.lastInsertRowid}`, - ); - } - - return { - applied: true, - event: inserted, - result: args.onApply ? args.onApply() : (null as T | null), - }; - })(); -} - /** * 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. diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 76851c3..81844f1 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -660,33 +660,19 @@ describe('runAgentForGroup room memory', () => { reviewer_service_id: 'codex-main', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', 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, - snapshot_source_fingerprint: null, + snapshot_ref: null, status: 'ready', snapshot_refreshed_at: null, created_at: '2026-03-28T00:00:00.000Z', @@ -695,7 +681,6 @@ describe('runAgentForGroup room memory', () => { 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', }, }); @@ -717,19 +702,16 @@ describe('runAgentForGroup room memory', () => { 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( - expect.objectContaining({ - executionId: 'run-room-role:claude', - status: 'succeeded', - summary: 'ok', - }), - ); + ).toHaveBeenCalledWith({ + taskId: 'paired-task-1', + status: 'succeeded', + summary: 'ok', + }); }); it('blocks reviewer execution when an in-review snapshot became stale and does not spawn the runner', async () => { @@ -759,30 +741,15 @@ describe('runAgentForGroup room memory', () => { reviewer_service_id: 'claude', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'approved', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', 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', }, @@ -817,7 +784,7 @@ describe('runAgentForGroup room memory', () => { expect( pairedExecutionContext.completePairedExecutionContext, ).toHaveBeenCalledWith({ - executionId: 'run-blocked-reviewer:claude', + taskId: 'paired-task-1', status: 'failed', summary: 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.', diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 4a91b8b..ea71323 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -240,7 +240,7 @@ export async function runAgentForGroup( phase: 'final', }); completePairedExecutionContext({ - executionId: pairedExecutionContext.execution.id, + taskId: pairedExecutionContext.task.id, status: pairedExecutionStatus, summary: pairedExecutionSummary, }); @@ -940,7 +940,7 @@ export async function runAgentForGroup( } finally { if (pairedExecutionContext) { completePairedExecutionContext({ - executionId: pairedExecutionContext.execution.id, + taskId: pairedExecutionContext.task.id, status: pairedExecutionStatus, summary: pairedExecutionSummary, reviewerVerdict: pairedReviewerVerdict, diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index c799c65..e5b195f 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -38,12 +38,6 @@ vi.mock('./paired-execution-context.js', () => ({ completePairedExecutionContext: vi.fn(), markRoomReviewReady: vi.fn(() => null), formatRoomReviewReadyMessage: vi.fn(() => null), - planPairedExecutionRecovery: vi.fn(() => null), - finalizeRoomDeployment: vi.fn(() => null), - setRoomTaskRiskLevel: vi.fn(() => null), - recordRoomPlan: vi.fn(() => null), - approveRoomPlan: vi.fn(() => null), - requestRoomPlanChanges: vi.fn(() => null), })); vi.mock('./db.js', () => { @@ -271,11 +265,9 @@ describe('createMessageRuntime', () => { reviewer_service_id: 'codex-main', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: '2026-03-29T00:00:00.000Z', - status: 'review_pending', + status: 'review_ready', created_at: '2026-03-29T00:00:00.000Z', updated_at: '2026-03-29T00:00:00.000Z', }, @@ -356,7 +348,7 @@ describe('createMessageRuntime', () => { actualSessionCommands.handleSessionCommand(opts), ); vi.mocked(pairedExecutionContext.markRoomReviewReady).mockReturnValue({ - status: 'blocked', + status: 'pending', task: { id: 'paired-task-1', chat_jid: chatJid, @@ -365,15 +357,13 @@ describe('createMessageRuntime', () => { reviewer_service_id: 'codex-main', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', + plan_notes: null, review_requested_at: null, - status: 'plan_review_pending', + status: 'active', created_at: '2026-03-29T00:00:00.000Z', updated_at: '2026-03-29T00:00:00.000Z', }, - blockedReason: 'plan-review-required', + pendingReason: 'owner-workspace-not-ready', }); vi.mocked( pairedExecutionContext.formatRoomReviewReadyMessage, @@ -410,228 +400,6 @@ describe('createMessageRuntime', () => { expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, blockedMessage); }); - it('surfaces the deploy-complete message through the message-runtime path', async () => { - const chatJid = 'group@test'; - const group = { - ...makeGroup('codex'), - workDir: '/repo/canonical', - }; - const channel = makeChannel(chatJid); - const saveState = vi.fn(); - const lastAgentTimestamps: Record = {}; - const deployMessage = [ - 'Deployment finalized.', - '- Task: paired-task-1', - '- Status: merged', - '- Checkpoint: abc123', - ].join('\n'); - - vi.mocked(db.isPairedRoomJid).mockReturnValue(true); - vi.mocked(db.getMessagesSince).mockReturnValue([ - { - id: 'msg-deploy', - chat_jid: chatJid, - sender: 'me@test', - sender_name: 'Me', - content: '/deploy-complete', - timestamp: '2026-03-29T00:00:00.000Z', - is_from_me: true, - }, - ]); - const actualSessionCommands = await vi.importActual< - typeof import('./session-commands.js') - >('./session-commands.js'); - vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) => - actualSessionCommands.handleSessionCommand(opts), - ); - vi.mocked(pairedExecutionContext.finalizeRoomDeployment).mockReturnValue( - deployMessage, - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [channel], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => lastAgentTimestamps, - saveState, - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-deploy-complete', - reason: 'messages', - }); - - expect(result).toBe(true); - expect(sessionCommands.handleSessionCommand).toHaveBeenCalled(); - expect(pairedExecutionContext.finalizeRoomDeployment).toHaveBeenCalled(); - expect(channel.sendMessage).toHaveBeenCalledWith(chatJid, deployMessage); - }); - - it('routes /approve-plan through reviewer context in the message-runtime path', async () => { - const chatJid = 'group@test'; - const group = { - ...makeGroup('codex'), - workDir: '/repo/canonical', - }; - const channel = makeChannel(chatJid); - const saveState = vi.fn(); - const lastAgentTimestamps: Record = {}; - - vi.mocked(db.isPairedRoomJid).mockReturnValue(true); - vi.mocked(db.getMessagesSince).mockReturnValue([ - { - id: 'msg-approve-plan', - chat_jid: chatJid, - sender: 'me@test', - sender_name: 'Me', - content: '/approve-plan', - timestamp: '2026-03-29T00:00:00.000Z', - is_from_me: true, - }, - ]); - const actualSessionCommands = await vi.importActual< - typeof import('./session-commands.js') - >('./session-commands.js'); - vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) => - actualSessionCommands.handleSessionCommand(opts), - ); - vi.mocked(pairedExecutionContext.approveRoomPlan).mockReturnValue( - 'Plan approved.\n- Task: paired-task-1', - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [channel], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => lastAgentTimestamps, - saveState, - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-approve-plan', - reason: 'messages', - }); - - expect(result).toBe(true); - expect(pairedExecutionContext.approveRoomPlan).toHaveBeenCalledWith( - expect.objectContaining({ - chatJid, - roomRoleContext: expect.objectContaining({ - role: 'reviewer', - serviceId: 'codex-main', - }), - }), - ); - expect(channel.sendMessage).toHaveBeenCalledWith( - chatJid, - 'Plan approved.\n- Task: paired-task-1', - ); - }); - - it('routes /request-plan-changes through reviewer context in the message-runtime path', async () => { - const chatJid = 'group@test'; - const group = { - ...makeGroup('codex'), - workDir: '/repo/canonical', - }; - const channel = makeChannel(chatJid); - const saveState = vi.fn(); - const lastAgentTimestamps: Record = {}; - - vi.mocked(db.isPairedRoomJid).mockReturnValue(true); - vi.mocked(db.getMessagesSince).mockReturnValue([ - { - id: 'msg-request-plan-changes', - chat_jid: chatJid, - sender: 'me@test', - sender_name: 'Me', - content: '/request-plan-changes tighten scope', - timestamp: '2026-03-29T00:00:00.000Z', - is_from_me: true, - }, - ]); - const actualSessionCommands = await vi.importActual< - typeof import('./session-commands.js') - >('./session-commands.js'); - vi.mocked(sessionCommands.handleSessionCommand).mockImplementation((opts) => - actualSessionCommands.handleSessionCommand(opts), - ); - vi.mocked(pairedExecutionContext.requestRoomPlanChanges).mockReturnValue( - 'Plan changes requested.\n- Task: paired-task-1', - ); - - const runtime = createMessageRuntime({ - assistantName: 'Andy', - idleTimeout: 1_000, - pollInterval: 1_000, - timezone: 'UTC', - triggerPattern: /^@Andy\b/i, - channels: [channel], - queue: { - registerProcess: vi.fn(), - closeStdin: vi.fn(), - notifyIdle: vi.fn(), - } as any, - getRegisteredGroups: () => ({ [chatJid]: group }), - getSessions: () => ({}), - getLastTimestamp: () => '', - setLastTimestamp: vi.fn(), - getLastAgentTimestamps: () => lastAgentTimestamps, - saveState, - persistSession: vi.fn(), - clearSession: vi.fn(), - }); - - const result = await runtime.processGroupMessages(chatJid, { - runId: 'run-request-plan-changes', - reason: 'messages', - }); - - expect(result).toBe(true); - expect(pairedExecutionContext.requestRoomPlanChanges).toHaveBeenCalledWith( - expect.objectContaining({ - chatJid, - roomRoleContext: expect.objectContaining({ - role: 'reviewer', - serviceId: 'codex-main', - }), - note: 'tighten scope', - }), - ); - expect(channel.sendMessage).toHaveBeenCalledWith( - chatJid, - 'Plan changes requested.\n- Task: paired-task-1', - ); - }); - it('ignores generic failure bot messages in paired rooms', async () => { const chatJid = 'group@test'; const group = makeGroup('codex'); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 685eb05..3607746 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -42,14 +42,8 @@ import { runAgentForGroup } from './message-agent-executor.js'; import { MessageTurnController } from './message-turn-controller.js'; import { createSuppressToken } from './output-suppression.js'; import { - approveRoomPlan, - finalizeRoomDeployment, formatRoomReviewReadyMessage, markRoomReviewReady, - planPairedExecutionRecovery, - recordRoomPlan, - requestRoomPlanChanges, - setRoomTaskRiskLevel, } from './paired-execution-context.js'; import { buildRoomRoleContext } from './room-role-context.js'; import { @@ -574,7 +568,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { // Session commands are explicit control events, so owner-only and // reviewer-only commands resolve against the target role rather than // whichever service happened to pick up the slash command first. - markReviewReady: async (dedupeKey) => { + markReviewReady: async () => { const lease = getEffectiveChannelLease(chatJid); const roomRoleContext = buildRoomRoleContext( lease, @@ -584,77 +578,9 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { group, chatJid, roomRoleContext, - dedupeKey, }); return formatRoomReviewReadyMessage(result); }, - finalizeDeployment: async () => { - const lease = getEffectiveChannelLease(chatJid); - const roomRoleContext = buildRoomRoleContext( - lease, - lease.owner_service_id, - ); - return finalizeRoomDeployment({ - group, - chatJid, - roomRoleContext, - }); - }, - setTaskRiskLevel: async (riskLevel, dedupeKey) => { - const lease = getEffectiveChannelLease(chatJid); - const roomRoleContext = buildRoomRoleContext( - lease, - lease.owner_service_id, - ); - return setRoomTaskRiskLevel({ - group, - chatJid, - roomRoleContext, - riskLevel, - dedupeKey, - }); - }, - recordPlan: async (plan, dedupeKey) => { - const lease = getEffectiveChannelLease(chatJid); - const roomRoleContext = buildRoomRoleContext( - lease, - lease.owner_service_id, - ); - return recordRoomPlan({ - group, - chatJid, - roomRoleContext, - dedupeKey, - ...plan, - }); - }, - approvePlan: async (dedupeKey) => { - const lease = getEffectiveChannelLease(chatJid); - const roomRoleContext = buildRoomRoleContext( - lease, - lease.reviewer_service_id ?? lease.owner_service_id, - ); - return approveRoomPlan({ - group, - chatJid, - roomRoleContext, - dedupeKey, - }); - }, - requestPlanChanges: async (note, dedupeKey) => { - const lease = getEffectiveChannelLease(chatJid); - const roomRoleContext = buildRoomRoleContext( - lease, - lease.reviewer_service_id ?? lease.owner_service_id, - ); - return requestRoomPlanChanges({ - group, - chatJid, - roomRoleContext, - note, - dedupeKey, - }); - }, }, }); if (cmdResult.handled) return cmdResult.success; diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 15a0e73..aa19c6c 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -6,32 +6,19 @@ import path from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./db.js', () => ({ - applyPairedEvent: vi.fn(), - cancelSupersededPairedExecutions: vi.fn(() => 0), - createPairedArtifact: vi.fn(), - createPairedExecution: vi.fn(), createPairedTask: vi.fn(), getLatestPairedTaskForChat: vi.fn(), getLatestOpenPairedTaskForChat: vi.fn(), - getPairedExecutionById: vi.fn(), getPairedTaskById: vi.fn(), getPairedWorkspace: vi.fn(), - listPairedArtifactsForTask: vi.fn(), - listPairedEventsForTask: vi.fn(), - listPairedExecutionsForTask: vi.fn(), - updatePairedExecution: vi.fn(), updatePairedTask: vi.fn(), upsertPairedProject: vi.fn(), })); vi.mock('./paired-workspace-manager.js', () => ({ - PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE: - 'Plan review is required before formal review for this high-risk task.', - hasReviewableOwnerWorkspaceChanges: vi.fn(), markPairedTaskReviewReady: vi.fn(), prepareReviewerWorkspaceForExecution: vi.fn(), provisionOwnerWorkspaceForPairedTask: vi.fn(), - resolvePairedTaskSourceFingerprint: vi.fn(), })); vi.mock('./logger.js', () => ({ @@ -45,16 +32,12 @@ vi.mock('./logger.js', () => ({ import * as db from './db.js'; import { - approveRoomPlan, completePairedExecutionContext, finalizeRoomDeployment, formatRoomReviewReadyMessage, markRoomReviewReady, planPairedExecutionRecovery, preparePairedExecutionContext, - recordRoomPlan, - requestRoomPlanChanges, - setRoomTaskRiskLevel, } from './paired-execution-context.js'; import * as pairedWorkspaceManager from './paired-workspace-manager.js'; import type { RegisteredGroup, RoomRoleContext } from './types.js'; @@ -108,45 +91,22 @@ async function importExecutionContextForService(serviceId: string) { vi.resetModules(); const dbModule = { - applyPairedEvent: vi.fn(), - cancelSupersededPairedExecutions: vi.fn(() => 0), - createPairedArtifact: vi.fn(), - createPairedExecution: vi.fn(), createPairedTask: vi.fn(), getLatestPairedTaskForChat: vi.fn(), getLatestOpenPairedTaskForChat: vi.fn(), - getPairedExecutionById: vi.fn(), getPairedTaskById: vi.fn(), getPairedWorkspace: vi.fn(), - listPairedArtifactsForTask: vi.fn(), - listPairedEventsForTask: vi.fn(), - listPairedExecutionsForTask: vi.fn(), - updatePairedExecution: vi.fn(), updatePairedTask: vi.fn(), upsertPairedProject: vi.fn(), }; - dbModule.applyPairedEvent.mockImplementation(({ event, onApply }) => ({ - applied: true, - event: { id: 1, ...event }, - result: onApply ? onApply() : null, - })); - dbModule.getLatestPairedTaskForChat.mockReturnValue(undefined); dbModule.getLatestOpenPairedTaskForChat.mockReturnValue(undefined); - dbModule.getPairedExecutionById.mockReturnValue(undefined); dbModule.getPairedTaskById.mockReturnValue(undefined); dbModule.getPairedWorkspace.mockReturnValue(undefined); - dbModule.listPairedArtifactsForTask.mockReturnValue([]); - dbModule.listPairedEventsForTask.mockReturnValue([]); - dbModule.listPairedExecutionsForTask.mockReturnValue([]); const pairedWorkspaceManagerModule = { - PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE: - 'Plan review is required before formal review for this high-risk task.', - hasReviewableOwnerWorkspaceChanges: vi.fn(), markPairedTaskReviewReady: vi.fn(), prepareReviewerWorkspaceForExecution: vi.fn(), provisionOwnerWorkspaceForPairedTask: vi.fn(), - resolvePairedTaskSourceFingerprint: vi.fn(), }; pairedWorkspaceManagerModule.provisionOwnerWorkspaceForPairedTask.mockReturnValue( { @@ -155,7 +115,7 @@ async function importExecutionContextForService(serviceId: string) { role: 'owner', workspace_dir: '/tmp/paired/task-1/owner', snapshot_source_dir: null, - snapshot_source_fingerprint: null, + snapshot_ref: null, status: 'ready', snapshot_refreshed_at: null, created_at: '2026-03-28T00:00:00.000Z', @@ -168,12 +128,6 @@ async function importExecutionContextForService(serviceId: string) { autoRefreshed: false, }, ); - pairedWorkspaceManagerModule.hasReviewableOwnerWorkspaceChanges.mockReturnValue( - true, - ); - pairedWorkspaceManagerModule.resolvePairedTaskSourceFingerprint.mockReturnValue( - 'fingerprint-1', - ); vi.doMock('./db.js', () => dbModule); vi.doMock('./paired-workspace-manager.js', () => pairedWorkspaceManagerModule); @@ -205,20 +159,9 @@ async function importExecutionContextForService(serviceId: string) { describe('paired execution context', () => { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(db.applyPairedEvent).mockImplementation(({ event, onApply }) => ({ - applied: true, - event: { id: 1, ...event }, - result: onApply ? onApply() : null, - })); - vi.mocked(db.cancelSupersededPairedExecutions).mockReturnValue(0); - vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(undefined); 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(db.listPairedArtifactsForTask).mockReturnValue([]); - vi.mocked(db.listPairedEventsForTask).mockReturnValue([]); - vi.mocked(db.listPairedExecutionsForTask).mockReturnValue([]); vi.mocked( pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask, ).mockReturnValue({ @@ -227,7 +170,7 @@ describe('paired execution context', () => { role: 'owner', workspace_dir: '/tmp/paired/task-1/owner', snapshot_source_dir: null, - snapshot_source_fingerprint: null, + snapshot_ref: null, status: 'ready', snapshot_refreshed_at: null, created_at: '2026-03-28T00:00:00.000Z', @@ -239,12 +182,6 @@ describe('paired execution context', () => { workspace: null, autoRefreshed: false, }); - vi.mocked( - pairedWorkspaceManager.hasReviewableOwnerWorkspaceChanges, - ).mockReturnValue(true); - vi.mocked( - pairedWorkspaceManager.resolvePairedTaskSourceFingerprint, - ).mockReturnValue('fingerprint-1'); }); it('creates an owner execution with a worktree override', () => { @@ -259,1143 +196,15 @@ describe('paired execution context', () => { expect(db.createPairedTask).toHaveBeenCalledTimes(1); expect(db.createPairedTask).toHaveBeenCalledWith( expect.objectContaining({ - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', status: 'active', }), ); - expect(db.createPairedExecution).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'run-1:codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - }), - ); expect(result?.envOverrides).toMatchObject({ EJCLAW_WORK_DIR: '/tmp/paired/task-1/owner', EJCLAW_PAIRED_ROLE: 'owner', }); }); - it('blocks owner execution when a gate turn lacks a visible reviewer 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - gate_turn_kind: 'implementation_start', - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, - status: 'active', - 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-gate-block', - roomRoleContext: ownerContext, - }); - - expect(result?.blockMessage).toContain( - 'A visible reviewer verdict is required before the owner can proceed with this gate.', - ); - expect(result?.blockMessage).toContain('- Gate: implementation_start'); - expect( - pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask, - ).not.toHaveBeenCalled(); - }); - - it('allows owner execution when the current gate has an approved reviewer 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - gate_turn_kind: 'implementation_start', - reviewer_verdict: 'done_with_concerns', - reviewer_verdict_at: '2026-03-28T00:01:00.000Z', - reviewer_verdict_note: '**DONE_WITH_CONCERNS** gate okay', - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:01:00.000Z', - }); - - const result = preparePairedExecutionContext({ - group, - chatJid: 'dc:test', - runId: 'run-gate-allowed', - roomRoleContext: ownerContext, - }); - - expect(result?.blockMessage).toBeUndefined(); - expect( - pairedWorkspaceManager.provisionOwnerWorkspaceForPairedTask, - ).toHaveBeenCalledWith('task-1'); - }); - - it('does not self-cancel when the same execution resumes', () => { - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - - preparePairedExecutionContext({ - group, - chatJid: 'dc:test', - runId: 'run-1', - roomRoleContext: ownerContext, - }); - - expect(db.cancelSupersededPairedExecutions).toHaveBeenCalledWith({ - taskId: 'task-1', - role: 'owner', - exceptExecutionId: 'run-1:codex-main', - note: 'Superseded by a newer execution for the same task and role.', - }); - }); - - it('records the latest structured reviewer verdict on a fresh gate turn completion', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-review:codex-review', - task_id: 'task-1', - service_id: 'codex-review', - role: 'reviewer', - workspace_id: 'task-1:reviewer', - checkpoint_fingerprint: null, - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - gate_turn_kind: 'implementation_start', - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - - completePairedExecutionContext({ - executionId: 'run-review:codex-review', - status: 'succeeded', - reviewerVerdict: 'done_with_concerns', - reviewerVerdictNote: '**DONE_WITH_CONCERNS** gate okay', - }); - - expect(db.updatePairedTask).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - reviewer_verdict: 'done_with_concerns', - reviewer_verdict_note: '**DONE_WITH_CONCERNS** gate okay', - }), - ); - }); - - it('promotes an approved push gate verdict to merge_ready', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-review:codex-review', - task_id: 'task-1', - service_id: 'codex-review', - role: 'reviewer', - workspace_id: 'task-1:reviewer', - checkpoint_fingerprint: null, - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'approved', - review_requested_at: '2026-03-28T00:01:00.000Z', - gate_turn_kind: 'push', - reviewer_verdict: null, - reviewer_verdict_at: null, - reviewer_verdict_note: null, - status: 'in_review', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:01:00.000Z', - }); - - completePairedExecutionContext({ - executionId: 'run-review:codex-review', - status: 'succeeded', - reviewerVerdict: 'done', - reviewerVerdictNote: '**DONE** okay to deploy', - }); - - expect(db.updatePairedTask).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - gate_turn_kind: null, - reviewer_verdict: 'done', - reviewer_verdict_note: '**DONE** okay to deploy', - status: 'merge_ready', - }), - ); - }); - - it('finalizes a merge-ready task with a durable deploy_complete checkpoint', () => { - const canonicalDir = createCanonicalRepoWithCommit('deploy-ready'); - const deployedCheckpoint = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: canonicalDir, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - const groupWithRepo: RegisteredGroup = { - ...group, - workDir: canonicalDir, - }; - const task = { - id: 'task-1', - chat_jid: 'dc:test', - group_folder: group.folder, - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous' as const, - risk_level: 'low' as const, - plan_status: 'approved' as const, - review_requested_at: '2026-03-28T00:01:00.000Z', - last_finalized_checkpoint: null, - gate_turn_kind: null, - reviewer_verdict: 'done' as const, - reviewer_verdict_at: '2026-03-28T00:01:00.000Z', - reviewer_verdict_note: '**DONE** okay to deploy', - status: 'merge_ready' as const, - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:01:00.000Z', - }; - vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(task); - vi.mocked(db.getPairedTaskById).mockImplementation(() => task as any); - vi.mocked(db.updatePairedTask).mockImplementation((_id, updates) => { - Object.assign(task, updates); - }); - - const message = finalizeRoomDeployment({ - group: groupWithRepo, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - - expect(db.applyPairedEvent).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - task_id: 'task-1', - event_type: 'deploy_complete', - source_fingerprint: deployedCheckpoint, - dedupe_key: `deploy-complete:${deployedCheckpoint}`, - }), - }), - ); - expect(task.status).toBe('merged'); - expect(task.last_finalized_checkpoint).toBe(deployedCheckpoint); - expect(message).toContain('Deployment finalized.'); - expect(message).toContain(deployedCheckpoint); - - fs.rmSync(canonicalDir, { recursive: true, force: true }); - }); - - it('keeps deploy completion idempotent for the same checkpoint', () => { - const canonicalDir = createCanonicalRepoWithCommit('deploy-once'); - const deployedCheckpoint = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: canonicalDir, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'pipe'], - }).trim(); - const groupWithRepo: RegisteredGroup = { - ...group, - workDir: canonicalDir, - }; - const task = { - id: 'task-1', - chat_jid: 'dc:test', - group_folder: group.folder, - owner_service_id: 'codex-main', - reviewer_service_id: 'codex-review', - title: null, - source_ref: 'HEAD', - task_policy: 'autonomous' as const, - risk_level: 'low' as const, - plan_status: 'approved' as const, - review_requested_at: '2026-03-28T00:01:00.000Z', - last_finalized_checkpoint: null, - gate_turn_kind: null, - reviewer_verdict: 'done' as const, - reviewer_verdict_at: '2026-03-28T00:01:00.000Z', - reviewer_verdict_note: '**DONE** okay to deploy', - status: 'merge_ready' as const, - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:01:00.000Z', - }; - let applied = false; - vi.mocked(db.getLatestPairedTaskForChat).mockImplementation(() => task as any); - vi.mocked(db.getPairedTaskById).mockImplementation(() => task as any); - vi.mocked(db.updatePairedTask).mockImplementation((_id, updates) => { - Object.assign(task, updates); - }); - vi.mocked(db.applyPairedEvent).mockImplementation(({ event, onApply }) => { - if (applied) { - return { - applied: false, - event: { id: 1, ...event }, - result: null, - }; - } - applied = true; - const result = onApply ? onApply() : null; - return { - applied: true, - event: { id: 1, ...event }, - result, - }; - }); - - finalizeRoomDeployment({ - group: groupWithRepo, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - const second = finalizeRoomDeployment({ - group: groupWithRepo, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - - expect(db.applyPairedEvent).toHaveBeenCalledTimes(2); - expect(task.status).toBe('merged'); - expect(task.last_finalized_checkpoint).toBe(deployedCheckpoint); - expect(second).toContain(deployedCheckpoint); - - fs.rmSync(canonicalDir, { recursive: true, force: true }); - }); - - it('rejects deploy completion for a non-merge-ready task', () => { - const canonicalDir = createCanonicalRepoWithCommit('not-ready'); - const groupWithRepo: RegisteredGroup = { - ...group, - workDir: canonicalDir, - }; - vi.mocked(db.getLatestPairedTaskForChat).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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'approved', - review_requested_at: '2026-03-28T00:01:00.000Z', - last_finalized_checkpoint: null, - gate_turn_kind: null, - reviewer_verdict: 'done', - reviewer_verdict_at: '2026-03-28T00:01:00.000Z', - reviewer_verdict_note: '**DONE** okay to deploy', - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:01:00.000Z', - }); - - const message = finalizeRoomDeployment({ - group: groupWithRepo, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - - expect(message).toContain( - 'Deploy completion requires a merge-ready task or the same already-finalized checkpoint.', - ); - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - - fs.rmSync(canonicalDir, { recursive: true, force: true }); - }); - - it('rejects deploy completion when the canonical HEAD is unavailable', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-no-git-')); - const message = finalizeRoomDeployment({ - group: { - ...group, - workDir: tempDir, - }, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - - expect(message).toBe( - 'Deploy completion requires a canonical workDir with a readable HEAD.', - ); - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it('plans reviewer recovery from the latest review checkpoint', () => { - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: '2026-03-29T00:00:00.000Z', - status: 'review_pending', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - vi.mocked(db.listPairedEventsForTask).mockReturnValue([ - { - id: 1, - task_id: 'task-1', - event_type: 'request_review', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'fingerprint-2', - dedupe_key: 'request-review', - payload_json: null, - created_at: '2026-03-29T00:00:00.000Z', - }, - ]); - - const result = planPairedExecutionRecovery({ - group, - chatJid: 'dc:test', - roomRoleContext: reviewerContext, - }); - - expect(result).toMatchObject({ - role: 'reviewer', - checkpointFingerprint: 'fingerprint-2', - recoveryKey: 'paired-recovery:task-1:reviewer:fingerprint-2', - }); - expect(result?.prompt).toContain('formal paired review'); - }); - - it('plans owner recovery for changes requested tasks', () => { - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: '2026-03-29T00:00:00.000Z', - status: 'changes_requested', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - vi.mocked(pairedWorkspaceManager.resolvePairedTaskSourceFingerprint).mockReturnValue( - 'fingerprint-3', - ); - - const result = planPairedExecutionRecovery({ - group, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - - expect(result).toMatchObject({ - role: 'owner', - checkpointFingerprint: 'fingerprint-3', - recoveryKey: - 'paired-recovery:task-1:owner:changes_requested:fingerprint-3', - }); - expect(result?.prompt).toContain('owner rework'); - }); - - it('plans owner recovery for an interrupted active owner execution only when the latest owner execution is still running', () => { - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-29T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - vi.mocked(db.listPairedExecutionsForTask).mockReturnValue([ - { - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'running', - summary: null, - created_at: '2026-03-29T00:00:00.000Z', - started_at: '2026-03-29T00:00:00.000Z', - completed_at: null, - }, - ]); - vi.mocked(pairedWorkspaceManager.resolvePairedTaskSourceFingerprint).mockReturnValue( - 'fingerprint-2', - ); - - const result = planPairedExecutionRecovery({ - group, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - - expect(result).toMatchObject({ - role: 'owner', - checkpointFingerprint: 'fingerprint-2', - recoveryKey: 'paired-recovery:task-1:owner:active:fingerprint-2', - }); - - vi.mocked(db.listPairedExecutionsForTask).mockReturnValue([ - { - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'succeeded', - summary: null, - created_at: '2026-03-29T00:00:00.000Z', - started_at: '2026-03-29T00:00:00.000Z', - completed_at: '2026-03-29T00:10:00.000Z', - }, - ]); - - expect( - planPairedExecutionRecovery({ - group, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }), - ).toBeNull(); - }); - - it('auto-requests review when a low-risk owner execution completes', () => { - vi.mocked(pairedWorkspaceManager.resolvePairedTaskSourceFingerprint).mockReturnValue( - 'fingerprint-2', - ); - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - vi.mocked(db.getPairedWorkspace).mockImplementation((taskId, role) => { - if (taskId !== 'task-1' || role !== 'owner') { - return undefined; - } - return { - id: 'task-1:owner', - task_id: 'task-1', - role: 'owner', - workspace_dir: '/tmp/paired/task-1/owner', - snapshot_source_dir: null, - snapshot_source_fingerprint: 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.markPairedTaskReviewReady).mockReturnValue( - { - ownerWorkspace: { - id: 'task-1:owner', - task_id: 'task-1', - role: 'owner', - workspace_dir: '/tmp/paired/task-1/owner', - snapshot_source_dir: null, - snapshot_source_fingerprint: 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', - snapshot_source_fingerprint: 'fingerprint-1', - status: 'ready', - snapshot_refreshed_at: '2026-03-28T00:00:00.000Z', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }, - }, - ); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'done', - }); - - expect(db.updatePairedExecution).toHaveBeenCalledWith('run-1:codex-main', { - status: 'succeeded', - summary: 'done', - completed_at: expect.any(String), - }); - expect(db.applyPairedEvent).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - task_id: 'task-1', - event_type: 'request_review', - source_fingerprint: 'fingerprint-2', - dedupe_key: 'auto-request-review:fingerprint-2', - }), - }), - ); - expect( - pairedWorkspaceManager.markPairedTaskReviewReady, - ).toHaveBeenCalledWith('task-1'); - }); - - it('does not auto-request review for a successful owner turn without reviewable changes', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: '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, - }); - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - vi.mocked(db.getPairedWorkspace).mockReturnValue({ - id: 'task-1:owner', - task_id: 'task-1', - role: 'owner', - workspace_dir: '/tmp/paired/task-1/owner', - snapshot_source_dir: null, - snapshot_source_fingerprint: 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.hasReviewableOwnerWorkspaceChanges, - ).mockReturnValue(false); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'done', - }); - - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - - it('does not auto-request review twice for the same fingerprint', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: '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, - }); - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - vi.mocked(db.getPairedWorkspace).mockReturnValue({ - id: 'task-1:owner', - task_id: 'task-1', - role: 'owner', - workspace_dir: '/tmp/paired/task-1/owner', - snapshot_source_dir: null, - snapshot_source_fingerprint: 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(db.listPairedEventsForTask).mockReturnValue([ - { - id: 1, - task_id: 'task-1', - event_type: 'request_review', - actor_role: 'owner', - source_service_id: 'codex-main', - source_fingerprint: 'fingerprint-1', - dedupe_key: 'auto-request-review:fingerprint-1', - payload_json: null, - created_at: '2026-03-28T00:00:00.000Z', - }, - ]); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'done', - }); - - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - - it('does not auto-request review for high-risk tasks before plan approval', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: '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, - }); - 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'not_requested', - review_requested_at: null, - status: 'plan_review_pending', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - vi.mocked(db.getPairedWorkspace).mockReturnValue({ - id: 'task-1:owner', - task_id: 'task-1', - role: 'owner', - workspace_dir: '/tmp/paired/task-1/owner', - snapshot_source_dir: null, - snapshot_source_fingerprint: null, - status: 'ready', - snapshot_refreshed_at: null, - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'done', - }); - - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - - it('does not auto-request review after a reviewer execution completes', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-review', - task_id: 'task-1', - service_id: 'codex-review', - role: 'reviewer', - workspace_id: 'task-1:reviewer', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - - completePairedExecutionContext({ - executionId: 'run-1:codex-review', - status: 'succeeded', - summary: 'done', - }); - - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - - it('does not reopen review from a cancelled stale owner execution', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'cancelled', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'stale', - }); - - expect(db.updatePairedExecution).toHaveBeenCalledWith( - 'run-1:codex-main', - expect.objectContaining({ - status: 'cancelled', - }), - ); - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - - it('does not reopen review from an older owner execution when a newer owner execution already failed', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - vi.mocked(db.listPairedExecutionsForTask).mockReturnValue([ - { - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }, - { - id: 'run-2:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-2', - status: 'failed', - summary: 'newer', - created_at: '2026-03-28T00:01:00.000Z', - started_at: '2026-03-28T00:01:00.000Z', - completed_at: '2026-03-28T00:02:00.000Z', - }, - ]); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'stale-after-failed', - }); - - expect(db.updatePairedExecution).toHaveBeenCalledWith( - 'run-1:codex-main', - expect.objectContaining({ - status: 'succeeded', - summary: 'stale-after-failed', - }), - ); - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - - it('does not reopen review from an older owner execution when a newer owner execution already succeeded', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }); - vi.mocked(db.listPairedExecutionsForTask).mockReturnValue([ - { - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-1', - status: 'running', - summary: null, - created_at: '2026-03-28T00:00:00.000Z', - started_at: '2026-03-28T00:00:00.000Z', - completed_at: null, - }, - { - id: 'run-2:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: 'task-1:owner', - checkpoint_fingerprint: 'fingerprint-2', - status: 'succeeded', - summary: 'newer', - created_at: '2026-03-28T00:01:00.000Z', - started_at: '2026-03-28T00:01:00.000Z', - completed_at: '2026-03-28T00:02:00.000Z', - }, - ]); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'stale-after-succeeded', - }); - - expect(db.updatePairedExecution).toHaveBeenCalledWith( - 'run-1:codex-main', - expect.objectContaining({ - status: 'succeeded', - summary: 'stale-after-succeeded', - }), - ); - expect(db.applyPairedEvent).not.toHaveBeenCalled(); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - - it('records a new checkpoint and returns the task to review_pending when owner changes arrive during in_review', () => { - vi.mocked(db.getPairedExecutionById).mockReturnValue({ - id: 'run-1:codex-main', - task_id: 'task-1', - service_id: 'codex-main', - role: 'owner', - workspace_id: '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, - }); - vi.mocked(pairedWorkspaceManager.resolvePairedTaskSourceFingerprint).mockReturnValue( - 'fingerprint-2', - ); - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: '2026-03-28T00:00:00.000Z', - status: 'in_review', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - vi.mocked(db.getPairedWorkspace).mockReturnValue({ - id: 'task-1:owner', - task_id: 'task-1', - role: 'owner', - workspace_dir: '/tmp/paired/task-1/owner', - snapshot_source_dir: null, - snapshot_source_fingerprint: null, - status: 'ready', - snapshot_refreshed_at: null, - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - - completePairedExecutionContext({ - executionId: 'run-1:codex-main', - status: 'succeeded', - summary: 'updated', - }); - - expect(db.applyPairedEvent).toHaveBeenCalledWith( - expect.objectContaining({ - event: expect.objectContaining({ - task_id: 'task-1', - event_type: 'request_review', - source_fingerprint: 'fingerprint-2', - dedupe_key: 'auto-request-review:fingerprint-2', - }), - }), - ); - expect(db.updatePairedTask).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - status: 'review_pending', - review_requested_at: expect.any(String), - }), - ); - expect(db.cancelSupersededPairedExecutions).toHaveBeenCalledWith({ - taskId: 'task-1', - role: 'reviewer', - note: 'Superseded by a newer review checkpoint.', - }); - expect(pairedWorkspaceManager.markPairedTaskReviewReady).not.toHaveBeenCalled(); - }); - it('uses the reviewer snapshot after lazy auto-refresh and marks the task in_review', () => { vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ id: 'task-1', @@ -1405,9 +214,7 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: '2026-03-28T00:00:00.000Z', status: 'review_ready', created_at: '2026-03-28T00:00:00.000Z', @@ -1422,7 +229,7 @@ describe('paired execution context', () => { role: 'reviewer', workspace_dir: '/tmp/paired/task-1/reviewer', snapshot_source_dir: '/tmp/paired/task-1/owner', - snapshot_source_fingerprint: 'fingerprint-1', + snapshot_ref: 'fingerprint-1', status: 'ready', snapshot_refreshed_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z', @@ -1438,9 +245,7 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'approved', + plan_notes: null, review_requested_at: '2026-03-28T00:00:00.000Z', status: 'review_ready', created_at: '2026-03-28T00:00:00.000Z', @@ -1474,11 +279,9 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); @@ -1516,9 +319,7 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'approved', + plan_notes: null, review_requested_at: '2026-03-28T00:00:00.000Z', status: 'in_review', created_at: '2026-03-28T00:00:00.000Z', @@ -1546,6 +347,16 @@ describe('paired execution context', () => { expect(result?.envOverrides.EJCLAW_WORK_DIR).toBeUndefined(); }); + it('completePairedExecutionContext logs without error', () => { + completePairedExecutionContext({ + taskId: 'task-1', + status: 'succeeded', + summary: 'done', + }); + + // Should not throw; just logs. + }); + it('returns ready for /review on the owner service', async () => { const { dbModule, @@ -1561,11 +372,9 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); @@ -1577,9 +386,7 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'approved', + plan_notes: null, review_requested_at: '2026-03-28T00:00:00.000Z', status: 'review_ready', created_at: '2026-03-28T00:00:00.000Z', @@ -1592,7 +399,7 @@ describe('paired execution context', () => { role: 'owner', workspace_dir: '/tmp/paired/task-1/owner', snapshot_source_dir: null, - snapshot_source_fingerprint: null, + snapshot_ref: null, status: 'ready', snapshot_refreshed_at: null, created_at: '2026-03-28T00:00:00.000Z', @@ -1604,7 +411,7 @@ describe('paired execution context', () => { role: 'reviewer', workspace_dir: '/tmp/paired/task-1/reviewer', snapshot_source_dir: '/tmp/paired/task-1/owner', - snapshot_source_fingerprint: 'fingerprint-1', + snapshot_ref: 'fingerprint-1', status: 'ready', snapshot_refreshed_at: '2026-03-28T00:00:00.000Z', created_at: '2026-03-28T00:00:00.000Z', @@ -1646,11 +453,9 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); @@ -1662,11 +467,9 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: '2026-03-28T00:00:00.000Z', - status: 'review_pending', + status: 'review_ready', created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); @@ -1689,13 +492,13 @@ describe('paired execution context', () => { status: 'pending', task: expect.objectContaining({ id: 'task-1', - status: 'review_pending', + status: 'review_ready', }), pendingReason: 'owner-workspace-not-ready', }); }); - it('keeps review_pending and returns a pending result when owner workspace is not ready', () => { + it('keeps review_ready and returns a pending result when owner workspace is not ready', () => { vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({ id: 'task-1', chat_jid: 'dc:test', @@ -1704,11 +507,9 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-28T00:00:00.000Z', }); @@ -1720,11 +521,9 @@ describe('paired execution context', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: '2026-03-29T00:00:00.000Z', - status: 'review_pending', + status: 'review_ready', created_at: '2026-03-28T00:00:00.000Z', updated_at: '2026-03-29T00:00:00.000Z', }); @@ -1742,7 +541,7 @@ describe('paired execution context', () => { status: 'pending', task: expect.objectContaining({ id: 'task-1', - status: 'review_pending', + status: 'review_ready', }), pendingReason: 'owner-workspace-not-ready', }); @@ -1754,323 +553,4 @@ describe('paired execution context', () => { ].join('\n'), ); }); - - it('blocks /review for high-risk tasks until the plan is approved', () => { - 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', - review_requested_at: null, - status: 'plan_review_pending', - 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, - ).not.toHaveBeenCalled(); - expect(result).toEqual({ - status: 'blocked', - task: expect.objectContaining({ - id: 'task-1', - risk_level: 'high', - plan_status: 'pending', - }), - blockedReason: 'plan-review-required', - }); - expect(formatRoomReviewReadyMessage(result)).toBe( - [ - 'Plan review is required before formal review for this high-risk task.', - '- Task: task-1', - '- Plan status: pending', - 'Ask the owner to record a plan and have the reviewer approve it before /review.', - ].join('\n'), - ); - }); - - it('raises a task to high risk and moves it into plan_review_pending', () => { - 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', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', - review_requested_at: null, - status: 'active', - 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'not_requested', - review_requested_at: null, - status: 'plan_review_pending', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - const message = setRoomTaskRiskLevel({ - group, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - riskLevel: 'high', - }); - - expect(db.updatePairedTask).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - risk_level: 'high', - plan_status: 'not_requested', - status: 'plan_review_pending', - }), - ); - expect(message).toBe( - [ - 'Task risk updated.', - '- Task: task-1', - '- Risk: high', - '- Status: plan_review_pending', - ].join('\n'), - ); - }); - - it('records plan artifacts for a high-risk task and keeps it pending 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'not_requested', - review_requested_at: null, - status: 'plan_review_pending', - 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', - review_requested_at: null, - status: 'plan_review_pending', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - const message = recordRoomPlan({ - group, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - planBrief: 'ship governance gate', - acceptanceCriteria: 'high risk blocks /review', - riskSummary: 'runtime state drift', - }); - - expect(db.createPairedArtifact).toHaveBeenCalledTimes(3); - expect(db.updatePairedTask).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - plan_status: 'pending', - status: 'plan_review_pending', - }), - ); - expect(message).toBe( - [ - 'Plan recorded.', - '- Task: task-1', - '- Plan status: pending', - '- Status: plan_review_pending', - ].join('\n'), - ); - }); - - it('approves a high-risk plan from the reviewer side once plan artifacts exist', () => { - 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', - review_requested_at: null, - status: 'plan_review_pending', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-28T00:00:00.000Z', - }); - vi.mocked(db.listPairedArtifactsForTask).mockReturnValue([ - { - id: 1, - task_id: 'task-1', - execution_id: null, - service_id: 'codex-main', - artifact_type: 'plan_brief', - title: null, - content: 'brief', - file_path: null, - created_at: '2026-03-29T00:00:00.000Z', - }, - { - id: 2, - task_id: 'task-1', - execution_id: null, - service_id: 'codex-main', - artifact_type: 'acceptance_criteria', - title: null, - content: 'criteria', - file_path: null, - created_at: '2026-03-29T00:00:01.000Z', - }, - { - id: 3, - task_id: 'task-1', - execution_id: null, - service_id: 'codex-main', - artifact_type: 'risk_summary', - title: null, - content: 'risk', - file_path: null, - created_at: '2026-03-29T00:00:02.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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'approved', - review_requested_at: null, - status: 'active', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - const message = approveRoomPlan({ - group, - chatJid: 'dc:test', - roomRoleContext: reviewerContext, - }); - - expect(db.updatePairedTask).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - plan_status: 'approved', - status: 'active', - }), - ); - expect(message).toBe( - ['Plan approved.', '- Task: task-1', '- Status: active'].join('\n'), - ); - }); - - it('requests plan changes from the reviewer side and keeps the task pending', () => { - 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', - review_requested_at: null, - status: 'plan_review_pending', - 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', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'changes_requested', - review_requested_at: null, - status: 'plan_review_pending', - created_at: '2026-03-28T00:00:00.000Z', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - const message = requestRoomPlanChanges({ - group, - chatJid: 'dc:test', - roomRoleContext: reviewerContext, - note: 'tighten acceptance criteria', - }); - - expect(db.createPairedArtifact).toHaveBeenCalledWith( - expect.objectContaining({ - task_id: 'task-1', - service_id: 'codex-review', - artifact_type: 'comment', - content: 'tighten acceptance criteria', - }), - ); - expect(db.updatePairedTask).toHaveBeenCalledWith( - 'task-1', - expect.objectContaining({ - plan_status: 'changes_requested', - status: 'plan_review_pending', - }), - ); - expect(message).toBe( - [ - 'Plan changes requested.', - '- Task: task-1', - '- Plan status: changes_requested', - '- Status: plan_review_pending', - ].join('\n'), - ); - }); }); diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index b7a5386..3687cb3 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -3,86 +3,30 @@ import crypto from 'crypto'; import { SERVICE_ID, normalizeServiceId } from './config.js'; import { - applyPairedEvent, - cancelSupersededPairedExecutions, - createPairedArtifact, - createPairedExecution, createPairedTask, getLatestPairedTaskForChat, getLatestOpenPairedTaskForChat, - getPairedExecutionById, getPairedTaskById, getPairedWorkspace, - listPairedArtifactsForTask, - listPairedEventsForTask, - listPairedExecutionsForTask, - updatePairedExecution, updatePairedTask, upsertPairedProject, } from './db.js'; import { logger } from './logger.js'; import { - PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE, - hasReviewableOwnerWorkspaceChanges, markPairedTaskReviewReady, prepareReviewerWorkspaceForExecution, provisionOwnerWorkspaceForPairedTask, - resolvePairedTaskSourceFingerprint, } from './paired-workspace-manager.js'; import type { - PairedArtifactType, - PairedEventType, - PairedExecution, - PairedGateTurnKind, - PairedReviewerVerdict, PairedTask, PairedWorkspace, RegisteredGroup, RoomRoleContext, } from './types.js'; -const APPROVED_REVIEWER_GATE_VERDICTS = new Set([ - 'done', - 'done_with_concerns', -]); -const VISIBLE_REVIEWER_GATE_VERDICTS = new Set([ - 'done', - 'done_with_concerns', - 'blocked', -]); -const REVIEWER_GATE_REQUIRED_MESSAGE = - 'A visible reviewer verdict is required before the owner can proceed with this gate.'; -const DEPLOY_COMPLETE_OWNER_ONLY_MESSAGE = - 'Deployment finalization must be handled by the owner service.'; -const DEPLOY_COMPLETE_PRECONDITION_MESSAGE = - 'Deploy completion requires a merge-ready task or the same already-finalized checkpoint.'; -const DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE = - 'Deploy completion requires a canonical workDir with a readable HEAD.'; - -function getGateTurnKind(task: PairedTask): PairedGateTurnKind | null { - return task.gate_turn_kind ?? null; -} - -function hasVisibleReviewerGateVerdict( - verdict: PairedReviewerVerdict | null | undefined, -): boolean { - return verdict ? VISIBLE_REVIEWER_GATE_VERDICTS.has(verdict) : false; -} - -function allowsOwnerToProceedForGate( - verdict: PairedReviewerVerdict | null | undefined, -): boolean { - return verdict ? APPROVED_REVIEWER_GATE_VERDICTS.has(verdict) : false; -} - -function formatOwnerGateBlockedMessage(task: PairedTask): string { - return [ - REVIEWER_GATE_REQUIRED_MESSAGE, - `- Task: ${task.id}`, - `- Gate: ${task.gate_turn_kind ?? 'implementation_start'}`, - `- Reviewer verdict: ${task.reviewer_verdict ?? 'missing'}`, - ].join('\n'); -} +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- function resolveCanonicalSourceRef(workDir: string): string { try { @@ -123,13 +67,16 @@ function ensurePairedProject( 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; } +// --------------------------------------------------------------------------- +// ensureActiveTask +// --------------------------------------------------------------------------- + function ensureActiveTask( group: RegisteredGroup, chatJid: string, @@ -154,9 +101,7 @@ function ensureActiveTask( reviewer_service_id: roomRoleContext.reviewerServiceId, title: null, source_ref: resolveCanonicalSourceRef(canonicalWorkDir), - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, last_finalized_checkpoint: null, gate_turn_kind: null, @@ -180,146 +125,12 @@ function ensureActiveTask( return task; } -function ensureExecutionRecord(args: { - runId: string; - roomRoleContext: RoomRoleContext; - task: PairedTask; - workspace?: PairedWorkspace; - checkpointFingerprint?: string | null; -}): PairedExecution { - const executionId = `${args.runId}:${args.roomRoleContext.serviceId}`; - const existing = getPairedExecutionById(executionId); - const now = new Date().toISOString(); - - cancelSupersededPairedExecutions({ - taskId: args.task.id, - role: args.roomRoleContext.role, - exceptExecutionId: executionId, - note: 'Superseded by a newer execution for the same task and role.', - }); - - if (existing) { - updatePairedExecution(existing.id, { - workspace_id: args.workspace?.id ?? existing.workspace_id, - checkpoint_fingerprint: - args.checkpointFingerprint ?? existing.checkpoint_fingerprint ?? null, - status: 'running', - started_at: existing.started_at ?? now, - }); - return { - ...existing, - workspace_id: args.workspace?.id ?? existing.workspace_id, - checkpoint_fingerprint: - args.checkpointFingerprint ?? existing.checkpoint_fingerprint ?? null, - 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, - checkpoint_fingerprint: args.checkpointFingerprint ?? null, - status: 'running', - summary: null, - created_at: now, - started_at: now, - completed_at: null, - }; - createPairedExecution(execution); - return execution; -} - -function getLatestReviewCheckpointFingerprint(taskId: string): string | null { - const events = listPairedEventsForTask(taskId); - for (let index = events.length - 1; index >= 0; index -= 1) { - const event = events[index]; - if ( - event?.event_type === 'request_review' && - event.source_fingerprint?.trim() - ) { - return event.source_fingerprint.trim(); - } - } - return getPairedWorkspace(taskId, 'reviewer')?.snapshot_source_fingerprint ?? null; -} - -function resolveExecutionCheckpointFingerprint(args: { - taskId: string; - role: RoomRoleContext['role']; - workspace: PairedWorkspace | null; -}): string | null { - if (args.role === 'reviewer') { - return ( - args.workspace?.snapshot_source_fingerprint ?? - getLatestReviewCheckpointFingerprint(args.taskId) - ); - } - return resolvePairedTaskSourceFingerprint(args.taskId); -} - -function isExecutionFreshForTaskStateWrite(args: { - execution: PairedExecution; -}): boolean { - if (args.execution.status === 'cancelled') { - return false; - } - - if (args.execution.role === 'owner') { - const executionOrderKey = [ - args.execution.started_at ?? args.execution.created_at, - args.execution.created_at, - args.execution.id, - ] as const; - const hasNewerOwnerExecution = listPairedExecutionsForTask( - args.execution.task_id, - ).some((candidate) => { - if (candidate.role !== 'owner' || candidate.id === args.execution.id) { - return false; - } - const candidateOrderKey = [ - candidate.started_at ?? candidate.created_at, - candidate.created_at, - candidate.id, - ] as const; - if (candidateOrderKey[0] > executionOrderKey[0]) { - return true; - } - if (candidateOrderKey[0] < executionOrderKey[0]) { - return false; - } - if (candidateOrderKey[1] > executionOrderKey[1]) { - return true; - } - if (candidateOrderKey[1] < executionOrderKey[1]) { - return false; - } - return candidateOrderKey[2] > executionOrderKey[2]; - }); - return !hasNewerOwnerExecution; - } - - const executionCheckpoint = args.execution.checkpoint_fingerprint ?? null; - if (!executionCheckpoint) { - return true; - } - - const latestCheckpoint = getLatestReviewCheckpointFingerprint( - args.execution.task_id, - ); - if (!latestCheckpoint) { - return true; - } - - return executionCheckpoint === latestCheckpoint; -} +// --------------------------------------------------------------------------- +// preparePairedExecutionContext +// --------------------------------------------------------------------------- export interface PreparedPairedExecutionContext { task: PairedTask; - execution: PairedExecution; workspace: PairedWorkspace | null; envOverrides: Record; gateTurnKind?: PairedGateTurnKind | null; @@ -341,7 +152,7 @@ export function preparePairedExecutionContext(args: { runId: string; roomRoleContext?: RoomRoleContext; }): PreparedPairedExecutionContext | undefined { - const { group, chatJid, runId, roomRoleContext } = args; + const { group, chatJid, roomRoleContext } = args; if (!roomRoleContext || !group.workDir) { return undefined; } @@ -382,20 +193,8 @@ export function preparePairedExecutionContext(args: { } } - const execution = ensureExecutionRecord({ - runId, - roomRoleContext, - task: latestTask, - workspace: workspace ?? undefined, - checkpointFingerprint: resolveExecutionCheckpointFingerprint({ - taskId: latestTask.id, - role: roomRoleContext.role, - workspace, - }), - }); const envOverrides: Record = { - EJCLAW_PAIRED_TASK_ID: latestTask.id, - EJCLAW_PAIRED_EXECUTION_ID: execution.id, + EJCLAW_PAIRED_TASK_ID: task.id, EJCLAW_PAIRED_ROLE: roomRoleContext.role, }; @@ -410,8 +209,7 @@ export function preparePairedExecutionContext(args: { } return { - task: getPairedTaskById(latestTask.id) ?? latestTask, - execution, + task: getPairedTaskById(task.id) ?? task, workspace, envOverrides, gateTurnKind, @@ -420,6 +218,29 @@ export function preparePairedExecutionContext(args: { }; } +// --------------------------------------------------------------------------- +// completePairedExecutionContext +// --------------------------------------------------------------------------- + +export function completePairedExecutionContext(args: { + taskId: string; + status: 'succeeded' | 'failed'; + summary?: string | null; +}): void { + logger.info( + { + taskId: args.taskId, + status: args.status, + summary: args.summary?.slice(0, 200), + }, + 'Paired execution completed', + ); +} + +// --------------------------------------------------------------------------- +// markRoomReviewReady +// --------------------------------------------------------------------------- + export type MarkRoomReviewReadyResult = | { status: 'ready'; @@ -432,456 +253,14 @@ export type MarkRoomReviewReadyResult = task: PairedTask; pendingReason: 'owner-workspace-not-ready'; } - | { - status: 'blocked'; - task: PairedTask; - blockedReason: 'plan-review-required'; - } | null; -const PLAN_RECORD_OWNER_ONLY_MESSAGE = - 'Plan recording must be handled by the owner service.'; -const RISK_UPDATE_OWNER_ONLY_MESSAGE = - 'Risk updates must be handled by the owner service.'; -const PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE = - 'Plan approval must be handled by the reviewer service.'; -const PLAN_REQUIRES_HIGH_RISK_MESSAGE = - 'Plan review commands are only required for high-risk tasks.'; -const PLAN_INCOMPLETE_MESSAGE = - 'Plan artifacts are incomplete. Record /plan || || before approval.'; -const AUTO_REQUEST_REVIEW_DEDUPE_PREFIX = 'auto-request-review'; - -function normalizeIntentDedupeKey(dedupeKey?: string): string { - return dedupeKey?.trim() || crypto.randomUUID(); -} - -function serializeIntentPayload( - payload?: Record, -): string | null { - if (!payload || Object.keys(payload).length === 0) { - return null; - } - return JSON.stringify(payload); -} - -function applyRoomIntent(args: { - task: PairedTask; - roomRoleContext: RoomRoleContext; - eventType: PairedEventType; - dedupeKey?: string; - payload?: Record; - onApply?: () => T; -}): { - applied: boolean; - result: T | null; -} { - const createdAt = new Date().toISOString(); - const sourceFingerprint = resolvePairedTaskSourceFingerprint(args.task.id); - const result = applyPairedEvent({ - event: { - task_id: args.task.id, - event_type: args.eventType, - actor_role: args.roomRoleContext.role, - source_service_id: args.roomRoleContext.serviceId, - source_fingerprint: sourceFingerprint, - dedupe_key: normalizeIntentDedupeKey(args.dedupeKey), - payload_json: serializeIntentPayload(args.payload), - created_at: createdAt, - }, - onApply: args.onApply, - }); - return { - applied: result.applied, - result: result.result, - }; -} - -function isPlanReviewRequired(task: PairedTask): boolean { - return task.risk_level === 'high' && task.plan_status !== 'approved'; -} - -function getLatestArtifactContent( - taskId: string, - artifactType: PairedArtifactType, -): string | null { - const artifacts = listPairedArtifactsForTask(taskId).filter( - (artifact) => artifact.artifact_type === artifactType, - ); - return artifacts.at(-1)?.content?.trim() || null; -} - -function hasCompletePlanArtifacts(taskId: string): boolean { - return ( - !!getLatestArtifactContent(taskId, 'plan_brief') && - !!getLatestArtifactContent(taskId, 'acceptance_criteria') && - !!getLatestArtifactContent(taskId, 'risk_summary') - ); -} - -function createTaskArtifact(args: { - taskId: string; - serviceId: string; - artifactType: PairedArtifactType; - content: string; -}): void { - createPairedArtifact({ - task_id: args.taskId, - execution_id: null, - service_id: args.serviceId, - artifact_type: args.artifactType, - title: null, - content: args.content, - file_path: null, - created_at: new Date().toISOString(), - }); -} - -function getPersistedReviewReadyState(taskId: string): { - ownerWorkspace: PairedWorkspace | null; - reviewerWorkspace: PairedWorkspace | null; -} { - return { - ownerWorkspace: getPairedWorkspace(taskId, 'owner') ?? null, - reviewerWorkspace: getPairedWorkspace(taskId, 'reviewer') ?? null, - }; -} - -function formatPlanReviewRequiredMessage(task: PairedTask): string { - return [ - PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE, - `- Task: ${task.id}`, - `- Plan status: ${task.plan_status}`, - 'Ask the owner to record a plan and have the reviewer approve it before /review.', - ].join('\n'); -} - -function hasRequestReviewForFingerprint( - taskId: string, - sourceFingerprint: string, -): boolean { - return listPairedEventsForTask(taskId).some( - (event) => - event.event_type === 'request_review' && - event.source_fingerprint === sourceFingerprint, - ); -} - -function buildRoomRoleContextFromExecution(args: { - task: PairedTask; - execution: PairedExecution; -}): RoomRoleContext { - return { - serviceId: args.execution.service_id, - role: args.execution.role, - ownerServiceId: args.task.owner_service_id, - reviewerServiceId: args.task.reviewer_service_id, - failoverOwner: false, - }; -} - -function getLatestExecutionForRole( - taskId: string, - role: PairedExecution['role'], -): PairedExecution | null { - const executions = listPairedExecutionsForTask(taskId).filter( - (execution) => execution.role === role, - ); - return executions.at(-1) ?? null; -} - -function buildPairedExecutionRecoveryPrompt(args: { - task: PairedTask; - role: RoomRoleContext['role']; - checkpointFingerprint: string | null; - mode: 'review' | 'owner-rework' | 'owner-resume'; -}): string { - const lines = [ - 'Internal paired execution recovery.', - '- This run was started automatically after service restart.', - `- Task: ${args.task.id}`, - `- Role: ${args.role}`, - `- Status: ${args.task.status}`, - ]; - if (args.checkpointFingerprint) { - lines.push(`- Checkpoint: ${args.checkpointFingerprint}`); - } - - if (args.mode === 'review') { - lines.push( - 'Resume the formal paired review for the current checkpoint without waiting for a new user message.', - ); - } else if (args.mode === 'owner-rework') { - lines.push( - 'Resume the owner rework for the current task state without waiting for a new user message.', - ); - } else { - lines.push( - 'Resume the interrupted owner implementation for the current task without waiting for a new user message.', - ); - } - - lines.push('- Keep the current task scope unchanged.'); - lines.push('- Follow the existing paired task guards and checkpoint rules.'); - return lines.join('\n'); -} - -export function planPairedExecutionRecovery(args: { - group: RegisteredGroup; - chatJid: string; - roomRoleContext?: RoomRoleContext; -}): PairedExecutionRecoveryPlan | null { - const { group, chatJid, roomRoleContext } = args; - if (!roomRoleContext || !group.workDir) { - return null; - } - - const task = getLatestOpenPairedTaskForChat(chatJid); - if (!task) { - return null; - } - - if (roomRoleContext.role === 'reviewer') { - if ( - task.status !== 'review_pending' && - task.status !== 'review_ready' && - task.status !== 'in_review' - ) { - return null; - } - - const checkpointFingerprint = getLatestReviewCheckpointFingerprint(task.id); - if (!checkpointFingerprint) { - return null; - } - - return { - task, - role: 'reviewer', - checkpointFingerprint, - recoveryKey: `paired-recovery:${task.id}:reviewer:${checkpointFingerprint}`, - prompt: buildPairedExecutionRecoveryPrompt({ - task, - role: 'reviewer', - checkpointFingerprint, - mode: 'review', - }), - }; - } - - if (task.status === 'changes_requested') { - const checkpointFingerprint = - resolvePairedTaskSourceFingerprint(task.id) ?? null; - return { - task, - role: 'owner', - checkpointFingerprint, - recoveryKey: `paired-recovery:${task.id}:owner:changes_requested:${ - checkpointFingerprint ?? 'none' - }`, - prompt: buildPairedExecutionRecoveryPrompt({ - task, - role: 'owner', - checkpointFingerprint, - mode: 'owner-rework', - }), - }; - } - - if (task.status !== 'active') { - return null; - } - - const latestOwnerExecution = getLatestExecutionForRole(task.id, 'owner'); - if (latestOwnerExecution?.status !== 'running') { - return null; - } - - const checkpointFingerprint = - resolvePairedTaskSourceFingerprint(task.id) ?? - latestOwnerExecution.checkpoint_fingerprint ?? - null; - return { - task, - role: 'owner', - checkpointFingerprint, - recoveryKey: `paired-recovery:${task.id}:owner:active:${ - checkpointFingerprint ?? 'none' - }`, - prompt: buildPairedExecutionRecoveryPrompt({ - task, - role: 'owner', - checkpointFingerprint, - mode: 'owner-resume', - }), - }; -} - -function markTaskReviewPendingWithoutSnapshot(taskId: string): void { - const requestedAt = new Date().toISOString(); - updatePairedTask(taskId, { - status: 'review_pending', - review_requested_at: requestedAt, - updated_at: requestedAt, - }); -} - -function maybeAutoRequestReviewForCompletedExecution(args: { - execution: PairedExecution; - status: 'succeeded' | 'failed'; -}): void { - if (args.status !== 'succeeded') { - return; - } - - if (args.execution.role !== 'owner') { - return; - } - - const task = getPairedTaskById(args.execution.task_id); - if (!task) { - return; - } - - if (isPlanReviewRequired(task)) { - return; - } - - const shouldOpenReview = - task.status === 'active' || task.status === 'changes_requested'; - const shouldRecordUpdatedCheckpoint = - task.status === 'review_pending' || - task.status === 'review_ready' || - task.status === 'in_review'; - - if (!shouldOpenReview && !shouldRecordUpdatedCheckpoint) { - return; - } - - const ownerWorkspace = getPairedWorkspace(task.id, 'owner'); - if (!ownerWorkspace) { - return; - } - - const sourceFingerprint = resolvePairedTaskSourceFingerprint(task.id); - if (!sourceFingerprint) { - return; - } - - if (!hasReviewableOwnerWorkspaceChanges(task.id)) { - return; - } - - if (hasRequestReviewForFingerprint(task.id, sourceFingerprint)) { - return; - } - - const roomRoleContext = buildRoomRoleContextFromExecution({ - task, - execution: args.execution, - }); - const reviewIntent = applyRoomIntent({ - task, - roomRoleContext, - eventType: 'request_review', - dedupeKey: `${AUTO_REQUEST_REVIEW_DEDUPE_PREFIX}:${sourceFingerprint}`, - onApply: () => - shouldOpenReview - ? markPairedTaskReviewReady(task.id) - : markTaskReviewPendingWithoutSnapshot(task.id), - }); - - if (reviewIntent.applied) { - cancelSupersededPairedExecutions({ - taskId: task.id, - role: 'reviewer', - note: 'Superseded by a newer review checkpoint.', - }); - logger.info( - { - taskId: task.id, - executionId: args.execution.id, - sourceFingerprint, - }, - 'Automatically requested review for low-risk owner checkpoint', - ); - } -} - -export function completePairedExecutionContext(args: { - executionId: string; - status: 'succeeded' | 'failed'; - summary?: string | null; - reviewerVerdict?: PairedReviewerVerdict | null; - reviewerVerdictNote?: string | null; -}): void { - const execution = getPairedExecutionById(args.executionId); - if (!execution) { - return; - } - - const completedAt = new Date().toISOString(); - const preservedStatus = - execution.status === 'cancelled' ? execution.status : args.status; - - updatePairedExecution(args.executionId, { - status: preservedStatus, - summary: args.summary ?? null, - completed_at: completedAt, - }); - - const completedExecution: PairedExecution = { - ...execution, - status: preservedStatus, - summary: args.summary ?? null, - completed_at: completedAt, - }; - - if (!isExecutionFreshForTaskStateWrite({ execution: completedExecution })) { - logger.info( - { - taskId: completedExecution.task_id, - executionId: completedExecution.id, - role: completedExecution.role, - checkpointFingerprint: completedExecution.checkpoint_fingerprint, - }, - 'Skipped task state write from stale paired execution', - ); - return; - } - - const task = getPairedTaskById(completedExecution.task_id); - if ( - task && - completedExecution.role === 'reviewer' && - task.gate_turn_kind && - args.reviewerVerdict - ) { - const shouldPromoteToMergeReady = - task.gate_turn_kind === 'push' && - APPROVED_REVIEWER_GATE_VERDICTS.has(args.reviewerVerdict); - updatePairedTask(task.id, { - gate_turn_kind: shouldPromoteToMergeReady ? null : task.gate_turn_kind, - reviewer_verdict: args.reviewerVerdict, - reviewer_verdict_at: completedAt, - reviewer_verdict_note: args.reviewerVerdictNote ?? null, - status: shouldPromoteToMergeReady ? 'merge_ready' : task.status, - updated_at: completedAt, - }); - } - - maybeAutoRequestReviewForCompletedExecution({ - execution: completedExecution, - status: args.status, - }); -} - export function markRoomReviewReady(args: { group: RegisteredGroup; chatJid: string; roomRoleContext?: RoomRoleContext; - dedupeKey?: string; }): MarkRoomReviewReadyResult { - const { group, chatJid, roomRoleContext, dedupeKey } = args; + const { group, chatJid, roomRoleContext } = args; if (!roomRoleContext || !group.workDir) { return null; } @@ -891,14 +270,6 @@ export function markRoomReviewReady(args: { return null; } - if (isPlanReviewRequired(task)) { - return { - status: 'blocked', - task, - blockedReason: 'plan-review-required', - }; - } - const isOwnerService = normalizeServiceId(SERVICE_ID) === task.owner_service_id; const ownerWorkspace = @@ -915,52 +286,41 @@ export function markRoomReviewReady(args: { }; } - const reviewIntent = applyRoomIntent({ - task, - roomRoleContext, - eventType: 'request_review', - dedupeKey, - onApply: () => markPairedTaskReviewReady(task.id), - }); - - if (reviewIntent.applied) { - cancelSupersededPairedExecutions({ - taskId: task.id, - role: 'reviewer', - note: 'Superseded by a newer review checkpoint.', - }); - } - + // Update task status and refresh reviewer snapshot + const reviewResult = markPairedTaskReviewReady(task.id); const latestTask = getPairedTaskById(task.id) ?? task; - const { ownerWorkspace: persistedOwnerWorkspace, reviewerWorkspace } = - getPersistedReviewReadyState( - task.id, - ); - if (reviewIntent.result) { + if (reviewResult) { return { status: 'ready', task: latestTask, - ownerWorkspace: reviewIntent.result.ownerWorkspace, - reviewerWorkspace: reviewIntent.result.reviewerWorkspace, + ownerWorkspace: reviewResult.ownerWorkspace, + reviewerWorkspace: reviewResult.reviewerWorkspace, }; } - if (!ownerWorkspace || !reviewerWorkspace) { + // Snapshot refresh succeeded but result was null — try reading persisted state + const reviewerWorkspace = getPairedWorkspace(task.id, 'reviewer'); + if (!reviewerWorkspace) { return { status: 'pending', task: latestTask, pendingReason: 'owner-workspace-not-ready', }; } + return { status: 'ready', task: latestTask, - ownerWorkspace: persistedOwnerWorkspace ?? ownerWorkspace, + ownerWorkspace, reviewerWorkspace, }; } +// --------------------------------------------------------------------------- +// formatRoomReviewReadyMessage +// --------------------------------------------------------------------------- + export function formatRoomReviewReadyMessage( result: MarkRoomReviewReadyResult, ): string | null { @@ -968,10 +328,6 @@ export function formatRoomReviewReadyMessage( return null; } - if (result.status === 'blocked') { - return formatPlanReviewRequiredMessage(result.task); - } - if (result.status === 'pending') { return [ 'Review request recorded, but the owner workspace is not ready yet.', @@ -987,312 +343,3 @@ export function formatRoomReviewReadyMessage( `- Reviewer snapshot: ${result.reviewerWorkspace.workspace_dir}`, ].join('\n'); } - -export function setRoomTaskRiskLevel(args: { - group: RegisteredGroup; - chatJid: string; - roomRoleContext?: RoomRoleContext; - riskLevel: 'low' | 'high'; - dedupeKey?: string; -}): string | null { - const { group, chatJid, roomRoleContext, riskLevel, dedupeKey } = args; - if (!roomRoleContext || !group.workDir) { - return null; - } - if (roomRoleContext.role !== 'owner') { - return RISK_UPDATE_OWNER_ONLY_MESSAGE; - } - - const task = ensureActiveTask(group, chatJid, roomRoleContext); - if (!task) { - return null; - } - - applyRoomIntent({ - task, - roomRoleContext, - eventType: 'set_risk', - dedupeKey, - payload: { riskLevel }, - onApply: () => { - const now = new Date().toISOString(); - if (riskLevel === 'high') { - updatePairedTask(task.id, { - risk_level: 'high', - plan_status: hasCompletePlanArtifacts(task.id) - ? 'pending' - : 'not_requested', - status: 'plan_review_pending', - updated_at: now, - }); - return; - } - - updatePairedTask(task.id, { - risk_level: 'low', - status: task.status === 'plan_review_pending' ? 'active' : task.status, - updated_at: now, - }); - }, - }); - - const latestTask = getPairedTaskById(task.id) ?? task; - return [ - 'Task risk updated.', - `- Task: ${latestTask.id}`, - `- Risk: ${latestTask.risk_level}`, - `- Status: ${latestTask.status}`, - ].join('\n'); -} - -export function recordRoomPlan(args: { - group: RegisteredGroup; - chatJid: string; - roomRoleContext?: RoomRoleContext; - planBrief: string; - acceptanceCriteria: string; - riskSummary: string; - dedupeKey?: string; -}): string | null { - const { - group, - chatJid, - roomRoleContext, - planBrief, - acceptanceCriteria, - riskSummary, - dedupeKey, - } = args; - if (!roomRoleContext || !group.workDir) { - return null; - } - if (roomRoleContext.role !== 'owner') { - return PLAN_RECORD_OWNER_ONLY_MESSAGE; - } - - const task = ensureActiveTask(group, chatJid, roomRoleContext); - if (!task) { - return null; - } - if (task.risk_level !== 'high') { - return PLAN_REQUIRES_HIGH_RISK_MESSAGE; - } - - applyRoomIntent({ - task, - roomRoleContext, - eventType: 'submit_plan', - dedupeKey, - payload: { - planBrief, - acceptanceCriteria, - riskSummary, - }, - onApply: () => { - createTaskArtifact({ - taskId: task.id, - serviceId: roomRoleContext.serviceId, - artifactType: 'plan_brief', - content: planBrief, - }); - createTaskArtifact({ - taskId: task.id, - serviceId: roomRoleContext.serviceId, - artifactType: 'acceptance_criteria', - content: acceptanceCriteria, - }); - createTaskArtifact({ - taskId: task.id, - serviceId: roomRoleContext.serviceId, - artifactType: 'risk_summary', - content: riskSummary, - }); - - updatePairedTask(task.id, { - plan_status: 'pending', - status: 'plan_review_pending', - updated_at: new Date().toISOString(), - }); - }, - }); - - const latestTask = getPairedTaskById(task.id) ?? task; - return [ - 'Plan recorded.', - `- Task: ${latestTask.id}`, - `- Plan status: ${latestTask.plan_status}`, - `- Status: ${latestTask.status}`, - ].join('\n'); -} - -export function approveRoomPlan(args: { - group: RegisteredGroup; - chatJid: string; - roomRoleContext?: RoomRoleContext; - dedupeKey?: string; -}): string | null { - const { group, chatJid, roomRoleContext, dedupeKey } = args; - if (!roomRoleContext || !group.workDir) { - return null; - } - if (roomRoleContext.role !== 'reviewer') { - return PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE; - } - - const task = ensureActiveTask(group, chatJid, roomRoleContext); - if (!task) { - return null; - } - if (task.risk_level !== 'high') { - return PLAN_REQUIRES_HIGH_RISK_MESSAGE; - } - if (!hasCompletePlanArtifacts(task.id)) { - return PLAN_INCOMPLETE_MESSAGE; - } - - applyRoomIntent({ - task, - roomRoleContext, - eventType: 'approve_plan', - dedupeKey, - onApply: () => { - updatePairedTask(task.id, { - plan_status: 'approved', - status: 'active', - updated_at: new Date().toISOString(), - }); - }, - }); - - const latestTask = getPairedTaskById(task.id) ?? task; - return [ - 'Plan approved.', - `- Task: ${latestTask.id}`, - `- Status: ${latestTask.status}`, - ].join('\n'); -} - -export function requestRoomPlanChanges(args: { - group: RegisteredGroup; - chatJid: string; - roomRoleContext?: RoomRoleContext; - note?: string; - dedupeKey?: string; -}): string | null { - const { group, chatJid, roomRoleContext, note, dedupeKey } = args; - if (!roomRoleContext || !group.workDir) { - return null; - } - if (roomRoleContext.role !== 'reviewer') { - return PLAN_APPROVAL_REVIEWER_ONLY_MESSAGE; - } - - const task = ensureActiveTask(group, chatJid, roomRoleContext); - if (!task) { - return null; - } - if (task.risk_level !== 'high') { - return PLAN_REQUIRES_HIGH_RISK_MESSAGE; - } - - applyRoomIntent({ - task, - roomRoleContext, - eventType: 'request_plan_changes', - dedupeKey, - payload: note?.trim() ? { note: note.trim() } : undefined, - onApply: () => { - if (note?.trim()) { - createTaskArtifact({ - taskId: task.id, - serviceId: roomRoleContext.serviceId, - artifactType: 'comment', - content: note.trim(), - }); - } - - updatePairedTask(task.id, { - plan_status: 'changes_requested', - status: 'plan_review_pending', - updated_at: new Date().toISOString(), - }); - }, - }); - - const latestTask = getPairedTaskById(task.id) ?? task; - return [ - 'Plan changes requested.', - `- Task: ${latestTask.id}`, - `- Plan status: ${latestTask.plan_status}`, - `- Status: ${latestTask.status}`, - ].join('\n'); -} - -export function finalizeRoomDeployment(args: { - group: RegisteredGroup; - chatJid: string; - roomRoleContext?: RoomRoleContext; -}): string | null { - const { group, chatJid, roomRoleContext } = args; - if (!roomRoleContext) { - return null; - } - if (roomRoleContext.role !== 'owner') { - return DEPLOY_COMPLETE_OWNER_ONLY_MESSAGE; - } - if (!group.workDir) { - return DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE; - } - - const deployedCheckpoint = resolveCanonicalHead(group.workDir); - if (!deployedCheckpoint) { - return DEPLOY_COMPLETE_HEAD_REQUIRED_MESSAGE; - } - - const task = getLatestPairedTaskForChat(chatJid); - if (!task) { - return null; - } - - const alreadyFinalized = - task.status === 'merged' && - task.last_finalized_checkpoint === deployedCheckpoint; - if (task.status !== 'merge_ready' && !alreadyFinalized) { - return [ - DEPLOY_COMPLETE_PRECONDITION_MESSAGE, - `- Task: ${task.id}`, - `- Status: ${task.status}`, - `- Checkpoint: ${deployedCheckpoint}`, - ].join('\n'); - } - - const finalizedAt = new Date().toISOString(); - applyPairedEvent({ - event: { - task_id: task.id, - event_type: 'deploy_complete', - actor_role: roomRoleContext.role, - source_service_id: roomRoleContext.serviceId, - source_fingerprint: deployedCheckpoint, - dedupe_key: `deploy-complete:${deployedCheckpoint}`, - payload_json: serializeIntentPayload({ checkpoint: deployedCheckpoint }), - created_at: finalizedAt, - }, - onApply: () => { - updatePairedTask(task.id, { - status: 'merged', - last_finalized_checkpoint: deployedCheckpoint, - gate_turn_kind: null, - updated_at: finalizedAt, - }); - }, - }); - - const latestTask = getPairedTaskById(task.id) ?? task; - return [ - 'Deployment finalized.', - `- Task: ${latestTask.id}`, - `- Status: ${latestTask.status}`, - `- Checkpoint: ${deployedCheckpoint}`, - ].join('\n'); -} diff --git a/src/paired-review-command.test.ts b/src/paired-review-command.test.ts index 9bbfef6..4eed8a9 100644 --- a/src/paired-review-command.test.ts +++ b/src/paired-review-command.test.ts @@ -95,24 +95,13 @@ describe('paired /review command path', () => { group, chatJid: 'dc:test', roomRoleContext: ownerContext, - dedupeKey: 'msg-review-owner-1', }); - const task = db.getLatestOpenPairedTaskForChat('dc:test'); - const events = db.listPairedEventsForTask(task!.id); - expect(result?.status).toBe('ready'); - expect(events).toHaveLength(1); - expect(events[0]?.event_type).toBe('request_review'); - expect(events[0]?.dedupe_key).toBe('msg-review-owner-1'); - expect(events[0]?.source_fingerprint).toBeTruthy(); - expect(events[0]?.source_fingerprint).not.toBe(task?.source_ref); if (!result || result.status !== 'ready') { throw new Error('expected owner /review to prepare a ready snapshot'); } - expect(events[0]?.source_fingerprint).toBe( - result.reviewerWorkspace.snapshot_source_fingerprint, - ); + expect(result.reviewerWorkspace.snapshot_ref).toBeTruthy(); }); it('reuses the db owner workspace when reviewer service handles /review', async () => { @@ -240,134 +229,12 @@ describe('paired /review command path', () => { status: 'pending', task: expect.objectContaining({ id: task!.id, - status: 'review_pending', + status: 'review_ready', }), pendingReason: 'owner-workspace-not-ready', }); - expect(task?.status).toBe('review_pending'); + expect(task?.status).toBe('review_ready'); expect(task?.review_requested_at).toBeTruthy(); - expect(db.listPairedEventsForTask(task!.id)).toEqual([]); expect(fs.existsSync(reviewerLocalOwnerDir)).toBe(false); }); - - it('creates the first checkpointed request_review event only after owner workspace exists', async () => { - const canonicalDir = path.join(tempRoot, 'canonical'); - fs.mkdirSync(canonicalDir, { recursive: true }); - runGit(['init'], canonicalDir); - runGit(['config', 'user.email', 'test@example.com'], canonicalDir); - runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir); - fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n'); - runGit(['add', 'README.md'], canonicalDir); - runGit(['commit', '-m', 'initial'], canonicalDir); - - const group: RegisteredGroup = { - name: 'Paired Room', - folder: 'paired-room', - trigger: '@codex', - added_at: '2026-03-28T00:00:00.000Z', - agentType: 'codex', - workDir: canonicalDir, - }; - - process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-review'); - process.env.SERVICE_ID = 'codex-review'; - vi.resetModules(); - let { db, executionContext } = await loadModules(); - db.initDatabase(); - - const firstResult = executionContext.markRoomReviewReady({ - group, - chatJid: 'dc:test', - roomRoleContext: reviewerContext, - dedupeKey: 'msg-review-pending-1', - }); - - const task = db.getLatestOpenPairedTaskForChat('dc:test'); - expect(firstResult?.status).toBe('pending'); - expect(db.listPairedEventsForTask(task!.id)).toEqual([]); - - process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner'); - process.env.SERVICE_ID = 'codex-main'; - vi.resetModules(); - ({ db, executionContext } = await loadModules()); - db.initDatabase(); - - executionContext.preparePairedExecutionContext({ - group, - chatJid: 'dc:test', - runId: 'run-owner', - roomRoleContext: ownerContext, - }); - - const secondResult = executionContext.markRoomReviewReady({ - group, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - dedupeKey: 'msg-review-owner-2', - }); - - const events = db.listPairedEventsForTask(task!.id); - expect(secondResult?.status).toBe('ready'); - expect(events).toHaveLength(1); - expect(events[0]?.event_type).toBe('request_review'); - expect(events[0]?.dedupe_key).toBe('msg-review-owner-2'); - }); - - it('blocks /review when a high-risk task plan is not approved yet', async () => { - const canonicalDir = path.join(tempRoot, 'canonical'); - fs.mkdirSync(canonicalDir, { recursive: true }); - runGit(['init'], canonicalDir); - runGit(['config', 'user.email', 'test@example.com'], canonicalDir); - runGit(['config', 'user.name', 'EJClaw Test'], canonicalDir); - fs.writeFileSync(path.join(canonicalDir, 'README.md'), 'original\n'); - runGit(['add', 'README.md'], canonicalDir); - runGit(['commit', '-m', 'initial'], canonicalDir); - - const group: RegisteredGroup = { - name: 'Paired Room', - folder: 'paired-room', - trigger: '@codex', - added_at: '2026-03-28T00:00:00.000Z', - agentType: 'codex', - workDir: canonicalDir, - }; - - process.env.EJCLAW_DATA_DIR = path.join(tempRoot, 'data-owner'); - process.env.SERVICE_ID = 'codex-main'; - vi.resetModules(); - const { db, executionContext } = await loadModules(); - db.initDatabase(); - - const task = executionContext.preparePairedExecutionContext({ - group, - chatJid: 'dc:test', - runId: 'run-owner', - roomRoleContext: ownerContext, - })?.task; - - expect(task).toBeTruthy(); - db.updatePairedTask(task!.id, { - risk_level: 'high', - plan_status: 'pending', - status: 'plan_review_pending', - updated_at: '2026-03-29T00:00:00.000Z', - }); - - const result = executionContext.markRoomReviewReady({ - group, - chatJid: 'dc:test', - roomRoleContext: ownerContext, - }); - - expect(result).toEqual({ - status: 'blocked', - task: expect.objectContaining({ - id: task!.id, - risk_level: 'high', - plan_status: 'pending', - status: 'plan_review_pending', - }), - blockedReason: 'plan-review-required', - }); - }); }); diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index 7d5839d..0d3f772 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -63,8 +63,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -75,11 +74,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: 'review the owner changes', source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -133,7 +130,7 @@ describe('paired workspace manager', () => { ).toBeTruthy(); expect( db.getPairedWorkspace('paired-task-1', 'reviewer') - ?.snapshot_source_fingerprint, + ?.snapshot_ref, ).toBeTruthy(); }); @@ -155,8 +152,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -167,11 +163,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -210,8 +204,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -222,11 +215,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: 'cross service review', source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -288,8 +279,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -300,11 +290,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -365,8 +353,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -377,11 +364,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -508,8 +493,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -520,11 +504,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -572,8 +554,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -584,11 +565,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -630,8 +609,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -642,11 +620,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'pending', + plan_notes: null, review_requested_at: '2026-03-28T00:01:00.000Z', - status: 'review_pending', + status: 'review_ready', created_at: now, updated_at: '2026-03-28T00:01:00.000Z', }); @@ -694,8 +670,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -706,11 +681,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'low', - plan_status: 'not_requested', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -760,8 +733,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -772,11 +744,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'approved', + plan_notes: null, review_requested_at: null, - status: 'draft', + status: 'active', created_at: now, updated_at: now, }); @@ -851,8 +821,7 @@ describe('paired workspace manager', () => { chat_jid: 'dc:test', group_folder: 'paired-room', canonical_work_dir: canonicalDir, - workspace_topology: 'shadow-snapshot', - created_at: now, + created_at: now, updated_at: now, }); db.createPairedTask({ @@ -863,11 +832,9 @@ describe('paired workspace manager', () => { reviewer_service_id: 'codex-review', title: null, source_ref: 'HEAD', - task_policy: 'autonomous', - risk_level: 'high', - plan_status: 'pending', + plan_notes: null, review_requested_at: null, - status: 'plan_review_pending', + status: 'active', created_at: now, updated_at: now, }); diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index e4aeee9..c4f7a2a 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -18,9 +18,6 @@ const REVIEWER_SNAPSHOT_STALE_BLOCK_MESSAGE = 'Review snapshot is stale after owner changes. Retry the review once to refresh against the latest owner workspace.'; const REVIEWER_SNAPSHOT_NOT_READY_BLOCK_MESSAGE = 'Review snapshot is not ready yet. Ask the owner to run /review (or /review-ready) after preparing changes.'; -export const PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE = - 'Plan review is required before formal review for this high-risk task.'; - const REVIEWER_SNAPSHOT_DENY_SEGMENTS = new Set([ '.git', '.claude', @@ -325,9 +322,9 @@ function makeWorkspaceRecord(args: { workspace_dir: args.workspaceDir, snapshot_source_dir: args.snapshotSourceDir ?? existing?.snapshot_source_dir ?? null, - snapshot_source_fingerprint: + snapshot_ref: args.snapshotSourceFingerprint ?? - existing?.snapshot_source_fingerprint ?? + existing?.snapshot_ref ?? null, status: args.status ?? 'ready', snapshot_refreshed_at: @@ -489,7 +486,6 @@ export function markPairedTaskReviewReady(taskId: string): { } | null { const requestedAt = new Date().toISOString(); updatePairedTask(taskId, { - status: 'review_pending', review_requested_at: requestedAt, updated_at: requestedAt, }); @@ -518,14 +514,6 @@ export interface PreparedReviewerWorkspace { export function prepareReviewerWorkspaceForExecution( task: PairedTask, ): PreparedReviewerWorkspace { - if (task.risk_level === 'high' && task.plan_status !== 'approved') { - return { - workspace: null, - autoRefreshed: false, - blockMessage: PLAN_REVIEW_REQUIRED_BLOCK_MESSAGE, - }; - } - const ownerWorkspace = getPairedWorkspace(task.id, 'owner') ?? null; if (!ownerWorkspace) { return { @@ -555,11 +543,11 @@ export function prepareReviewerWorkspaceForExecution( const snapshotStale = !!reviewerWorkspace && (reviewerWorkspace.status === 'stale' || - reviewerWorkspace.snapshot_source_fingerprint !== currentFingerprint); + reviewerWorkspace.snapshot_ref !== currentFingerprint); const now = new Date().toISOString(); if (snapshotMissing || snapshotStale) { - if (task.status === 'review_pending' || task.status === 'review_ready') { + if (task.status === 'review_ready') { const refreshedWorkspace = refreshReviewerSnapshotForPairedTask(task.id); updatePairedTask(task.id, { status: 'review_ready', @@ -587,7 +575,7 @@ export function prepareReviewerWorkspaceForExecution( workspaceDir: reviewerWorkspace.workspace_dir, snapshotSourceDir: reviewerWorkspace.snapshot_source_dir, snapshotSourceFingerprint: - reviewerWorkspace.snapshot_source_fingerprint, + reviewerWorkspace.snapshot_ref, snapshotRefreshedAt: reviewerWorkspace.snapshot_refreshed_at, status: 'stale', createdAt: reviewerWorkspace.created_at, @@ -595,7 +583,6 @@ export function prepareReviewerWorkspaceForExecution( ); } updatePairedTask(task.id, { - status: 'review_pending', updated_at: now, }); return { diff --git a/src/session-commands.test.ts b/src/session-commands.test.ts index cbeea65..529a0fe 100644 --- a/src/session-commands.test.ts +++ b/src/session-commands.test.ts @@ -37,28 +37,6 @@ describe('extractSessionCommand', () => { expect(extractSessionCommand('/review-ready', trigger)).toBe('/review'); }); - it('detects /risk with an argument payload', () => { - expect(extractSessionCommand('/risk high', trigger)).toBe('/risk'); - }); - - it('detects /plan with structured payload', () => { - expect( - extractSessionCommand('/plan brief || criteria || risk', trigger), - ).toBe('/plan'); - }); - - it('detects /approve-plan', () => { - expect(extractSessionCommand('/approve-plan', trigger)).toBe( - '/approve-plan', - ); - }); - - it('detects /request-plan-changes with an optional note', () => { - expect( - extractSessionCommand('/request-plan-changes tighten scope', trigger), - ).toBe('/request-plan-changes'); - }); - it('rejects /compact with extra text', () => { expect(extractSessionCommand('/compact now please', trigger)).toBeNull(); }); @@ -198,11 +176,6 @@ function makeDeps( isAdminSender: vi.fn().mockReturnValue(false), canSenderInteract: vi.fn().mockReturnValue(true), markReviewReady: vi.fn().mockResolvedValue('Review snapshot updated.'), - finalizeDeployment: vi.fn().mockResolvedValue('Deployment finalized.'), - setTaskRiskLevel: vi.fn().mockResolvedValue('Task risk updated.'), - recordPlan: vi.fn().mockResolvedValue('Plan recorded.'), - approvePlan: vi.fn().mockResolvedValue('Plan approved.'), - requestPlanChanges: vi.fn().mockResolvedValue('Plan changes requested.'), ...overrides, }; } @@ -361,127 +334,6 @@ describe('handleSessionCommand', () => { ); }); - it('handles authorized /risk without invoking the agent', async () => { - const deps = makeDeps({ - setTaskRiskLevel: vi - .fn() - .mockResolvedValue('Task risk updated.\n- Task: paired-task-1'), - }); - - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/risk high')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - - expect(result).toEqual({ handled: true, success: true }); - expect(deps.setTaskRiskLevel).toHaveBeenCalledWith('high', 'msg-1'); - expect(deps.runAgent).not.toHaveBeenCalled(); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'Task risk updated.\n- Task: paired-task-1', - ); - }); - - it('handles authorized /plan without invoking the agent', async () => { - const deps = makeDeps({ - recordPlan: vi - .fn() - .mockResolvedValue('Plan recorded.\n- Task: paired-task-1'), - }); - - const result = await handleSessionCommand({ - missedMessages: [ - makeMsg('/plan brief || acceptance criteria || rollout risk'), - ], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - - expect(result).toEqual({ handled: true, success: true }); - expect(deps.recordPlan).toHaveBeenCalledWith({ - planBrief: 'brief', - acceptanceCriteria: 'acceptance criteria', - riskSummary: 'rollout risk', - }, 'msg-1'); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'Plan recorded.\n- Task: paired-task-1', - ); - }); - - it('sends /plan usage when structured payload is incomplete', async () => { - const deps = makeDeps(); - - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/plan only brief')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - - expect(result).toEqual({ handled: true, success: true }); - expect(deps.recordPlan).not.toHaveBeenCalled(); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'Usage: /plan || || ', - ); - }); - - it('handles /approve-plan without invoking the agent', async () => { - const deps = makeDeps({ - approvePlan: vi - .fn() - .mockResolvedValue('Plan approved.\n- Task: paired-task-1'), - }); - - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/approve-plan')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - - expect(result).toEqual({ handled: true, success: true }); - expect(deps.approvePlan).toHaveBeenCalledWith('msg-1'); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'Plan approved.\n- Task: paired-task-1', - ); - }); - - it('handles /request-plan-changes without invoking the agent', async () => { - const deps = makeDeps({ - requestPlanChanges: vi - .fn() - .mockResolvedValue('Plan changes requested.\n- Task: paired-task-1'), - }); - - const result = await handleSessionCommand({ - missedMessages: [makeMsg('/request-plan-changes tighten scope')], - isMainGroup: true, - groupName: 'test', - triggerPattern: trigger, - timezone: 'UTC', - deps, - }); - - expect(result).toEqual({ handled: true, success: true }); - expect(deps.requestPlanChanges).toHaveBeenCalledWith( - 'tighten scope', - 'msg-1', - ); - expect(deps.sendMessage).toHaveBeenCalledWith( - 'Plan changes requested.\n- Task: paired-task-1', - ); - }); - it('sends denial to interactable sender in non-main group', async () => { const deps = makeDeps(); const result = await handleSessionCommand({ diff --git a/src/session-commands.ts b/src/session-commands.ts index ca368fd..8d8a987 100644 --- a/src/session-commands.ts +++ b/src/session-commands.ts @@ -12,20 +12,6 @@ const SESSION_COMMAND_CONTROL_PATTERNS = [ /^Conversation compacted\.$/, /^Review snapshot updated\.(?:\n|$)/, /^Review request recorded, but the owner workspace is not ready yet\.(?:\n|$)/, - /^Plan review is required before formal review for this high-risk task\.(?:\n|$)/, - /^Task risk updated\.(?:\n|$)/, - /^Plan recorded\.(?:\n|$)/, - /^Plan approved\.(?:\n|$)/, - /^Plan changes requested\.(?:\n|$)/, - /^Risk updates must be handled by the owner service\.$/, - /^Plan recording must be handled by the owner service\.$/, - /^Plan approval must be handled by the reviewer service\.$/, - /^Plan review commands are only required for high-risk tasks\.$/, - /^Plan artifacts are incomplete\.(?:\n|$)/, - /^Deployment finalized\.(?:\n|$)/, - /^Deployment finalization must be handled by the owner service\.$/, - /^Deploy completion requires a merge-ready task or the same already-finalized checkpoint\.(?:\n|$)/, - /^Deploy completion requires a canonical workDir with a readable HEAD\.$/, /^Review is unavailable for this room\./, ]; @@ -48,13 +34,6 @@ export function extractSessionCommand( if (text === '/compact') return '/compact'; if (text === '/clear') return '/clear'; if (text === '/review' || text === '/review-ready') return '/review'; - if (text === '/deploy-complete') return '/deploy-complete'; - if (/^\/risk(?:\s|$)/.test(text)) return '/risk'; - if (/^\/plan(?:\s|$)/.test(text)) return '/plan'; - if (text === '/approve-plan') return '/approve-plan'; - if (/^\/request-plan-changes(?:\s|$)/.test(text)) { - return '/request-plan-changes'; - } return null; } @@ -99,22 +78,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: (dedupeKey: string) => Promise; - setTaskRiskLevel: ( - riskLevel: 'low' | 'high', - dedupeKey: string, - ) => Promise; - recordPlan: (plan: { - planBrief: string; - acceptanceCriteria: string; - riskSummary: string; - }, dedupeKey: string) => Promise; - approvePlan: (dedupeKey: string) => Promise; - requestPlanChanges: ( - note: string | undefined, - dedupeKey: string, - ) => Promise; - finalizeDeployment: () => Promise; + markReviewReady: () => Promise; } function resultToText(result: string | object | null | undefined): string { @@ -200,7 +164,7 @@ export async function handleSessionCommand(opts: { } if (command === '/review') { - const result = await deps.markReviewReady(cmdMsg.id); + const result = await deps.markReviewReady(); deps.advanceCursor(cmdMsg.timestamp); await deps.sendMessage( result ?? @@ -209,73 +173,6 @@ export async function handleSessionCommand(opts: { return { handled: true, success: true }; } - if (command === '/deploy-complete') { - deps.advanceCursor(cmdMsg.timestamp); - await deps.sendMessage( - (await deps.finalizeDeployment()) ?? - 'Deploy finalization is unavailable for this room.', - ); - return { handled: true, success: true }; - } - - if (command === '/risk') { - const riskArg = normalizedCommandText - .slice('/risk'.length) - .trim() - .toLowerCase(); - const riskLevel = - riskArg === 'high' ? 'high' : riskArg === 'low' ? 'low' : null; - deps.advanceCursor(cmdMsg.timestamp); - await deps.sendMessage( - riskLevel - ? ((await deps.setTaskRiskLevel(riskLevel, cmdMsg.id)) ?? - 'Risk is unavailable for this room.') - : 'Usage: /risk ', - ); - return { handled: true, success: true }; - } - - if (command === '/plan') { - const payload = normalizedCommandText.slice('/plan'.length).trim(); - const parts = payload.split('||').map((part) => part.trim()); - deps.advanceCursor(cmdMsg.timestamp); - if (parts.length !== 3 || parts.some((part) => !part)) { - await deps.sendMessage( - 'Usage: /plan || || ', - ); - return { handled: true, success: true }; - } - await deps.sendMessage( - (await deps.recordPlan({ - planBrief: parts[0], - acceptanceCriteria: parts[1], - riskSummary: parts[2], - }, cmdMsg.id)) ?? 'Plan recording is unavailable for this room.', - ); - return { handled: true, success: true }; - } - - if (command === '/approve-plan') { - deps.advanceCursor(cmdMsg.timestamp); - await deps.sendMessage( - (await deps.approvePlan(cmdMsg.id)) ?? - 'Plan approval is unavailable for this room.', - ); - return { handled: true, success: true }; - } - - if (command === '/request-plan-changes') { - const note = normalizedCommandText - .slice('/request-plan-changes'.length) - .trim(); - deps.advanceCursor(cmdMsg.timestamp); - await deps.sendMessage( - (await deps.requestPlanChanges(note || undefined, cmdMsg.id)) ?? - 'Plan change requests are unavailable for this room.', - ); - return { handled: true, success: true }; - } - const cmdIndex = missedMessages.indexOf(cmdMsg); const preCompactMsgs = missedMessages.slice(0, cmdIndex); diff --git a/src/types.ts b/src/types.ts index e628faa..28bb28d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,74 +25,16 @@ export type AgentVisibility = 'public' | 'silent'; export type PairedRoomRole = 'owner' | 'reviewer'; -export type PairedWorkspaceTopology = 'shadow-snapshot' | 'reviewer-cow'; - export type PairedTaskStatus = | 'active' - | 'draft' - | 'plan_review_pending' - | 'review_pending' | 'review_ready' | 'in_review' - | 'changes_requested' | 'merge_ready' - | 'merged' - | 'discarded' - | 'failed'; - -export type PairedTaskPolicy = 'autonomous' | 'user_signoff_required'; - -export type PairedRiskLevel = 'low' | 'high'; - -export type PairedPlanStatus = - | 'not_requested' - | 'pending' - | 'approved' - | 'changes_requested'; - -export type PairedGateTurnKind = 'implementation_start' | 'commit' | 'push'; - -export type PairedReviewerVerdict = - | 'done' - | 'done_with_concerns' - | 'blocked' - | 'silent'; - -export type PairedExecutionStatus = - | 'pending' - | 'running' - | 'succeeded' - | 'failed' - | 'cancelled'; + | 'completed'; 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' - | 'plan_brief' - | 'acceptance_criteria' - | 'risk_summary'; - -export type PairedEventActorRole = PairedRoomRole | 'system'; - -export type PairedEventType = - | 'set_risk' - | 'submit_plan' - | 'approve_plan' - | 'request_plan_changes' - | 'request_review' - | 'deploy_complete'; +export type PairedWorkspaceStatus = 'ready' | 'stale'; export interface RoomRoleContext { serviceId: string; @@ -106,7 +48,6 @@ export interface PairedProject { chat_jid: string; group_folder: string; canonical_work_dir: string; - workspace_topology: PairedWorkspaceTopology; created_at: string; updated_at: string; } @@ -119,9 +60,7 @@ export interface PairedTask { reviewer_service_id: string; title: string | null; source_ref: string | null; - task_policy: PairedTaskPolicy; - risk_level: PairedRiskLevel; - plan_status: PairedPlanStatus; + plan_notes: string | null; review_requested_at: string | null; last_finalized_checkpoint?: string | null; gate_turn_kind?: PairedGateTurnKind | null; @@ -133,66 +72,20 @@ export interface PairedTask { updated_at: string; } -export interface PairedExecution { - id: string; - task_id: string; - service_id: string; - role: PairedWorkspaceRole; - workspace_id: string | null; - checkpoint_fingerprint?: 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; - snapshot_source_fingerprint: string | null; + snapshot_ref: 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 interface PairedEvent { - id: number; - task_id: string; - event_type: PairedEventType; - actor_role: PairedEventActorRole; - source_service_id: string; - source_fingerprint: string | null; - dedupe_key: string; - payload_json: string | null; - created_at: string; -} +export type AgentResponsePolicy = 'normal' | 'silent-unless-addressed'; export type StructuredAgentOutput = | {