Owner continuity: - Seed a freshly created owner task with the previous task's latest owner final on a cold start after the previous task already closed, so a user reply to a finalized TASK_DONE no longer produces a "no context" answer. Activated for this deployment via PAIRED_CARRY_FORWARD_LATEST_OWNER_FINAL (.env); carried text is injected as clearly-marked background only. - Skip intermediate STEP_DONE outputs when picking the carry-forward anchor. Single-mode routing: - enforceRoomModeOnLease strips a stale reviewer/arbiter lease from a room switched back to single, preventing single-mode messages from stalling in the paired path on a stuck execution lease. Session auth / credentials: - Pre-sync Claude credentials into each session dir before the agent spawns. - Honor CLAUDE_CREDENTIALS_PATH in setup/login.ts (per-service isolation). - Add a relogin-required gate so a permanently logged-out claude-code room asks the user to re-login instead of spawning a doomed agent. Other: - Arbiter verdicts written in the user's language (verdict keyword stays EN). - status-dashboard chatName field; runtime-inventory credential path resolver. Tests (make suite fully green: 1595 pass / 3 skip): - service-routing: default owner is now the claude service and reviewer is codex-review; update the 7 failover/default expectations accordingly. - migrate-room-registrations: owner inferred as claude-code (configured OWNER_AGENT_TYPE) for a dual legacy room; reviewer becomes codex. - register: mock paired-workspace provisioning + reload signal (registration now provisions a workspace and hot-reloads); assert RELOADED status. - paired-execution-context: force a claude-code reviewer to exercise the Claude read-only branch regardless of the deployment default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
import {
|
|
ARBITER_AGENT_TYPE,
|
|
ARBITER_SERVICE_ID,
|
|
CODEX_REVIEW_SERVICE_ID,
|
|
OWNER_AGENT_TYPE,
|
|
REVIEWER_AGENT_TYPE,
|
|
SERVICE_ID,
|
|
isArbiterEnabled,
|
|
normalizeServiceId,
|
|
} from './config.js';
|
|
import {
|
|
clearChannelOwnerLease,
|
|
getAllChannelOwnerLeases,
|
|
getEffectiveRuntimeRoomMode,
|
|
getStoredRoomRoleAgentPlan,
|
|
getStoredRoomSettings,
|
|
type ChannelOwnerLeaseRow,
|
|
} from './db.js';
|
|
import { logger } from './logger.js';
|
|
import {
|
|
inferAgentTypeFromServiceShadow,
|
|
resolveRoleServiceShadow,
|
|
} from './role-service-shadow.js';
|
|
import { resolveRoleAgentPlan } from './role-agent-plan.js';
|
|
import type { AgentType, PairedRoomRole } from './types.js';
|
|
|
|
export interface EffectiveChannelLease {
|
|
chat_jid: string;
|
|
owner_agent_type?: AgentType;
|
|
reviewer_agent_type?: AgentType | null;
|
|
arbiter_agent_type?: AgentType | null;
|
|
owner_service_id: string;
|
|
reviewer_service_id: string | null;
|
|
arbiter_service_id: string | null;
|
|
owner_failover_active?: boolean;
|
|
activated_at: string | null;
|
|
reason: string | null;
|
|
explicit: boolean;
|
|
}
|
|
|
|
const LEASE_CACHE_REFRESH_MS = 2_000;
|
|
|
|
let lastLeaseRefreshAt = 0;
|
|
const leaseCache = new Map<string, ChannelOwnerLeaseRow>();
|
|
|
|
function normalizeLeaseRow(
|
|
row: ChannelOwnerLeaseRow,
|
|
explicit: boolean,
|
|
): EffectiveChannelLease {
|
|
const storedRolePlan = getStoredRoomRoleAgentPlan(row.chat_jid);
|
|
const ownerAgentType =
|
|
row.owner_agent_type ??
|
|
storedRolePlan?.ownerAgentType ??
|
|
getStoredRoomSettings(row.chat_jid)?.ownerAgentType ??
|
|
inferAgentTypeFromServiceShadow(row.owner_service_id) ??
|
|
'claude-code';
|
|
const reviewerAgentType =
|
|
row.reviewer_service_id == null
|
|
? null
|
|
: (row.reviewer_agent_type ??
|
|
inferAgentTypeFromServiceShadow(row.reviewer_service_id) ??
|
|
storedRolePlan?.reviewerAgentType ??
|
|
resolveRoleAgentPlan({
|
|
paired: true,
|
|
groupAgentType: ownerAgentType,
|
|
configuredReviewer: REVIEWER_AGENT_TYPE,
|
|
configuredArbiter: ARBITER_AGENT_TYPE,
|
|
}).reviewerAgentType);
|
|
const arbiterAgentType =
|
|
row.arbiter_agent_type ??
|
|
(row.arbiter_service_id
|
|
? inferAgentTypeFromServiceShadow(row.arbiter_service_id)
|
|
: undefined) ??
|
|
storedRolePlan?.arbiterAgentType ??
|
|
null;
|
|
|
|
return {
|
|
chat_jid: row.chat_jid,
|
|
owner_agent_type: ownerAgentType,
|
|
reviewer_agent_type: reviewerAgentType,
|
|
arbiter_agent_type: arbiterAgentType,
|
|
owner_service_id:
|
|
(row.owner_service_id
|
|
? normalizeServiceId(row.owner_service_id)
|
|
: null) ??
|
|
resolveRoleServiceShadow('owner', ownerAgentType) ??
|
|
normalizeServiceId(SERVICE_ID),
|
|
reviewer_service_id:
|
|
(row.reviewer_service_id
|
|
? normalizeServiceId(row.reviewer_service_id)
|
|
: null) ??
|
|
(reviewerAgentType
|
|
? resolveRoleServiceShadow('reviewer', reviewerAgentType)
|
|
: null),
|
|
arbiter_service_id:
|
|
(row.arbiter_service_id
|
|
? normalizeServiceId(row.arbiter_service_id)
|
|
: null) ??
|
|
(arbiterAgentType
|
|
? resolveRoleServiceShadow('arbiter', arbiterAgentType)
|
|
: null) ??
|
|
(isArbiterEnabled() ? ARBITER_SERVICE_ID : null),
|
|
owner_failover_active: false,
|
|
activated_at: row.activated_at,
|
|
reason: row.reason,
|
|
explicit,
|
|
};
|
|
}
|
|
|
|
function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|
const roomMode = getEffectiveRuntimeRoomMode(chatJid);
|
|
const storedRolePlan = getStoredRoomRoleAgentPlan(chatJid);
|
|
const ownerAgentType =
|
|
storedRolePlan?.ownerAgentType ??
|
|
getStoredRoomSettings(chatJid)?.ownerAgentType ??
|
|
OWNER_AGENT_TYPE;
|
|
const rolePlan =
|
|
storedRolePlan ??
|
|
resolveRoleAgentPlan({
|
|
paired: roomMode === 'tribunal',
|
|
groupAgentType: ownerAgentType,
|
|
configuredReviewer: REVIEWER_AGENT_TYPE,
|
|
configuredArbiter: ARBITER_AGENT_TYPE,
|
|
});
|
|
|
|
return {
|
|
chat_jid: chatJid,
|
|
owner_agent_type: rolePlan.ownerAgentType,
|
|
reviewer_agent_type: rolePlan.reviewerAgentType,
|
|
arbiter_agent_type: rolePlan.arbiterAgentType,
|
|
owner_service_id:
|
|
resolveRoleServiceShadow('owner', rolePlan.ownerAgentType) ??
|
|
normalizeServiceId(SERVICE_ID),
|
|
reviewer_service_id: resolveRoleServiceShadow(
|
|
'reviewer',
|
|
rolePlan.reviewerAgentType,
|
|
),
|
|
arbiter_service_id: resolveRoleServiceShadow(
|
|
'arbiter',
|
|
rolePlan.arbiterAgentType,
|
|
),
|
|
owner_failover_active: false,
|
|
activated_at: null,
|
|
reason: null,
|
|
explicit: false,
|
|
};
|
|
}
|
|
|
|
function enforceRoomModeOnLease(
|
|
chatJid: string,
|
|
lease: EffectiveChannelLease,
|
|
): EffectiveChannelLease {
|
|
// Invariant: a single-mode room never runs a reviewer/arbiter, even if a
|
|
// stored lease still carries them from when the room was tribunal. Without
|
|
// this, a stale reviewer lease keeps routing single-mode messages into the
|
|
// paired path, where they stall forever on a stuck/mismatched execution
|
|
// lease (the "task revision was already claimed elsewhere" hang). The stored
|
|
// row is left untouched, so switching the room back to tribunal restores it.
|
|
if (
|
|
(lease.reviewer_service_id == null && lease.arbiter_service_id == null) ||
|
|
getEffectiveRuntimeRoomMode(chatJid) !== 'single'
|
|
) {
|
|
return lease;
|
|
}
|
|
return {
|
|
...lease,
|
|
reviewer_agent_type: null,
|
|
arbiter_agent_type: null,
|
|
reviewer_service_id: null,
|
|
arbiter_service_id: null,
|
|
};
|
|
}
|
|
|
|
function getStoredOrDefaultLease(chatJid: string): EffectiveChannelLease {
|
|
refreshChannelOwnerCache();
|
|
const row = leaseCache.get(chatJid);
|
|
const lease = row ? normalizeLeaseRow(row, true) : getDefaultLease(chatJid);
|
|
return enforceRoomModeOnLease(chatJid, lease);
|
|
}
|
|
|
|
export function refreshChannelOwnerCache(force = false): void {
|
|
const now = Date.now();
|
|
if (!force && now - lastLeaseRefreshAt < LEASE_CACHE_REFRESH_MS) {
|
|
return;
|
|
}
|
|
|
|
leaseCache.clear();
|
|
for (const row of getAllChannelOwnerLeases()) {
|
|
leaseCache.set(row.chat_jid, row);
|
|
}
|
|
lastLeaseRefreshAt = now;
|
|
}
|
|
|
|
export function getEffectiveChannelLease(
|
|
chatJid: string,
|
|
): EffectiveChannelLease {
|
|
// Global failover overrides the owner execution backend for all channels,
|
|
// while preserving the room's reviewer/arbiter role assignments.
|
|
if (globalFailoverActive) {
|
|
const baseLease = getStoredOrDefaultLease(chatJid);
|
|
return {
|
|
...baseLease,
|
|
owner_service_id: CODEX_REVIEW_SERVICE_ID,
|
|
owner_failover_active: true,
|
|
activated_at: globalFailoverActivatedAt,
|
|
reason: globalFailoverReason,
|
|
explicit: true,
|
|
};
|
|
}
|
|
return getStoredOrDefaultLease(chatJid);
|
|
}
|
|
|
|
export function resolveLeaseServiceId(
|
|
lease: Pick<
|
|
EffectiveChannelLease,
|
|
| 'owner_agent_type'
|
|
| 'reviewer_agent_type'
|
|
| 'arbiter_agent_type'
|
|
| 'owner_service_id'
|
|
| 'reviewer_service_id'
|
|
| 'arbiter_service_id'
|
|
| 'owner_failover_active'
|
|
>,
|
|
role: PairedRoomRole,
|
|
): string | null {
|
|
switch (role) {
|
|
case 'owner':
|
|
return (
|
|
(lease.owner_service_id
|
|
? normalizeServiceId(lease.owner_service_id)
|
|
: null) ??
|
|
resolveRoleServiceShadow('owner', lease.owner_agent_type) ??
|
|
null
|
|
);
|
|
case 'reviewer':
|
|
return (
|
|
(lease.reviewer_service_id
|
|
? normalizeServiceId(lease.reviewer_service_id)
|
|
: null) ??
|
|
(lease.reviewer_agent_type
|
|
? resolveRoleServiceShadow('reviewer', lease.reviewer_agent_type)
|
|
: null)
|
|
);
|
|
case 'arbiter':
|
|
return (
|
|
(lease.arbiter_service_id
|
|
? normalizeServiceId(lease.arbiter_service_id)
|
|
: null) ??
|
|
(lease.arbiter_agent_type
|
|
? resolveRoleServiceShadow('arbiter', lease.arbiter_agent_type)
|
|
: null)
|
|
);
|
|
}
|
|
}
|
|
|
|
export function isOwnerServiceForChat(
|
|
chatJid: string,
|
|
serviceId: string = SERVICE_ID,
|
|
): boolean {
|
|
const lease = getEffectiveChannelLease(chatJid);
|
|
return (
|
|
normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'owner')
|
|
);
|
|
}
|
|
|
|
export function isReviewerServiceForChat(
|
|
chatJid: string,
|
|
serviceId: string = SERVICE_ID,
|
|
): boolean {
|
|
const lease = getEffectiveChannelLease(chatJid);
|
|
return (
|
|
normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'reviewer')
|
|
);
|
|
}
|
|
|
|
export function hasReviewerLease(chatJid: string): boolean {
|
|
return (
|
|
resolveLeaseServiceId(getEffectiveChannelLease(chatJid), 'reviewer') !==
|
|
null
|
|
);
|
|
}
|
|
|
|
export function isArbiterServiceForChat(
|
|
chatJid: string,
|
|
serviceId: string = SERVICE_ID,
|
|
): boolean {
|
|
const lease = getEffectiveChannelLease(chatJid);
|
|
return (
|
|
normalizeServiceId(serviceId) === resolveLeaseServiceId(lease, 'arbiter')
|
|
);
|
|
}
|
|
|
|
export function shouldServiceProcessChat(
|
|
_chatJid: string,
|
|
_serviceId: string = SERVICE_ID,
|
|
): boolean {
|
|
return true;
|
|
}
|
|
|
|
// ── Global failover ──────────────────────────────────────────────
|
|
// Claude API limits are account-level, so owner failover applies to all channels.
|
|
|
|
let globalFailoverActive = false;
|
|
let globalFailoverReason: string | null = null;
|
|
let globalFailoverActivatedAt: string | null = null;
|
|
|
|
export function activateCodexFailover(_chatJid: string, reason: string): void {
|
|
globalFailoverActive = true;
|
|
globalFailoverReason = reason;
|
|
globalFailoverActivatedAt = new Date().toISOString();
|
|
logger.warn(
|
|
{ reason, activatedAt: globalFailoverActivatedAt },
|
|
'Global failover activated — owner execution switching to codex across all channels',
|
|
);
|
|
}
|
|
|
|
export function isGlobalFailoverActive(): boolean {
|
|
return globalFailoverActive;
|
|
}
|
|
|
|
export function getGlobalFailoverInfo(): {
|
|
active: boolean;
|
|
reason: string | null;
|
|
activatedAt: string | null;
|
|
} {
|
|
return {
|
|
active: globalFailoverActive,
|
|
reason: globalFailoverReason,
|
|
activatedAt: globalFailoverActivatedAt,
|
|
};
|
|
}
|
|
|
|
export function clearGlobalFailover(): void {
|
|
if (!globalFailoverActive) return;
|
|
globalFailoverActive = false;
|
|
globalFailoverReason = null;
|
|
globalFailoverActivatedAt = null;
|
|
logger.info('Global failover cleared — resuming normal owner routing');
|
|
}
|
|
|
|
export function restoreDefaultChannelLease(chatJid: string): void {
|
|
clearChannelOwnerLease(chatJid);
|
|
leaseCache.delete(chatJid);
|
|
lastLeaseRefreshAt = Date.now();
|
|
}
|
|
|
|
export interface ActiveFailoverLease {
|
|
chatJid: string;
|
|
activatedAt: string | null;
|
|
}
|
|
|
|
export function getActiveCodexFailoverLeases(): ActiveFailoverLease[] {
|
|
// Global failover: report as a single pseudo-lease
|
|
if (globalFailoverActive) {
|
|
return [{ chatJid: '*', activatedAt: globalFailoverActivatedAt }];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** @deprecated Use getActiveCodexFailoverLeases() instead */
|
|
export function getActiveCodexFailoverChatJids(): string[] {
|
|
return getActiveCodexFailoverLeases().map((l) => l.chatJid);
|
|
}
|