fix: avoid MySQL thread_meta refresh races and harden roundtable bootstrap

Skip post-commit ORM refresh on threads_meta creates so concurrent MySQL
read/write-split deployments no longer fail with Could not refresh instance;
clean up partial roundtable threads on checkpoint failure, and ship related
position-roundtable/frontend build fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-07-31 21:00:56 +08:00
1 parent e52261fc3d
commit a9d66536c3
12 files changed
+503 -127

No files matched your search

@@ -168,13 +168,6 @@ function BusinessChainCanvas({
className="flex min-h-full min-w-max flex-col items-center px-5 py-6"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
<FlowNode
title="任务理解智能体"
description="任务理解岗"
accent={NODE_COLORS[0]}
completed
width={nodeWidth}
/>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
@@ -182,12 +175,14 @@ function BusinessChainCanvas({
);
return (
<div key={`${stage.goal ?? "stage"}-${stageIndex}`} className="flex flex-col items-center">
<FlowConnector
branches={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
/>
{stageIndex > 0 && (
<FlowConnector
branches={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
/>
)}
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat) => {
const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!;
@@ -205,20 +200,6 @@ function BusinessChainCanvas({
</div>
);
})}
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<FlowNode
title="方案总结智能体"
description="内置总结节点"
accent="#8b83f5"
width={nodeWidth}
/>
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<FlowNode
title="行动规划智能体"
description="内置规划节点"
accent="#78bce6"
width={nodeWidth}
/>
</div>
</div>
);
@@ -61,6 +61,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/contexts/ThemeContext";
import { isEmbeddedHost } from "@/core/embed/frame-context";
import { emitWujieEvent, isWujieEnv } from "@/core/embed/wujie";
import { LayoutModeSwitcher, ThemeSwitcher } from "@/components/workspace/appearance-controls";
import { getAPIClient } from "@/core/api/api-client";
import { useAgent } from "@/core/agents";
@@ -167,6 +169,131 @@ type SandboxThreadSnapshot = {
signature: string;
};
type PositionRoundtableParentReturnMessage = {
source: "deerflow-position-roundtable";
type: "deerflow:position-roundtable:return";
payload: {
route: string | null;
fallback: "history-back";
};
};
type PositionRoundtableBridgeInitMessage = {
source: "code-zk-roundtable-host";
type: "code-zk:roundtable-bridge:init";
};
const POSITION_ROUNDTABLE_BRIDGE_SOURCE = "code-zk-roundtable-host";
const POSITION_ROUNDTABLE_BRIDGE_INIT_TYPE = "code-zk:roundtable-bridge:init";
const POSITION_ROUNDTABLE_BRIDGE_READY_TYPE = "deerflow:position-roundtable:bridge-ready";
function isEnabledEmbedFlag(value: string | null): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes";
}
/**
* Some hosts only retain URL parameters after the login redirect. Treat those
* established embed markers as an embedded context as well as checking the
* actual browser frame/Wujie runtime.
*/
function hasEmbeddedRouteHint(searchParams: URLSearchParams): boolean {
return (
searchParams.get("from") === "iframe" ||
searchParams.get("chatMode")?.trim().toLowerCase() === "iframe" ||
isEnabledEmbedFlag(searchParams.get("embed")) ||
isEnabledEmbedFlag(searchParams.get("hideChrome")) ||
isEnabledEmbedFlag(searchParams.get("hideHeader"))
);
}
/**
* `parentReturnTo` belongs to the embedding application, not this SPA. Keep
* it constrained to a route/hash path so the same-origin fallback can never
* turn an externally supplied URL into an arbitrary top-level navigation.
*/
function parentReturnRoute(value: string | null): string | null {
const route = value?.trim();
if (!route || route.length > 2048) return null;
if ((!route.startsWith("/") && !route.startsWith("#")) || route.startsWith("//") || route.includes("\\")) {
return null;
}
return route;
}
/**
* Request navigation from the embedding host. A real iframe parent is always
* the nearest integration boundary, even if an outer layer happens to be
* Wujie. This matters for Wujie host -> code-zk -> DeerFlow iframe: code-zk
* owns the postMessage contract and its own router. Wujie's event bus is only
* an additional fallback for a directly mounted Wujie child.
*/
function returnToEmbeddingParent(route: string | null, bridgePort?: MessagePort | null): void {
const message: PositionRoundtableParentReturnMessage = {
source: "deerflow-position-roundtable",
type: "deerflow:position-roundtable:return",
payload: { route, fallback: "history-back" },
};
if (typeof window === "undefined") return;
// In a Wujie host -> code-zk -> DeerFlow topology, the DOM iframe can be
// re-parented by Wujie. A port transferred by code-zk survives that DOM
// topology change and is the only channel guaranteed to reach code-zk.
if (bridgePort) {
try {
bridgePort.postMessage(message);
console.info("[position-roundtable] posted return through MessageChannel bridge");
return;
} catch {
// The owner may have unmounted/reloaded. Fall back to native bridges.
}
}
console.info("[position-roundtable] embedded return requested", {
route,
hasNativeParent: window.parent !== window,
wujie: isWujieEnv(),
});
if (window.parent === window) {
// Some Wujie execution modes expose no meaningful native parent window.
// In that direct-mount case the event bus is the only available bridge.
if (isWujieEnv()) {
emitWujieEvent("position-roundtable:return", message.payload);
console.info("[position-roundtable] emitted Wujie-only return event");
}
return;
}
try {
if (window.parent.location.origin === window.location.origin) {
if (route?.startsWith("#")) {
window.parent.location.hash = route;
} else if (route) {
window.parent.location.assign(route);
} else {
window.parent.history.back();
}
console.info("[position-roundtable] completed same-origin parent return");
return;
}
} catch {
// Cross-origin parent: fall through to the explicit postMessage contract.
}
window.parent.postMessage(message, "*");
console.info("[position-roundtable] posted return message to direct parent");
// A nested iframe must never skip the direct-parent notification merely
// because Wujie markers were inherited or injected. Emitting as well keeps
// directly mounted Wujie integrations compatible when their host listens.
if (isWujieEnv()) {
emitWujieEvent("position-roundtable:return", message.payload);
console.info("[position-roundtable] emitted supplemental Wujie return event");
}
}
/**
* URL parameters are externally supplied, so never cast the raw value into
* page state without checking it against the position directory first.
@@ -609,6 +736,50 @@ export default function PositionRoundtablePage({
: rawSessionIdFromUrl;
const forceNewSession =
!standaloneDemoMode && searchParams.get("new") === "1";
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
const isEmbeddingContext = embeddedHost || hasEmbeddedRouteHint(searchParams);
const embeddingParentReturnRoute = useMemo(
() =>
parentReturnRoute(
searchParams.get("parentReturnTo") ??
searchParams.get("parentRoute") ??
searchParams.get("returnTo"),
),
[searchParamsString],
);
const embeddingBridgePortRef = useRef<MessagePort | null>(null);
const handleReturnToEmbeddingParent = useCallback(() => {
returnToEmbeddingParent(embeddingParentReturnRoute, embeddingBridgePortRef.current);
}, [embeddingParentReturnRoute]);
useEffect(() => {
const receiveBridgePort = (event: MessageEvent<PositionRoundtableBridgeInitMessage>) => {
const message = event.data;
const bridgePort = event.ports?.[0];
if (
message?.source !== POSITION_ROUNDTABLE_BRIDGE_SOURCE ||
message?.type !== POSITION_ROUNDTABLE_BRIDGE_INIT_TYPE ||
!bridgePort
) {
return;
}
embeddingBridgePortRef.current?.close();
embeddingBridgePortRef.current = bridgePort;
bridgePort.start?.();
bridgePort.postMessage({
source: "deerflow-position-roundtable",
type: POSITION_ROUNDTABLE_BRIDGE_READY_TYPE,
});
console.info("[position-roundtable] MessageChannel bridge connected");
};
window.addEventListener("message", receiveBridgePort);
return () => {
window.removeEventListener("message", receiveBridgePort);
embeddingBridgePortRef.current?.close();
embeddingBridgePortRef.current = null;
};
}, []);
const [positionSession, setPositionSession] = useState<PositionRoundtableSession | null>(null);
const positionSessionRef = useRef<PositionRoundtableSession | null>(null);
const sessionCreationRef = useRef<Promise<PositionRoundtableSession> | null>(null);
@@ -2273,6 +2444,17 @@ export default function PositionRoundtablePage({
</div>
<div className="flex shrink-0 items-center gap-2">
{isEmbeddingContext && (
<TButton
size="small"
variant="outline"
className="shrink-0 whitespace-nowrap"
icon={<ArrowLeft className="size-3.5" />}
onClick={handleReturnToEmbeddingParent}
>
返回
</TButton>
)}
<TButton
size="small"
variant="outline"
@@ -2443,14 +2625,9 @@ export default function PositionRoundtablePage({
<BusinessChainFlow
chain={activeChain}
nodes={positionSession?.nodes ?? []}
session={positionSession}
activeNodeKey={activeNodeKey}
onSelectIntent={
activePosition.isIntentPosition ? openIntentFlowDetail : undefined
}
onSelectNode={openNodeFlowDetail}
onSelectSeat={openSeatFlowDetail}
onSelectBuiltin={handleSelectBuiltinStage}
embedded
/>
)}
@@ -4215,23 +4392,17 @@ const FLOW_NODE_GAP = 10;
function BusinessChainFlow({
chain,
nodes,
session,
activeNodeKey,
onSelectIntent,
onSelectNode,
onSelectSeat,
onSelectBuiltin,
className,
embedded = false,
}: {
chain: BusinessChain | null;
nodes: PositionRoundtableNode[];
session: PositionRoundtableSession | null;
activeNodeKey: string | null;
onSelectIntent?: () => void;
onSelectNode: (node: PositionRoundtableNode) => void;
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
className?: string;
embedded?: boolean;
}) {
@@ -4287,12 +4458,9 @@ function BusinessChainFlow({
<ChainFlowCanvas
chain={chain}
nodes={nodes}
session={session}
activeNodeKey={activeNodeKey}
onSelectIntent={onSelectIntent}
onSelectNode={onSelectNode}
onSelectSeat={onSelectSeat}
onSelectBuiltin={onSelectBuiltin}
className="min-h-0 flex-1"
/>
<Dialog open={flowDialogOpen} onOpenChange={setFlowDialogOpen}>
@@ -4307,16 +4475,7 @@ function BusinessChainFlow({
<ChainFlowCanvas
chain={chain}
nodes={nodes}
session={session}
activeNodeKey={activeNodeKey}
onSelectIntent={
onSelectIntent
? () => {
onSelectIntent();
setFlowDialogOpen(false);
}
: undefined
}
onSelectNode={(node) => {
onSelectNode(node);
setFlowDialogOpen(false);
@@ -4325,10 +4484,6 @@ function BusinessChainFlow({
onSelectSeat?.(seat, stageIndex, seatIndex);
setFlowDialogOpen(false);
}}
onSelectBuiltin={(kind) => {
onSelectBuiltin(kind);
setFlowDialogOpen(false);
}}
className="h-full"
expanded
/>
@@ -4344,23 +4499,17 @@ function BusinessChainFlow({
function ChainFlowCanvas({
chain,
nodes,
session,
activeNodeKey,
onSelectIntent,
onSelectNode,
onSelectSeat,
onSelectBuiltin,
className,
expanded = false,
}: {
chain: BusinessChain;
nodes: PositionRoundtableNode[];
session: PositionRoundtableSession | null;
activeNodeKey: string | null;
onSelectIntent?: () => void;
onSelectNode: (node: PositionRoundtableNode) => void;
onSelectSeat?: (seat: BusinessChain["seats"][number], stageIndex: number, seatIndex: number) => void;
onSelectBuiltin: (kind: "summary" | "action_plan") => void;
className?: string;
expanded?: boolean;
}) {
@@ -4376,12 +4525,6 @@ function ChainFlowCanvas({
} | null>(null);
const nodeWidth = expanded ? 190 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 16 : FLOW_NODE_GAP;
const allDone = allExecutableNodesDone(nodes);
const summaryStatus = builtinNodeStatus(session?.summarySnapshot, allDone);
const actionPlanStatus = builtinNodeStatus(
session?.actionPlanSnapshot,
snapshotStatus(session?.summarySnapshot) === "done",
);
const connectorColor =
theme === "dark" ? "rgba(143, 196, 255, 0.95)" : "rgba(148, 163, 184, 0.88)";
@@ -4439,14 +4582,6 @@ function ChainFlowCanvas({
className="flex min-h-full min-w-max flex-col items-center p-5"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
<CompactChainNode
title="情报分析智能体"
description="情报分析岗"
accent="#3b82f6"
nodeStatus={nodes.length > 0 ? "done" : "ready"}
width={nodeWidth}
onClick={onSelectIntent}
/>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
@@ -4457,13 +4592,15 @@ function ChainFlowCanvas({
key={`${stage.goal ?? "stage"}-${stageIndex}`}
className="flex flex-col items-center"
>
<VerticalFlowConnector
seatCount={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
{stageIndex > 0 && (
<VerticalFlowConnector
seatCount={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
)}
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat, seatIndex) => {
const node = nodes.find(
@@ -4499,38 +4636,6 @@ function ChainFlowCanvas({
</div>
);
})}
<VerticalFlowConnector
seatCount={1}
width={nodeWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
<CompactChainNode
title="方案总结智能体"
description="内置总结节点"
accent="#6366f1"
nodeStatus={summaryStatus}
width={nodeWidth}
titleAttr="对所有有效交付产物进行方案总结"
onClick={() => onSelectBuiltin("summary")}
/>
<VerticalFlowConnector
seatCount={1}
width={nodeWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
connectorColor={connectorColor}
/>
<CompactChainNode
title="行动规划智能体"
description="内置规划节点"
accent="#0ea5e9"
nodeStatus={actionPlanStatus}
width={nodeWidth}
titleAttr="基于方案总结和岗位产物拆解子任务"
onClick={() => onSelectBuiltin("action_plan")}
/>
</div>
</div>
);
@@ -82,8 +82,11 @@ import { listAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import {
POSITION_OPTIONS,
setRuntimePositionOptions,
type PositionOption,
type PositionId,
} from "@/position-roundtable/lib/positions";
import { usePositionRoles } from "@/strategy-components/api/position-roles";
import {
filterRecommendCandidates,
@@ -160,6 +163,33 @@ export default function BusinessChainEditorPage() {
// 岗位配置只由岗位协同页入口开启,普通圆桌编辑器完全不显示也不依赖该控件。
// `returnTo` 只接受站内绝对路径,避免把导航带往外部地址。
const positionConfigMode = searchParams.get("mode") === "position";
// This is the same directory the administrator edits in “岗位角色配置”.
// The editor used to read the static POSITION_OPTIONS array below, which
// meant a newly configured role never appeared in this seat dropdown.
const { data: configuredPositionRoles = [] } = usePositionRoles({
refreshOnMountOrFocus: positionConfigMode,
});
const positionOptions = useMemo<PositionOption[]>(
() =>
configuredPositionRoles.length > 0
? [...configuredPositionRoles]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((role) => ({
id: role.id,
name: role.name,
description: role.description,
isIntentPosition: role.roleType === "intent",
enabled: role.enabled,
isBuiltin: role.isBuiltin,
sortOrder: role.sortOrder,
}))
: [...POSITION_OPTIONS],
[configuredPositionRoles],
);
// Keep flow previews and historical seat labels on the same live directory.
useEffect(() => {
setRuntimePositionOptions(positionOptions);
}, [positionOptions]);
const requestedReturnTo = searchParams.get("returnTo");
const returnPath =
positionConfigMode && requestedReturnTo?.startsWith("/")
@@ -1244,6 +1274,7 @@ export default function BusinessChainEditorPage() {
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
onSeatModeChange={setSeatModeCb}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
onSeatPositionChange={setSeatPositionCb}
onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx}
@@ -1426,6 +1457,7 @@ function StagesColumn({
onSeatSkillDirectiveChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
onSeatPositionChange,
onStageGoalChange,
goalOptimizingIdx,
@@ -1447,6 +1479,8 @@ function StagesColumn({
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
positionConfigMode: boolean;
/** Live directory from the administrator's position-role configuration. */
positionOptions: readonly PositionOption[];
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
@@ -1554,6 +1588,7 @@ function StagesColumn({
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
onSeatModeChange={onSeatModeChange}
positionConfigMode={positionConfigMode}
positionOptions={positionOptions}
onSeatPositionChange={onSeatPositionChange}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx}
@@ -1587,6 +1622,7 @@ function StageSection({
onSeatSkillDirectiveChange,
onSeatModeChange,
positionConfigMode,
positionOptions,
onSeatPositionChange,
onGoalChange,
goalOptimizing,
@@ -1609,6 +1645,7 @@ function StageSection({
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
positionConfigMode: boolean;
positionOptions: readonly PositionOption[];
onSeatPositionChange: (agentId: string, positionId: PositionId | null) => void;
onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */
@@ -1774,6 +1811,7 @@ function StageSection({
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
showPositionConfig={positionConfigMode}
positionOptions={positionOptions}
onPositionChange={(positionId) => onSeatPositionChange(seat.agent_id, positionId)}
onDetail={() => onSeatDetail(seat.agent_id, seat.name)}
/>
@@ -1801,6 +1839,7 @@ function SeatStrip({
onSkillDirectiveChange,
onSeatModeChange,
showPositionConfig,
positionOptions,
onPositionChange,
onDetail,
}: {
@@ -1822,12 +1861,41 @@ function SeatStrip({
onSeatModeChange: (mode: SeatMode | null) => void;
/** 仅岗位协同入口展示的岗位配置。 */
showPositionConfig: boolean;
/** Current position directory, injected by the business-chain editor. */
positionOptions: readonly PositionOption[];
onPositionChange: (positionId: PositionId | null) => void;
onDetail: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
const avatarType = getAvatarTypeFor(seat.agent_id, globalIndex);
const selectablePositionOptions = useMemo(() => {
const assignedId = seat.position_id ?? null;
const assigned = assignedId
? positionOptions.find((position) => position.id === assignedId)
: undefined;
const enabled = positionOptions.filter((position) => position.enabled !== false);
// Do not silently erase an archived configuration from a saved chain.
// It remains visible but cannot be selected for another seat.
const historical =
assignedId && (!assigned || assigned.enabled === false)
? [
{
label: assigned
? `${assigned.name}(已停用)`
: `${assignedId}(岗位已删除)`,
value: assignedId,
disabled: true,
},
]
: [];
return [
{ label: "未分配", value: "" },
...historical,
...enabled.map((position) => ({ label: position.name, value: position.id })),
];
}, [positionOptions, seat.position_id]);
return (
<div
ref={setNodeRef}
@@ -1880,13 +1948,7 @@ function SeatStrip({
<TSelect
size="small"
value={seat.position_id ?? ""}
options={[
{ label: "未分配", value: "" },
...POSITION_OPTIONS.map((position) => ({
label: position.name,
value: position.id,
})),
]}
options={selectablePositionOptions}
onChange={(value) =>
onPositionChange(value ? (String(value) as PositionId) : null)
}
@@ -158,12 +158,21 @@ export async function replacePositionRoles(roles: PositionRole[]): Promise<Posit
return saved;
}
export function usePositionRoles(options?: { enabled?: boolean }) {
export function usePositionRoles(options?: {
/** Whether this query should run. */
enabled?: boolean;
/**
* Consumers that edit business-chain seats need the latest shared directory
* whenever their page is opened or focused.
*/
refreshOnMountOrFocus?: boolean;
}) {
return useQuery({
queryKey: POSITION_ROLES_QUERY_KEY,
queryFn: listPositionRoles,
staleTime: 30_000,
refetchOnWindowFocus: false,
refetchOnMount: options?.refreshOnMountOrFocus ? "always" : true,
refetchOnWindowFocus: options?.refreshOnMountOrFocus ? "always" : false,
enabled: options?.enabled ?? true,
});
}