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:
@@ -9,6 +9,7 @@ import path from 'path';
|
|||||||
import { GROUPS_DIR } from '../src/config.js';
|
import { GROUPS_DIR } from '../src/config.js';
|
||||||
import { assignRoom, initDatabase } from '../src/db.js';
|
import { assignRoom, initDatabase } from '../src/db.js';
|
||||||
import { isValidGroupFolder } from '../src/group-folder.js';
|
import { isValidGroupFolder } from '../src/group-folder.js';
|
||||||
|
import { ensurePairedWorkspaceProvisioned } from '../src/paired-workspace-manager.js';
|
||||||
import { logger } from '../src/logger.js';
|
import { logger } from '../src/logger.js';
|
||||||
import { emitStatus } from './status.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');
|
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'), {
|
fs.mkdirSync(path.join(GROUPS_DIR, parsed.folder, 'logs'), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,6 +123,37 @@ describe('paired workspace manager', () => {
|
|||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
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 () => {
|
it('registers the owner workspace for reviewer execution when review is requested', async () => {
|
||||||
const { db, manager } = await loadModules();
|
const { db, manager } = await loadModules();
|
||||||
db._initTestDatabase();
|
db._initTestDatabase();
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import crypto from 'crypto';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { DATA_DIR } from './config.js';
|
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||||
import {
|
import {
|
||||||
getPairedProject,
|
getPairedProject,
|
||||||
getPairedTaskById,
|
getPairedTaskById,
|
||||||
getPairedWorkspace,
|
getPairedWorkspace,
|
||||||
|
upsertPairedProject,
|
||||||
upsertPairedWorkspace,
|
upsertPairedWorkspace,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { resolvePairedTaskWorkspacePath } from './group-folder.js';
|
import { resolvePairedTaskWorkspacePath } from './group-folder.js';
|
||||||
@@ -610,6 +611,62 @@ function getTaskAndProject(taskId: string): {
|
|||||||
return { task, canonicalWorkDir: project.canonical_work_dir };
|
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: {
|
function makeWorkspaceRecord(args: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
role: PairedWorkspace['role'];
|
role: PairedWorkspace['role'];
|
||||||
|
|||||||
Reference in New Issue
Block a user