diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index 6e52f51..ed1b54c 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -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', diff --git a/src/paired-execution-context.ts b/src/paired-execution-context.ts index 9a24f5f..6f641a6 100644 --- a/src/paired-execution-context.ts +++ b/src/paired-execution-context.ts @@ -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();