feat: add omx orchestration backend selection

This commit is contained in:
kris
2026-04-03 03:17:12 +08:00
parent 60f5e2d7d6
commit ec45bed59f
18 changed files with 1993 additions and 20 deletions

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import {
getProject,
getProjectOrchestrationBackendState,
updateProjectOrchestrationBackend,
} from "@/lib/boss-data";
function normalizeRequestedBackendId(value: unknown) {
return value === "omx-team" ? "omx-team" : "boss-native-orchestrator";
}
async function readGroupProjectOrNotFound(projectId: string) {
const project = await getProject(projectId);
if (!project) {
return { ok: false as const, response: NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }) };
}
if (!project.isGroup) {
return {
ok: false as const,
response: NextResponse.json({ ok: false, message: "PROJECT_NOT_GROUP_CHAT" }, { status: 400 }),
};
}
return { ok: true as const, project };
}
export async function GET(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const projectCheck = await readGroupProjectOrNotFound(projectId);
if (!projectCheck.ok) {
return projectCheck.response;
}
const state = await getProjectOrchestrationBackendState(projectId);
if (!state) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json({
ok: true,
...state,
requestedBackendId: projectCheck.project.orchestrationBackendOverride ?? null,
requestedBackendLabel: projectCheck.project.orchestrationBackendOverride
? state.requestedBackendLabel
: null,
});
}
export async function PATCH(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId } = await context.params;
const projectCheck = await readGroupProjectOrNotFound(projectId);
if (!projectCheck.ok) {
return projectCheck.response;
}
const rawBody = await request.text().catch(() => "");
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 });
}
if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ ok: false, message: "INVALID_ORCHESTRATION_BACKEND_PAYLOAD" }, { status: 400 });
}
const payload = body as {
orchestrationBackendOverride?: unknown;
backendId?: unknown;
requestedBackendId?: unknown;
};
const hasOrchestrationBackendOverride = Object.prototype.hasOwnProperty.call(
payload,
"orchestrationBackendOverride",
);
const hasBackendId = Object.prototype.hasOwnProperty.call(payload, "backendId");
const hasRequestedBackendId = Object.prototype.hasOwnProperty.call(payload, "requestedBackendId");
if (!hasOrchestrationBackendOverride && !hasBackendId && !hasRequestedBackendId) {
return NextResponse.json({ ok: false, message: "INVALID_ORCHESTRATION_BACKEND_PAYLOAD" }, { status: 400 });
}
const requestedBackendId = normalizeRequestedBackendId(
hasOrchestrationBackendOverride
? payload.orchestrationBackendOverride
: hasRequestedBackendId
? payload.requestedBackendId
: payload.backendId,
);
try {
await updateProjectOrchestrationBackend(projectId, requestedBackendId);
const state = await getProjectOrchestrationBackendState(projectId);
if (!state) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json({ ok: true, ...state });
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: error instanceof Error && error.message === "PROJECT_NOT_FOUND" ? 404 : 400 },
);
}
}

View File

