From 44c54a759cb63c2ce48891a1c07a5700d7cdf046 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 30 Jul 2026 01:45:28 +0900 Subject: [PATCH] fix(paired): auto-provision paired workspace so tribunal rooms get a reviewer on registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms registered as tribunal had no work_dir and no paired_projects row, so ensurePairedProject() returned null, no paired task was ever created, and only the owner ran — the reviewer/arbiter never fired (web-vstock and 4 other rooms). Add ensurePairedWorkspaceProvisioned(): defaults canonical work_dir to groups/, guarantees it is a standalone git repo with an initial commit (detected via a LOCAL .git so we never walk up into the EJClaw checkout and create a stray worktree), and upserts the paired_projects row. Wire it into setup/register.ts so every newly registered room is immediately usable by the full owner→reviewer→arbiter flow. Adds a regression test. --- setup/register.ts | 19 +++++++++ src/paired-workspace-manager.test.ts | 31 +++++++++++++++ src/paired-workspace-manager.ts | 59 +++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/setup/register.ts b/setup/register.ts index 7901411..d0a8e27 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -9,6 +9,7 @@ import path from 'path'; import { GROUPS_DIR } from '../src/config.js'; import { assignRoom, initDatabase } from '../src/db.js'; import { isValidGroupFolder } from '../src/group-folder.js'; +import { ensurePairedWorkspaceProvisioned } from '../src/paired-workspace-manager.js'; import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; @@ -109,6 +110,24 @@ export async function run(args: string[]): Promise { }); logger.info('Assigned room through canonical room service'); + // Provision the paired workspace so a tribunal room's reviewer/arbiter work + // immediately on registration. Without this the room has work_dir=null and + // the paired flow never creates a task, so only the owner ever runs. + const canonicalWorkDir = ensurePairedWorkspaceProvisioned({ + chatJid: parsed.jid, + groupFolder: parsed.folder, + }); + assignRoom(parsed.jid, { + name: parsed.name, + folder: parsed.folder, + isMain: parsed.isMain, + workDir: canonicalWorkDir, + }); + logger.info( + { folder: parsed.folder, canonicalWorkDir }, + 'Provisioned paired workspace for room so reviewer/arbiter run on registration', + ); + fs.mkdirSync(path.join(GROUPS_DIR, parsed.folder, 'logs'), { recursive: true, }); diff --git a/src/paired-workspace-manager.test.ts b/src/paired-workspace-manager.test.ts index b5eb64d..67367ee 100644 --- a/src/paired-workspace-manager.test.ts +++ b/src/paired-workspace-manager.test.ts @@ -123,6 +123,37 @@ describe('paired workspace manager', () => { fs.rmSync(tempRoot, { recursive: true, force: true }); }); + it('ensurePairedWorkspaceProvisioned inits a canonical repo with a commit and upserts the project', async () => { + const { db, manager } = await loadModules(); + db._initTestDatabase(); + + const workDir = path.join(tempRoot, 'canon'); + const result = manager.ensurePairedWorkspaceProvisioned({ + chatJid: 'dc:test-room', + groupFolder: 'test-group', + workDir, + }); + + expect(result).toBe(workDir); + expect(fs.existsSync(path.join(workDir, '.git'))).toBe(true); + // HEAD must resolve so owner-workspace provisioning can `git worktree add`. + expect(runGit(['rev-parse', 'HEAD'], workDir)).toMatch(/^[0-9a-f]{40}$/); + // A paired_projects row is created from the canonical work dir, without + // which the tribunal flow throws "Paired project not found" and no + // reviewer/arbiter ever runs. + const project = db.getPairedProject('dc:test-room'); + expect(project?.canonical_work_dir).toBe(workDir); + + // Idempotent — a second call reuses the repo and does not throw. + expect(() => + manager.ensurePairedWorkspaceProvisioned({ + chatJid: 'dc:test-room', + groupFolder: 'test-group', + workDir, + }), + ).not.toThrow(); + }); + it('registers the owner workspace for reviewer execution when review is requested', async () => { const { db, manager } = await loadModules(); db._initTestDatabase(); diff --git a/src/paired-workspace-manager.ts b/src/paired-workspace-manager.ts index 4f94924..e7dc080 100644 --- a/src/paired-workspace-manager.ts +++ b/src/paired-workspace-manager.ts @@ -3,11 +3,12 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import { DATA_DIR } from './config.js'; +import { DATA_DIR, GROUPS_DIR } from './config.js'; import { getPairedProject, getPairedTaskById, getPairedWorkspace, + upsertPairedProject, upsertPairedWorkspace, } from './db.js'; import { resolvePairedTaskWorkspacePath } from './group-folder.js'; @@ -610,6 +611,62 @@ function getTaskAndProject(taskId: string): { return { task, canonicalWorkDir: project.canonical_work_dir }; } +/** + * Ensure a room's canonical paired workspace exists so the tribunal flow can + * provision an owner worktree and run the reviewer/arbiter. + * + * Root cause this guards against: a room registered as tribunal with no + * `work_dir` → `ensurePairedProject` returns null → no paired task is created → + * reviewer/arbiter never run (the symptom seen in web-vstock and other rooms). + * + * This defaults the canonical work dir to `groups/`, guarantees it is a + * standalone git repo with at least one commit (so `git worktree add` can + * resolve HEAD during owner-workspace provisioning), and upserts the + * `paired_projects` row. Idempotent — safe to call on every registration. + */ +export function ensurePairedWorkspaceProvisioned(args: { + chatJid: string; + groupFolder: string; + workDir?: string | null; +}): string { + const canonicalWorkDir = + args.workDir && args.workDir.trim().length > 0 + ? args.workDir + : path.join(GROUPS_DIR, args.groupFolder); + + fs.mkdirSync(canonicalWorkDir, { recursive: true }); + + // Detect a standalone repo by its OWN `.git` only. A bare `git rev-parse` + // would walk up to a parent repo (e.g. the EJClaw checkout that contains + // groups/) and falsely report the dir as a repo — the exact bug that + // produced stray EJClaw worktrees. So init a fresh repo when no local .git. + if (!fs.existsSync(path.join(canonicalWorkDir, '.git'))) { + runGit(['init', '-b', 'main'], canonicalWorkDir); + } + + if (!resolveCommit(canonicalWorkDir, 'HEAD')) { + // Fresh repo with no commits — owner-workspace provisioning needs a + // resolvable HEAD, so seed an empty initial commit with a local identity. + runGit(['config', 'user.email', 'claude-bot@ejclaw'], canonicalWorkDir); + runGit(['config', 'user.name', 'EJClaw'], canonicalWorkDir); + runGit( + ['commit', '--allow-empty', '-m', 'chore: initialize paired workspace'], + canonicalWorkDir, + ); + } + + const now = new Date().toISOString(); + upsertPairedProject({ + chat_jid: args.chatJid, + group_folder: args.groupFolder, + canonical_work_dir: canonicalWorkDir, + created_at: now, + updated_at: now, + }); + + return canonicalWorkDir; +} + function makeWorkspaceRecord(args: { taskId: string; role: PairedWorkspace['role'];