From 03e6f8bcc6d172e1523367b28acb2a0361096e87 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 25 Aug 2026 20:47:00 +0900 Subject: [PATCH] Self-heal tribunal rooms with no work_dir so the owner never goes silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/paired-execution-context.test.ts | 52 ++++++++++++++++++++++++++++ src/paired-execution-context.ts | 18 +++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) 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();