fix(paired): auto-provision paired workspace so tribunal rooms get a reviewer on registration

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/<folder>, 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.
This commit is contained in:
Codex
2026-07-30 01:45:28 +09:00
parent 6036430c60
commit 44c54a759c
3 changed files with 108 additions and 1 deletions

View File

@@ -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<void> {
});
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,
});

View File

@@ -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();

View File

@@ -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/<folder>`, 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/<folder>) 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'];