@@ -9,10 +9,15 @@ import {
MasterIdentityPill,
PageNav,
ProjectHeaderActions,
ProjectOrchestrationBackendCard,
StatusBar,
} from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { listDispatchPlansByProject, readState } from "@/lib/boss-data";
import {
getProjectOrchestrationBackendState,
listDispatchPlansByProject,
readState,
} from "@/lib/boss-data";
import { resolveDispatchPlanComposerState } from "@/lib/dispatch-plan-ui";
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
@@ -30,6 +35,9 @@ export default async function ProjectChatPage({
const dispatchPlanState = detail?.project.isGroup
? resolveDispatchPlanComposerState(await listDispatchPlansByProject(projectId))
: resolveDispatchPlanComposerState([]);
const orchestrationBackendState = detail?.project.isGroup
? await getProjectOrchestrationBackendState(projectId)
: null;
if (!detail) notFound();
@@ -76,6 +84,14 @@ export default async function ProjectChatPage({
<div className="pt-3">
<ProjectHeaderActions projectId={detail.project.id} />
</div>
{detail.project.isGroup && orchestrationBackendState ? (
<div className="mt-3">
<ProjectOrchestrationBackendCard
projectId={detail.project.id}
initialState={orchestrationBackendState}
/>
</div>
) : null}
<div className="mt-4 space-y-3">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]"> Agent </div>

View File

@@ -29,6 +29,8 @@ import type {
OtaUpdateLog,
OpsRepairTicket,
OpsRepairVerification,
ProjectOrchestrationBackendState,
OrchestrationBackendId,
ThreadContextSnapshot,
UserProfile,
UserSettings,
@@ -781,6 +783,172 @@ export function ProjectHeaderActions({ projectId }: { projectId: string }) {
);
}
function orchestrationBackendChoiceLabel(choice: ProjectOrchestrationBackendState["availableChoices"][number]) {
return choice.backendId === "boss-native-orchestrator"
? "Boss Native Orchestrator"
: "OMX Team Runtime";
}
function normalizeOrchestrationReasonLabel(value: string) {
const trimmed = value.trim();
if (trimmed.endsWith("。") || trimmed.endsWith(".")) {
return trimmed.slice(0, -1);
}
return trimmed;
}
function orchestrationBackendAvailabilityCopy(
state: ProjectOrchestrationBackendState,
fallbackActive: boolean,
) {
if (state.omxAvailability.selectable) {
return {
badge: "正常",
summary: "OMX Team Runtime 当前可用,当前可切换到该后端。",
};
}
return {
badge: fallbackActive ? "已回退" : "OMX 受限",
summary: fallbackActive
? `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},当前已自动回退到 Boss Native Orchestrator。`
: `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},切换后会自动回退到 Boss Native Orchestrator。`,
};
}
export function ProjectOrchestrationBackendCard({
projectId,
initialState,
}: {
projectId: string;
initialState: ProjectOrchestrationBackendState;
}) {
const router = useRouter();
const [state, setState] = useState(initialState);
const [savingBackendId, setSavingBackendId] = useState<OrchestrationBackendId | null>(null);
const [message, setMessage] = useState("");
const fallbackActive = state.requestedBackendId !== state.currentBackendId;
const availabilityCopy = orchestrationBackendAvailabilityCopy(state, fallbackActive);
async function saveBackend(requestedBackendId: OrchestrationBackendId) {
setSavingBackendId(requestedBackendId);
const response = await fetch(`/api/v1/projects/${projectId}/orchestration-backend`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestedBackendId }),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
currentBackendId?: OrchestrationBackendId;
currentBackendLabel?: string;
requestedBackendId?: OrchestrationBackendId;
requestedBackendLabel?: string;
availableChoices?: ProjectOrchestrationBackendState["availableChoices"];
omxAvailability?: ProjectOrchestrationBackendState["omxAvailability"];
};
setSavingBackendId(null);
if (
!result.ok ||
!result.currentBackendId ||
!result.currentBackendLabel ||
!result.requestedBackendId ||
!result.requestedBackendLabel ||
!result.availableChoices ||
!result.omxAvailability
) {
setMessage(result.message ?? "保存失败");
return;
}
setState({
projectId,
currentBackendId: result.currentBackendId,
currentBackendLabel: result.currentBackendLabel,
requestedBackendId: result.requestedBackendId,
requestedBackendLabel: result.requestedBackendLabel,
availableChoices: result.availableChoices,
omxAvailability: result.omxAvailability,
});
setMessage(
requestedBackendId === "omx-team"
? "已切换到 OMX Team Runtime。"
: "已切换回 Boss Native Orchestrator。",
);
router.refresh();
}
return (
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[14px] font-semibold text-[#111111]"></div>
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">
{state.currentBackendLabel}
<br />
{state.requestedBackendLabel}
</div>
</div>
<div
className={clsx(
"rounded-full px-3 py-1 text-[11px] font-semibold",
fallbackActive || !state.omxAvailability.selectable
? "bg-[#FFF7E6] text-[#D46B08]"
: "bg-[#EAF7F0] text-[#215B39]",
)}
>
{availabilityCopy.badge}
</div>
</div>
<div className="mt-3 grid gap-2">
{state.availableChoices.map((choice) => {
const active = choice.current;
const selectable = choice.selectable && savingBackendId !== choice.backendId;
return (
<button
key={choice.backendId}
type="button"
onClick={() => void saveBackend(choice.backendId)}
disabled={!selectable}
className={clsx(
"flex items-center justify-between rounded-2xl border px-4 py-3 text-left",
active ? "border-[#07C160] bg-[#F5FFF8]" : "border-[#E5E5EA] bg-[#F7F8FA]",
!choice.selectable ? "opacity-70" : "",
)}
>
<div>
<div className="text-[14px] font-semibold text-[#111111]">
{orchestrationBackendChoiceLabel(choice)}
</div>
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">{choice.label}</div>
</div>
<div className="text-right text-[11px] text-[#8C8C8C]">
<div>{active ? "当前" : "切换"}</div>
{!choice.selectable ? <div></div> : null}
</div>
</button>
);
})}
</div>
<div
className={clsx(
"mt-3 rounded-2xl px-4 py-3 text-[12px] leading-6",
state.omxAvailability.selectable
? "bg-[#EAF7F0] text-[#215B39]"
: "bg-[#FFF7E6] text-[#8D5D00]",
)}
>
{availabilityCopy.summary}
</div>
{message ? (
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">
{message}
</div>
) : null}
</div>
);
}
function masterIdentityPillClasses(role: MasterIdentitySummary["role"]) {
switch (role) {
case "primary":

View File

@@ -6,6 +6,13 @@ import { publishBossEvent } from "@/lib/boss-events";
import type { VerificationDeliveryMode } from "@/lib/boss-mail";
import { getFixedVerificationCode, getVerificationDeliveryMode } from "@/lib/boss-mail";
import { getPublishedOtaAsset } from "@/lib/boss-ota";
import { BOSS_NATIVE_ORCHESTRATOR } from "@/lib/execution/backends/boss-native-orchestrator";
import {
OMX_TEAM_BACKEND,
getOmxTeamBackendSelectionState,
type OmxTeamBackendSelectionState,
} from "@/lib/execution/backends/omx-team-backend";
import { selectOrchestrationBackend } from "@/lib/execution/orchestration-backend-selector";
export type DeviceStatus = "online" | "abnormal" | "offline";
export type DeviceSource = "production" | "demo";
@@ -144,6 +151,8 @@ export type DispatchPlanStatus =
| "dispatched";
export type DispatchExecutionStatus = "queued" | "running" | "completed" | "failed";
export type ReasoningEffort = "low" | "medium" | "high";
export type OrchestrationBackendId = import("@/lib/execution/orchestration-backend").OrchestrationBackendId;
export type OrchestrationBackendOverride = "omx-team";
export interface UserSettings {
liveUpdates: boolean;
@@ -305,6 +314,7 @@ export interface Project {
createdByAgent: boolean;
collaborationMode: "development" | "approval_required";
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
orchestrationBackendOverride?: OrchestrationBackendOverride;
agentControls?: ProjectAgentControls;
unreadCount: number;
riskLevel: RiskLevel;
@@ -334,6 +344,10 @@ export interface DispatchPlan {
status: DispatchPlanStatus;
targets: DispatchPlanTarget[];
summary: string;
requestedOrchestrationBackendId?: OrchestrationBackendOverride;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
orchestrationFallbackReason?: string;
createdAt: string;
confirmedAt?: string;
confirmedBy?: string;
@@ -347,6 +361,8 @@ export interface DispatchExecution {
targetProjectId: string;
targetThreadId: string;
deviceId: string;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
status: DispatchExecutionStatus;
createdAt: string;
completedAt?: string;
@@ -382,6 +398,23 @@ export interface ProjectAgentControls {
updatedAt: string;
}
export interface ProjectOrchestrationBackendChoice {
backendId: OrchestrationBackendId;
label: string;
selectable: boolean;
current: boolean;
}
export interface ProjectOrchestrationBackendState {
projectId: string;
requestedBackendId: OrchestrationBackendId;
currentBackendId: OrchestrationBackendId;
currentBackendLabel: string;
requestedBackendLabel: string;
availableChoices: ProjectOrchestrationBackendChoice[];
omxAvailability: OmxTeamBackendSelectionState["availability"];
}
export interface UserProjectAgentControls {
account: string;
projectId: string;
@@ -607,6 +640,8 @@ export interface MasterAgentTask {
targetThreadDisplayName?: string;
targetCodexThreadRef?: string;
targetCodexFolderRef?: string;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
deviceImportDraftId?: string;
status: MasterAgentTaskStatus;
requestedAt: string;
@@ -1726,6 +1761,12 @@ function parseBackendOverride(value: unknown) {
return { kind: "set" as const, value: "claw-runtime" as const };
}
function normalizeOrchestrationBackendOverride(
value: unknown,
): OrchestrationBackendOverride | undefined {
return value === "omx-team" ? "omx-team" : undefined;
}
function normalizeStringSet(values: string[]) {
return dedupeStrings(values.map((value) => value.trim()).filter(Boolean)).sort((a, b) => a.localeCompare(b));
}
@@ -1913,6 +1954,20 @@ function normalizeDispatchPlan(raw: Partial<DispatchPlan>, fallback?: DispatchPl
status: raw.status ?? fallback?.status ?? "pending_user_confirmation",
targets,
summary: raw.summary ?? fallback?.summary ?? "",
requestedOrchestrationBackendId: normalizeOrchestrationBackendOverride(
raw.requestedOrchestrationBackendId ?? fallback?.requestedOrchestrationBackendId,
),
orchestrationBackendId:
raw.orchestrationBackendId === "omx-team" || raw.orchestrationBackendId === "boss-native-orchestrator"
? raw.orchestrationBackendId
: fallback?.orchestrationBackendId ?? "boss-native-orchestrator",
orchestrationBackendLabel:
trimToDefined(raw.orchestrationBackendLabel) ??
trimToDefined(fallback?.orchestrationBackendLabel) ??
(raw.orchestrationBackendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator"),
orchestrationFallbackReason:
trimToDefined(raw.orchestrationFallbackReason) ??
trimToDefined(fallback?.orchestrationFallbackReason),
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
confirmedAt: raw.confirmedAt ?? fallback?.confirmedAt,
confirmedBy: raw.confirmedBy ?? fallback?.confirmedBy,
@@ -1936,6 +1991,14 @@ function normalizeDispatchExecution(
targetProjectId: raw.targetProjectId ?? fallback?.targetProjectId ?? "",
targetThreadId: raw.targetThreadId ?? fallback?.targetThreadId ?? "",
deviceId: raw.deviceId ?? fallback?.deviceId ?? "",
orchestrationBackendId:
raw.orchestrationBackendId === "omx-team" || raw.orchestrationBackendId === "boss-native-orchestrator"
? raw.orchestrationBackendId
: fallback?.orchestrationBackendId ?? "boss-native-orchestrator",
orchestrationBackendLabel:
trimToDefined(raw.orchestrationBackendLabel) ??
trimToDefined(fallback?.orchestrationBackendLabel) ??
(raw.orchestrationBackendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator"),
status: raw.status ?? fallback?.status ?? "queued",
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
completedAt: raw.completedAt ?? fallback?.completedAt,
@@ -2714,6 +2777,7 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
createdByAgent: raw.createdByAgent ?? false,
collaborationMode: raw.collaborationMode ?? "development",
approvalState: raw.approvalState ?? "not_required",
orchestrationBackendOverride: normalizeOrchestrationBackendOverride(raw.orchestrationBackendOverride),
agentControls: normalizeProjectAgentControls(raw.agentControls),
};
project.groupMembers = ensureArray(raw.groupMembers, []).map((member) =>
@@ -2846,6 +2910,11 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
targetProjectId: task.targetProjectId,
targetThreadId: task.targetThreadId,
targetThreadDisplayName: task.targetThreadDisplayName,
orchestrationBackendId:
task.orchestrationBackendId === "omx-team" || task.orchestrationBackendId === "boss-native-orchestrator"
? task.orchestrationBackendId
: undefined,
orchestrationBackendLabel: task.orchestrationBackendLabel,
deviceImportDraftId: task.deviceImportDraftId,
status: task.status ?? "queued",
requestedAt: task.requestedAt ?? nowIso(),
@@ -3584,6 +3653,33 @@ export async function getProject(projectId: string) {
return state.projects.find((project) => project.id === projectId) ?? null;
}
export async function updateProjectOrchestrationBackendOverride(input: {
projectId: string;
requestedBy: string;
orchestrationBackendOverride?: OrchestrationBackendOverride;
}) {
return mutateState((state) => {
const project = state.projects.find((item) => item.id === input.projectId);
if (!project) {
throw new Error("PROJECT_NOT_FOUND");
}
if (!project.isGroup) {
throw new Error("PROJECT_NOT_GROUP_CHAT");
}
requireDispatchActorSession(state, input.requestedBy);
const nextOverride = input.orchestrationBackendOverride;
if (project.orchestrationBackendOverride === nextOverride) {
return project;
}
project.orchestrationBackendOverride = nextOverride;
project.updatedAt = nowIso();
project.threadMeta.updatedAt = project.updatedAt;
return project;
});
}
export async function hasPersistedProject(projectId: string) {
const rawState = await loadPersistedStateRaw();
return Array.isArray(rawState.projects) && rawState.projects.some((project) => project?.id === projectId);
@@ -3731,6 +3827,87 @@ export async function updateProjectAgentControls(
});
}
function projectOrchestrationRequestedBackendId(project: Project): OrchestrationBackendId {
return project.orchestrationBackendOverride ?? "boss-native-orchestrator";
}
async function buildProjectOrchestrationBackendState(
project: Project,
): Promise<ProjectOrchestrationBackendState> {
const requestedBackendId = projectOrchestrationRequestedBackendId(project);
const omxSelection = await getOmxTeamBackendSelectionState();
const currentBackend = await selectOrchestrationBackend({
requestedBackendId,
omx: omxSelection,
});
const nativeBackend = await BOSS_NATIVE_ORCHESTRATOR.describe();
const omxBackend = await OMX_TEAM_BACKEND.describe();
const availableChoices: ProjectOrchestrationBackendChoice[] = [
{
backendId: nativeBackend.backendId as OrchestrationBackendId,
label: nativeBackend.label,
selectable: true,
current: currentBackend.backendId === nativeBackend.backendId,
},
{
backendId: omxBackend.backendId as OrchestrationBackendId,
label: omxBackend.label,
selectable: omxSelection.selectable,
current: currentBackend.backendId === omxBackend.backendId,
},
];
return {
projectId: project.id,
requestedBackendId,
currentBackendId: currentBackend.backendId as OrchestrationBackendId,
currentBackendLabel:
availableChoices.find((choice) => choice.backendId === currentBackend.backendId)?.label ??
nativeBackend.label,
requestedBackendLabel:
availableChoices.find((choice) => choice.backendId === requestedBackendId)?.label ??
nativeBackend.label,
availableChoices,
omxAvailability: omxSelection.availability,
};
}
export async function getProjectOrchestrationBackendState(
projectId: string,
): Promise<ProjectOrchestrationBackendState | null> {
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
}
return buildProjectOrchestrationBackendState(project);
}
export async function updateProjectOrchestrationBackend(
projectId: string,
requestedBackendId: OrchestrationBackendId,
) {
return mutateStateIfChanged(async (state) => {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
throw new Error("PROJECT_NOT_FOUND");
}
const nextOverride =
requestedBackendId === "boss-native-orchestrator" ? undefined : "omx-team";
const currentRequestedBackendId = projectOrchestrationRequestedBackendId(project);
if (currentRequestedBackendId === requestedBackendId && project.orchestrationBackendOverride === nextOverride) {
return { result: project.orchestrationBackendOverride ?? null, changed: false };
}
project.orchestrationBackendOverride = nextOverride;
const updatedAt = nowIso();
project.updatedAt = updatedAt;
project.threadMeta.updatedAt = updatedAt;
return { result: project.orchestrationBackendOverride ?? null, changed: true };
});
}
export async function getDevice(deviceId: string) {
const state = await readState();
return state.devices.find((device) => device.id === deviceId) ?? null;
@@ -4914,6 +5091,8 @@ export async function queueMasterAgentTask(payload: {
targetThreadDisplayName?: string;
targetCodexThreadRef?: string;
targetCodexFolderRef?: string;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
}) {
const task = await mutateState((state) => {
const task: MasterAgentTask = {
@@ -4941,6 +5120,8 @@ export async function queueMasterAgentTask(payload: {
targetThreadDisplayName: payload.targetThreadDisplayName,
targetCodexThreadRef: payload.targetCodexThreadRef,
targetCodexFolderRef: payload.targetCodexFolderRef,
orchestrationBackendId: payload.orchestrationBackendId,
orchestrationBackendLabel: payload.orchestrationBackendLabel,
status: "queued",
requestedAt: nowIso(),
};
@@ -4961,6 +5142,10 @@ export async function createDispatchPlan(input: {
requestedBy: string;
summary?: string;
targets: DispatchPlanTarget[];
requestedOrchestrationBackendId?: OrchestrationBackendOverride;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
orchestrationFallbackReason?: string;
}) {
return mutateState((state) => {
return upsertDispatchPlanInState(state, input);
@@ -4975,12 +5160,22 @@ function upsertDispatchPlanInState(
requestedBy: string;
summary?: string;
targets: DispatchPlanTarget[];
requestedOrchestrationBackendId?: OrchestrationBackendOverride;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
orchestrationFallbackReason?: string;
},
) {
const groupProjectId = input.groupProjectId.trim();
const requestMessageId = input.requestMessageId.trim();
const requestedBy = input.requestedBy.trim();
const summary = input.summary?.trim() ?? "";
const requestedOrchestrationBackendId = input.requestedOrchestrationBackendId;
const orchestrationBackendId = input.orchestrationBackendId ?? "boss-native-orchestrator";
const orchestrationBackendLabel =
trimToDefined(input.orchestrationBackendLabel) ??
(orchestrationBackendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator");
const orchestrationFallbackReason = trimToDefined(input.orchestrationFallbackReason);
if (!groupProjectId) throw new Error("DISPATCH_PLAN_GROUP_PROJECT_REQUIRED");
if (!requestMessageId) throw new Error("DISPATCH_PLAN_REQUEST_MESSAGE_REQUIRED");
@@ -4997,7 +5192,11 @@ function upsertDispatchPlanInState(
const payloadMatches =
existing.requestedBy === requestedBy &&
existing.summary === summary &&
sameDispatchPlanTargets(existing.targets, validatedTargets);
sameDispatchPlanTargets(existing.targets, validatedTargets) &&
existing.requestedOrchestrationBackendId === requestedOrchestrationBackendId &&
existing.orchestrationBackendId === orchestrationBackendId &&
existing.orchestrationBackendLabel === orchestrationBackendLabel &&
existing.orchestrationFallbackReason === orchestrationFallbackReason;
if (!payloadMatches) {
throw new Error("DISPATCH_PLAN_RETRY_MISMATCH");
}
@@ -5015,6 +5214,10 @@ function upsertDispatchPlanInState(
status: "pending_user_confirmation",
targets: validatedTargets,
summary,
requestedOrchestrationBackendId,
orchestrationBackendId,
orchestrationBackendLabel,
orchestrationFallbackReason,
createdAt: nowIso(),
};
state.dispatchPlans.unshift(plan);
@@ -5168,6 +5371,10 @@ export async function createDispatchExecutionsFromPlan(input: {
if (!sameStringSet(existingTargetIds, canonicalTargetProjectIds)) {
throw new Error("DISPATCH_EXECUTION_SET_MISMATCH");
}
for (const execution of existingExecutions) {
execution.orchestrationBackendId = execution.orchestrationBackendId ?? plan.orchestrationBackendId;
execution.orchestrationBackendLabel = execution.orchestrationBackendLabel ?? plan.orchestrationBackendLabel;
}
if (plan.status !== "dispatched") {
plan.status = "dispatched";
}
@@ -5192,6 +5399,8 @@ export async function createDispatchExecutionsFromPlan(input: {
targetProjectId: target.projectId,
targetThreadId: target.threadId,
deviceId: target.deviceId,
orchestrationBackendId: plan.orchestrationBackendId,
orchestrationBackendLabel: plan.orchestrationBackendLabel,
status: "queued",
createdAt,
};
@@ -5267,6 +5476,8 @@ function ensureDispatchExecutionTaskInState(
existing.targetThreadDisplayName = existing.targetThreadDisplayName ?? target.threadDisplayName;
existing.targetCodexThreadRef = existing.targetCodexThreadRef ?? target.codexThreadRef;
existing.targetCodexFolderRef = existing.targetCodexFolderRef ?? target.codexFolderRef;
existing.orchestrationBackendId = existing.orchestrationBackendId ?? execution.orchestrationBackendId;
existing.orchestrationBackendLabel = existing.orchestrationBackendLabel ?? execution.orchestrationBackendLabel;
existing.executionPrompt =
existing.executionPrompt ||
buildDispatchExecutionPrompt({
@@ -5299,6 +5510,8 @@ function ensureDispatchExecutionTaskInState(
targetThreadDisplayName: target.threadDisplayName,
targetCodexThreadRef: target.codexThreadRef,
targetCodexFolderRef: target.codexFolderRef,
orchestrationBackendId: execution.orchestrationBackendId,
orchestrationBackendLabel: execution.orchestrationBackendLabel,
status: "queued",
requestedAt: nowIso(),
};
@@ -5346,6 +5559,10 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
if (!sameStringSet(existingTargetIds, canonicalTargetProjectIds)) {
throw new Error("DISPATCH_EXECUTION_SET_MISMATCH");
}
for (const execution of existingExecutions) {
execution.orchestrationBackendId = execution.orchestrationBackendId ?? plan.orchestrationBackendId;
execution.orchestrationBackendLabel = execution.orchestrationBackendLabel ?? plan.orchestrationBackendLabel;
}
if (plan.status !== "dispatched") {
plan.status = "dispatched";
}
@@ -5368,6 +5585,8 @@ export async function confirmDispatchPlanAndCreateExecutions(input: {
targetProjectId: target.projectId,
targetThreadId: target.threadId,
deviceId: target.deviceId,
orchestrationBackendId: plan.orchestrationBackendId,
orchestrationBackendLabel: plan.orchestrationBackendLabel,
status: "queued",
createdAt,
};
@@ -5684,6 +5903,10 @@ export async function completeMasterAgentTask(payload: {
dispatchPlan?: {
summary?: string;
targets: DispatchPlanTarget[];
requestedOrchestrationBackendId?: OrchestrationBackendOverride;
orchestrationBackendId?: OrchestrationBackendId;
orchestrationBackendLabel?: string;
orchestrationFallbackReason?: string;
};
}) {
const result = await mutateState((state) => {
@@ -5776,6 +5999,10 @@ export async function completeMasterAgentTask(payload: {
requestedBy: task.requestedByAccount,
summary: payload.dispatchPlan.summary,
targets: payload.dispatchPlan.targets,
requestedOrchestrationBackendId: payload.dispatchPlan.requestedOrchestrationBackendId,
orchestrationBackendId: payload.dispatchPlan.orchestrationBackendId,
orchestrationBackendLabel: payload.dispatchPlan.orchestrationBackendLabel,
orchestrationFallbackReason: payload.dispatchPlan.orchestrationFallbackReason,
});
}
} else if (task.taskType === "device_import_resolution") {

View File

@@ -34,7 +34,9 @@ import {
createClawBackend,
getClawBackendSelectionState,
} from "@/lib/execution/backends/claw-backend";
import { getOmxTeamBackendSelectionState } from "@/lib/execution/backends/omx-team-backend";
import { listExecutionBackendChoices, selectExecutionBackend } from "@/lib/execution/backend-selector";
import { selectOrchestrationBackend } from "@/lib/execution/orchestration-backend-selector";
import { resolveRuntimeRelevantMemories } from "@/lib/execution/memory-resolver";
import type { RelevantMemory } from "@/lib/execution/memory-resolver";
import { buildExecutionPrompt } from "@/lib/execution/prompt-assembler";
@@ -1223,6 +1225,25 @@ type GroupDispatchRecommendationResult =
error: string;
};
async function resolveGroupOrchestrationBackend(project: Project) {
const requestedBackendId = project.orchestrationBackendOverride;
const omx = await getOmxTeamBackendSelectionState();
const selectedBackend = await selectOrchestrationBackend({
requestedBackendId,
omx,
});
const description = await selectedBackend.describe();
return {
requestedBackendId,
orchestrationBackendId: description.backendId,
orchestrationBackendLabel: description.label,
orchestrationFallbackReason:
requestedBackendId === "omx-team" && description.backendId !== "omx-team"
? omx.availability.reasonLabel
: undefined,
};
}
async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispatchRecommendationResult> {
const task = await getMasterAgentTask(taskId);
if (!task) {
@@ -1246,6 +1267,7 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispat
if (targets.length === 0) {
throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED");
}
const orchestrationBackend = await resolveGroupOrchestrationBackend(project);
const completedTask = await completeMasterAgentTask({
taskId: task.taskId,
@@ -1254,6 +1276,10 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispat
dispatchPlan: {
summary: summarizeGroupDispatchPlan(task.requestText, targets),
targets,
requestedOrchestrationBackendId: orchestrationBackend.requestedBackendId,
orchestrationBackendId: orchestrationBackend.orchestrationBackendId,
orchestrationBackendLabel: orchestrationBackend.orchestrationBackendLabel,
orchestrationFallbackReason: orchestrationBackend.orchestrationFallbackReason,
},
});
@@ -1296,6 +1322,8 @@ export async function queueGroupDispatchPlan(params: {
throw new Error("PROJECT_NOT_GROUP_CHAT");
}
const orchestrationBackend = await resolveGroupOrchestrationBackend(project);
const task = await queueMasterAgentTask({
projectId: project.id,
taskType: "group_dispatch_plan",
@@ -1305,6 +1333,8 @@ export async function queueGroupDispatchPlan(params: {
requestedBy: params.requestedBy,
requestedByAccount: params.requestedBy,
deviceId: state.user.boundDeviceId || "mac-studio",
orchestrationBackendId: orchestrationBackend.orchestrationBackendId,
orchestrationBackendLabel: orchestrationBackend.orchestrationBackendLabel,
});
return resolveGroupDispatchPlanTask(task.taskId);

View File

@@ -3,7 +3,13 @@ import {
OMX_TEAM_BACKEND,
type OmxTeamBackendSelectionState,
} from "@/lib/execution/backends/omx-team-backend";
import type { OrchestrationBackend } from "@/lib/execution/orchestration-backend";
import {
labelForOrchestrationBackend,
normalizeOrchestrationBackendId,
type OrchestrationBackend,
type OrchestrationBackendChoiceView,
type OrchestrationBackendSelectionState,
} from "@/lib/execution/orchestration-backend";
export interface OrchestrationBackendSelectionInput {
requestedBackendId?: string;
@@ -24,10 +30,27 @@ function isReadyBackend(
return true;
}
function getRequestedBackendId(input: OrchestrationBackendSelectionInput) {
return normalizeOrchestrationBackendId(input.requestedBackendId);
}
function isSelectableBackend(
backendId: string,
input: OrchestrationBackendSelectionInput,
) {
if (backendId === OMX_TEAM_BACKEND.backendId) {
return input.omx?.selectable ?? false;
}
return true;
}
export async function selectOrchestrationBackend(
input: OrchestrationBackendSelectionInput = {},
): Promise<OrchestrationBackendChoice> {
return (await listOrchestrationBackendChoices(input))[0] ?? BOSS_NATIVE_ORCHESTRATOR;
const resolution = await resolveOrchestrationBackendSelection(input);
return resolution.currentBackendId === OMX_TEAM_BACKEND.backendId
? OMX_TEAM_BACKEND
: BOSS_NATIVE_ORCHESTRATOR;
}
export async function listOrchestrationBackendChoices(
@@ -60,4 +83,45 @@ export async function listOrchestrationBackendChoices(
return ordered;
}
export async function listOrchestrationBackendChoiceViews(
input: OrchestrationBackendSelectionInput = {},
): Promise<OrchestrationBackendChoiceView[]> {
const requestedBackendId = getRequestedBackendId(input);
const omxSelectable = isSelectableBackend(OMX_TEAM_BACKEND.backendId, input);
const choices = await listOrchestrationBackendChoices(input);
return choices.map((backend) => ({
backendId: backend.backendId,
label: labelForOrchestrationBackend(backend.backendId),
selectable: backend.backendId === OMX_TEAM_BACKEND.backendId ? omxSelectable : true,
current: backend.backendId === requestedBackendId && (!omxSelectable || backend.backendId === requestedBackendId)
? true
: backend.backendId === BOSS_NATIVE_ORCHESTRATOR.backendId && requestedBackendId !== OMX_TEAM_BACKEND.backendId,
}));
}
export async function resolveOrchestrationBackendSelection(
input: OrchestrationBackendSelectionInput = {},
): Promise<OrchestrationBackendSelectionState> {
const requestedBackendId = getRequestedBackendId(input);
const omxAvailability = input.omx?.availability;
const omxSelectable = input.omx?.selectable ?? false;
const omxRequested = requestedBackendId === OMX_TEAM_BACKEND.backendId;
const currentBackendId =
omxRequested && omxSelectable ? OMX_TEAM_BACKEND.backendId : BOSS_NATIVE_ORCHESTRATOR.backendId;
return {
requestedBackendId,
currentBackendId,
resolvedAt: new Date().toISOString(),
...(omxAvailability ? { omxAvailability } : {}),
...(omxRequested && !omxSelectable
? {
fallbackReason: "omx-team unavailable",
fallbackReasonLabel:
omxAvailability?.reasonLabel ??
"OMX Team Runtime 当前不可用,已自动回退到 Boss Native Orchestrator。",
}
: {}),
};
}
export const selectOrchestrationBackendForTesting = selectOrchestrationBackend;

View File

@@ -1,4 +1,34 @@
import type { OmxTeamBackendAvailability } from "@/lib/execution/backends/omx-team-config";
export type OrchestrationBackendId = "boss-native-orchestrator" | "omx-team";
export interface OrchestrationBackend {
backendId: string;
describe(): Promise<{ backendId: string; label: string }>;
backendId: OrchestrationBackendId;
describe(): Promise<{ backendId: OrchestrationBackendId; label: string }>;
}
export interface OrchestrationBackendChoiceView {
backendId: OrchestrationBackendId;
label: string;
selectable: boolean;
current: boolean;
}
export interface OrchestrationBackendSelectionState {
requestedBackendId: OrchestrationBackendId;
currentBackendId: OrchestrationBackendId;
resolvedAt: string;
fallbackReason?: string;
fallbackReasonLabel?: string;
omxAvailability?: OmxTeamBackendAvailability;
}
export function normalizeOrchestrationBackendId(
backendId?: string | null,
): OrchestrationBackendId {
return backendId?.trim() === "omx-team" ? "omx-team" : "boss-native-orchestrator";
}
export function labelForOrchestrationBackend(backendId: OrchestrationBackendId) {
return backendId === "omx-team" ? "OMX Team Runtime" : "Boss Native Orchestrator";
}