Files
EJClaw/src/service-routing.ts
Codex 712664ca00 Fix owner context loss after finalize; green the test suite
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>
2026-08-25 18:40:14 +09:00

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);
}