Self-heal tribunal rooms with no work_dir so the owner never goes silent
A tribunal room whose work_dir was never provisioned (null) made resolveOwnerTaskForHumanMessage return a null task, so the owner never ran and the room went completely silent — the user's messages got no reply at all (seen in the tts_site room, where days of requests were dropped). ensurePairedProject now provisions the canonical workspace on demand when work_dir is missing, guarded to tribunal rooms so a single-mode room never gets a spurious paired workspace. Idempotent via ensurePairedWorkspaceProvisioned. Adds tests for both the tribunal self-heal and the single-mode no-op. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ vi.mock('./db.js', () => {
|
||||
return {
|
||||
cancelPairedTurn: vi.fn(),
|
||||
createPairedTask: vi.fn(),
|
||||
getEffectiveRuntimeRoomMode: vi.fn(() => 'tribunal'),
|
||||
getLatestPairedTaskForChat: vi.fn(),
|
||||
getLatestOpenPairedTaskForChat: vi.fn(),
|
||||
getPairedTaskById: vi.fn(),
|
||||
@@ -33,6 +34,9 @@ vi.mock('./db.js', () => {
|
||||
});
|
||||
|
||||
vi.mock('./paired-workspace-manager.js', () => ({
|
||||
ensurePairedWorkspaceProvisioned: vi.fn(
|
||||
() => '/repo/self-healed-canonical',
|
||||
),
|
||||
isOwnerWorkspaceRepairNeededError: vi.fn(() => false),
|
||||
markPairedTaskReviewReady: vi.fn(),
|
||||
prepareReviewerWorkspaceForExecution: vi.fn(),
|
||||
@@ -312,6 +316,54 @@ describe('paired execution context', () => {
|
||||
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('self-heals a tribunal room with no work_dir by provisioning the canonical workspace', () => {
|
||||
// Reproduces the silent-room incident: a tribunal room whose work_dir was
|
||||
// never provisioned would return a null task and never run the owner.
|
||||
const noWorkDirGroup: RegisteredGroup = { ...group, workDir: undefined };
|
||||
vi.mocked(db.getEffectiveRuntimeRoomMode).mockReturnValue('tribunal');
|
||||
vi.mocked(
|
||||
pairedWorkspaceManager.ensurePairedWorkspaceProvisioned,
|
||||
).mockReturnValue('/repo/self-healed-canonical');
|
||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
||||
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(undefined);
|
||||
|
||||
const result = resolveOwnerTaskForHumanMessage({
|
||||
group: noWorkDirGroup,
|
||||
chatJid: 'dc:test',
|
||||
roomRoleContext: ownerContext,
|
||||
existingTask: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
pairedWorkspaceManager.ensurePairedWorkspaceProvisioned,
|
||||
).toHaveBeenCalledWith({
|
||||
chatJid: 'dc:test',
|
||||
groupFolder: noWorkDirGroup.folder,
|
||||
});
|
||||
expect(result.task).not.toBeNull();
|
||||
expect(db.createPairedTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not provision a workspace for a single-mode room with no work_dir', () => {
|
||||
const noWorkDirGroup: RegisteredGroup = { ...group, workDir: undefined };
|
||||
vi.mocked(db.getEffectiveRuntimeRoomMode).mockReturnValue('single');
|
||||
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue(undefined);
|
||||
vi.mocked(db.getLatestPairedTaskForChat).mockReturnValue(undefined);
|
||||
|
||||
const result = resolveOwnerTaskForHumanMessage({
|
||||
group: noWorkDirGroup,
|
||||
chatJid: 'dc:test',
|
||||
roomRoleContext: ownerContext,
|
||||
existingTask: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
pairedWorkspaceManager.ensurePairedWorkspaceProvisioned,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(result.task).toBeNull();
|
||||
expect(db.createPairedTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a quick reopen when a new owner task starts shortly after TASK_DONE completion', () => {
|
||||
const previousTask = buildPairedTask({
|
||||
id: 'task-completed',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import {
|
||||
cancelPairedTurn,
|
||||
createPairedTask,
|
||||
getEffectiveRuntimeRoomMode,
|
||||
getLatestPairedTaskForChat,
|
||||
getLatestOpenPairedTaskForChat,
|
||||
getPairedTaskById,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
} from './paired-task-status.js';
|
||||
import { resolveCanonicalSourceRef } from './paired-source-ref.js';
|
||||
import {
|
||||
ensurePairedWorkspaceProvisioned,
|
||||
isOwnerWorkspaceRepairNeededError,
|
||||
prepareReviewerWorkspaceForExecution,
|
||||
provisionOwnerWorkspaceForPairedTask,
|
||||
@@ -74,7 +76,21 @@ function ensurePairedProject(
|
||||
chatJid: string,
|
||||
): string | null {
|
||||
if (!group.workDir) {
|
||||
return null;
|
||||
// Self-heal: a tribunal room whose work_dir was never provisioned (null)
|
||||
// makes resolveOwnerTaskForHumanMessage return a null task, so the owner
|
||||
// never runs and the room goes completely silent — the user's messages get
|
||||
// no reply at all. Provision the canonical workspace on demand so the owner
|
||||
// can always start. Guarded to tribunal rooms so a single-mode room never
|
||||
// gets a spurious paired workspace. Idempotent: ensurePairedWorkspace-
|
||||
// Provisioned skips git init/commit when they already exist and upserts the
|
||||
// paired project itself.
|
||||
if (getEffectiveRuntimeRoomMode(chatJid) !== 'tribunal') {
|
||||
return null;
|
||||
}
|
||||
return ensurePairedWorkspaceProvisioned({
|
||||
chatJid,
|
||||
groupFolder: group.folder,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
Reference in New Issue
Block a user