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 {
|
return {
|
||||||
cancelPairedTurn: vi.fn(),
|
cancelPairedTurn: vi.fn(),
|
||||||
createPairedTask: vi.fn(),
|
createPairedTask: vi.fn(),
|
||||||
|
getEffectiveRuntimeRoomMode: vi.fn(() => 'tribunal'),
|
||||||
getLatestPairedTaskForChat: vi.fn(),
|
getLatestPairedTaskForChat: vi.fn(),
|
||||||
getLatestOpenPairedTaskForChat: vi.fn(),
|
getLatestOpenPairedTaskForChat: vi.fn(),
|
||||||
getPairedTaskById: vi.fn(),
|
getPairedTaskById: vi.fn(),
|
||||||
@@ -33,6 +34,9 @@ vi.mock('./db.js', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('./paired-workspace-manager.js', () => ({
|
vi.mock('./paired-workspace-manager.js', () => ({
|
||||||
|
ensurePairedWorkspaceProvisioned: vi.fn(
|
||||||
|
() => '/repo/self-healed-canonical',
|
||||||
|
),
|
||||||
isOwnerWorkspaceRepairNeededError: vi.fn(() => false),
|
isOwnerWorkspaceRepairNeededError: vi.fn(() => false),
|
||||||
markPairedTaskReviewReady: vi.fn(),
|
markPairedTaskReviewReady: vi.fn(),
|
||||||
prepareReviewerWorkspaceForExecution: vi.fn(),
|
prepareReviewerWorkspaceForExecution: vi.fn(),
|
||||||
@@ -312,6 +316,54 @@ describe('paired execution context', () => {
|
|||||||
expect(db.insertPairedTurnOutput).not.toHaveBeenCalled();
|
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', () => {
|
it('records a quick reopen when a new owner task starts shortly after TASK_DONE completion', () => {
|
||||||
const previousTask = buildPairedTask({
|
const previousTask = buildPairedTask({
|
||||||
id: 'task-completed',
|
id: 'task-completed',
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
cancelPairedTurn,
|
cancelPairedTurn,
|
||||||
createPairedTask,
|
createPairedTask,
|
||||||
|
getEffectiveRuntimeRoomMode,
|
||||||
getLatestPairedTaskForChat,
|
getLatestPairedTaskForChat,
|
||||||
getLatestOpenPairedTaskForChat,
|
getLatestOpenPairedTaskForChat,
|
||||||
getPairedTaskById,
|
getPairedTaskById,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
} from './paired-task-status.js';
|
} from './paired-task-status.js';
|
||||||
import { resolveCanonicalSourceRef } from './paired-source-ref.js';
|
import { resolveCanonicalSourceRef } from './paired-source-ref.js';
|
||||||
import {
|
import {
|
||||||
|
ensurePairedWorkspaceProvisioned,
|
||||||
isOwnerWorkspaceRepairNeededError,
|
isOwnerWorkspaceRepairNeededError,
|
||||||
prepareReviewerWorkspaceForExecution,
|
prepareReviewerWorkspaceForExecution,
|
||||||
provisionOwnerWorkspaceForPairedTask,
|
provisionOwnerWorkspaceForPairedTask,
|
||||||
@@ -74,7 +76,21 @@ function ensurePairedProject(
|
|||||||
chatJid: string,
|
chatJid: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!group.workDir) {
|
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();
|
const now = new Date().toISOString();
|
||||||
|
|||||||
Reference in New Issue
Block a user