Merge branch 'master-nj' into 'master'

feat: 岗位会商命令幂等与圆桌后台作业租约调度

See merge request knowledge-base-bj/deerflow-code!22
This commit is contained in:
董宏凯 committed 2026-08-14 03:17:53 +00:00
commit 7b37415604
68 files changed
+5802 -867

No files matched your search

@@ -16,6 +16,7 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
@@ -93,13 +94,20 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings();
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
// Frontend single-round RAG writes its own source tool message, so it should
// use the same reference panel without changing the user's global RAG mode.
const hasSingleRoundRag = useMemo(
() => (thread.values.messages ?? []).some(isSingleRoundRagMessage),
// Frontend RAG modes write their own hidden source tool messages. They use
// the existing reference panel, but must not request or write reference
// batches through DeerFlow's backend APIs.
const hasFrontendRag = useMemo(
() =>
(thread.values.messages ?? []).some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[thread.values.messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
const referencesEnabled = ragModeEnabled || hasFrontendRag;
const shouldFetchBackendReferenceBatches =
referencesEnabled && !hasFrontendRag;
const { skills } = useSkills();
const {
@@ -128,7 +136,7 @@ const ChatBox: React.FC<{
);
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
useEffect(() => {
if (!referencesEnabled) {
if (!shouldFetchBackendReferenceBatches) {
setBackendReferenceBatches([]);
return;
}
@@ -164,7 +172,12 @@ const ChatBox: React.FC<{
}
});
return () => controller.abort();
}, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]);
}, [
shouldFetchBackendReferenceBatches,
thread.isLoading,
threadId,
thread.values.messages,
]);
const adaptedBackendReferenceBatches = useMemo(
() =>
adaptBackendReferenceBatches(
@@ -175,15 +188,22 @@ const ChatBox: React.FC<{
);
const referenceBatches = useMemo(
() => {
if (adaptedBackendReferenceBatches.length > 0) {
if (
shouldFetchBackendReferenceBatches &&
adaptedBackendReferenceBatches.length > 0
) {
return adaptedBackendReferenceBatches;
}
return fallbackReferenceBatches;
},
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
[
adaptedBackendReferenceBatches,
fallbackReferenceBatches,
shouldFetchBackendReferenceBatches,
],
);
useEffect(() => {
if (!referencesEnabled) {
if (!shouldFetchBackendReferenceBatches) {
return;
}
const signature = JSON.stringify({
@@ -210,7 +230,14 @@ const ChatBox: React.FC<{
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
finalBatches: debugReferenceBatches(referenceBatches),
});
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]);
}, [
adaptedBackendReferenceBatches,
backendReferenceBatches,
fallbackReferenceBatches,
referenceBatches,
shouldFetchBackendReferenceBatches,
threadId,
]);
const latestReferenceBatch =
referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]!
@@ -221,7 +248,7 @@ const ChatBox: React.FC<{
const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0;
useEffect(() => {
if (!referencesEnabled || !activeReferenceBatch) {
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
return;
}
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
@@ -230,7 +257,7 @@ const ChatBox: React.FC<{
sourceCount: activeReferenceBatch.sources.length,
sources: activeReferenceBatch.sources.map(debugReferenceSource),
});
}, [activeReferenceBatch, referencesEnabled, threadId]);
}, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]);
useEffect(() => {
if (hasCitationReferences) {
setReferencesOpen(true);
@@ -8,8 +8,13 @@ export function CitationLink({
href,
children,
className,
onReferenceClick,
onClick,
...props
}: ComponentProps<"a">) {
}: ComponentProps<"a"> & {
/** Lets a message renderer show an in-place source preview instead of scrolling. */
onReferenceClick?: (referenceId: string) => void;
}) {
const childrenText = normalizeCitationText(children);
const displayText = childrenText ?? "";
const referenceId = href?.startsWith("#reference-")
@@ -28,6 +33,11 @@ export function CitationLink({
)}
onClick={(event) => {
event.preventDefault();
if (onReferenceClick) {
onReferenceClick(referenceId);
onClick?.(event);
return;
}
const batchId =
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
?.dataset.referenceBatchId;
@@ -41,6 +51,7 @@ export function CitationLink({
target?.scrollIntoView({ behavior: "smooth", block: "center" });
};
window.setTimeout(scroll, 240);
onClick?.(event);
}}
{...props}
>
@@ -58,6 +69,7 @@ export function CitationLink({
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
onClick={onClick}
{...props}
>
{displayText || children}
@@ -210,6 +210,9 @@ export function InputBox({
const { models } = useModels();
const singleRoundRagActive =
context.execution_profile === "single_round_rag";
const multiRoundRagActive =
context.execution_profile === "multi_round_rag";
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
const modelOptions = useMemo(() => {
return singleRoundRagActive ? extraModels : models;
}, [extraModels, models, singleRoundRagActive]);
@@ -235,7 +238,12 @@ export function InputBox({
!userPreferences.hide_admin_prompt_prefix &&
recommendedQuestions.length > 0;
useEffect(() => {
useEffect(() => {
// Multi-round RAG is a frontend execution profile, not a model option.
// Do not let the normal model fallback clear it on every render.
if (multiRoundRagActive) {
return;
}
if (modelOptions.length === 0) {
return;
}
@@ -259,7 +267,7 @@ export function InputBox({
mode: nextMode,
execution_profile: fallbackModel.execution_profile,
});
}, [context, modelOptions, onContextChange]);
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
const selectedModel = useMemo(() => {
if (modelOptions.length === 0) {
@@ -342,6 +350,13 @@ export function InputBox({
execution_profile: "single_round_rag",
});
}, [context, extraModels, onContextChange]);
const handleMultiRoundRagSelect = useCallback(() => {
onContextChange?.({
...context,
execution_profile: "multi_round_rag",
});
}, [context, onContextChange]);
const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => {
@@ -712,14 +727,16 @@ export function InputBox({
/>
</PromptInputActionMenuContent>
</PromptInputActionMenu> */}
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
<Tooltip
{!frontendRagActive && (
<AddAttachmentsButton
className={cn(
"rounded-full border-0 bg-transparent px-2.5! shadow-none text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact &&
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
)}
/>
)}
{!frontendRagActive && <Tooltip
content={
context.writing_mode
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
@@ -744,8 +761,8 @@ export function InputBox({
<FileTextIcon className="size-3" />
<span className="text-xs font-normal">写作</span>
</PromptInputButton>
</Tooltip>
<Tooltip
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
memoryInjectionEnabled
? "记忆注入已开启:对话前会注入历史记忆"
@@ -770,8 +787,8 @@ export function InputBox({
<BrainCircuitIcon className="size-3" />
<span className="text-xs font-normal">记忆</span>
</PromptInputButton>
</Tooltip>
<Tooltip
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
ragModeEnabled
? "参考文献模式已开启:检索类结果会按编号引用展示"
@@ -800,8 +817,8 @@ export function InputBox({
<BookMarkedIcon className="size-3" />
<span className="text-xs font-normal">参考文献</span>
</PromptInputButton>
</Tooltip>
<Tooltip
</Tooltip>}
{!frontendRagActive && <Tooltip
content={
contextCompressionEnabled
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
@@ -829,7 +846,7 @@ export function InputBox({
<ShrinkIcon className="size-3" />
<span className="text-xs font-normal">上下文压缩</span>
</PromptInputButton>
</Tooltip>
</Tooltip>}
<PromptInputActionMenu>
<ModeHoverGuide
mode={
@@ -841,9 +858,11 @@ export function InputBox({
: "flash"
}
tooltipContent={
selectedSingleRoundRag
singleRoundRagActive
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: undefined
: multiRoundRagActive
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
: undefined
}
>
<PromptInputActionMenuTrigger
@@ -851,7 +870,7 @@ export function InputBox({
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact &&
(selectedSingleRoundRag
(frontendRagActive
? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
@@ -863,7 +882,7 @@ export function InputBox({
)}
>
<div>
{selectedSingleRoundRag ? (
{frontendRagActive ? (
<BookMarkedIcon className="size-3" />
) : context.mode === "flash" ? (
<ZapIcon className="size-3" />
@@ -881,8 +900,10 @@ export function InputBox({
context.mode === "ultra" ? "golden-text" : "",
)}
>
{selectedSingleRoundRag
? "单轮 RAG"
{frontendRagActive
? multiRoundRagActive
? "多轮 RAG"
: "单轮 RAG"
: (context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "thinking" &&
t.inputBox.reasoningMode) ||
@@ -899,7 +920,7 @@ export function InputBox({
<PromptInputActionMenu>
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "flash"
!frontendRagActive && context.mode === "flash"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -910,7 +931,7 @@ export function InputBox({
<ZapIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "flash" &&
!frontendRagActive && context.mode === "flash" &&
"text-accent-foreground",
)}
/>
@@ -920,7 +941,7 @@ export function InputBox({
{t.inputBox.flashModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "flash" ? (
{!frontendRagActive && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -929,7 +950,7 @@ export function InputBox({
{supportThinking && (
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "thinking"
!frontendRagActive && context.mode === "thinking"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -940,7 +961,7 @@ export function InputBox({
<LightbulbIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "thinking" &&
!frontendRagActive && context.mode === "thinking" &&
"text-accent-foreground",
)}
/>
@@ -950,7 +971,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "thinking" ? (
{!frontendRagActive && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -959,7 +980,7 @@ export function InputBox({
)}
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "pro"
!frontendRagActive && context.mode === "pro"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -970,7 +991,7 @@ export function InputBox({
<GraduationCapIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag && context.mode === "pro" &&
!frontendRagActive && context.mode === "pro" &&
"text-accent-foreground",
)}
/>
@@ -980,7 +1001,7 @@ export function InputBox({
{t.inputBox.proModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "pro" ? (
{!frontendRagActive && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -988,7 +1009,7 @@ export function InputBox({
</PromptInputActionMenuItem>
<PromptInputActionMenuItem
className={cn(
!selectedSingleRoundRag && context.mode === "ultra"
!frontendRagActive && context.mode === "ultra"
? "text-accent-foreground"
: "text-muted-foreground/65",
)}
@@ -999,14 +1020,14 @@ export function InputBox({
<RocketIcon
className={cn(
"mr-2 size-4",
!selectedSingleRoundRag &&
!frontendRagActive &&
context.mode === "ultra" &&
"text-[#dabb5e]",
)}
/>
<div
className={cn(
!selectedSingleRoundRag &&
!frontendRagActive &&
context.mode === "ultra" &&
"golden-text",
)}
@@ -1018,7 +1039,7 @@ export function InputBox({
{t.inputBox.ultraModeDescription}
</div>
</div>
{!selectedSingleRoundRag && context.mode === "ultra" ? (
{!frontendRagActive && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
@@ -1057,11 +1078,39 @@ export function InputBox({
</PromptInputActionMenuItem>
</>
)}
<PromptInputActionMenuItem
className={cn(
multiRoundRagActive
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleMultiRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
multiRoundRagActive && "text-emerald-700 dark:text-emerald-200",
)}
/>
多轮 RAG 问答
</div>
<div className="pl-7 text-xs">
多次检索、展示过程与参考资料,适合复杂问题
</div>
</div>
{multiRoundRagActive ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</PromptInputActionMenu>
</DropdownMenuGroup>
</PromptInputActionMenuContent>
</PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && (
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu>
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
<div className="text-xs font-normal">
@@ -1179,8 +1228,8 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{knowledgeSelector}
{agentSelector && (
{!frontendRagActive && agentSelector && (
{!frontendRagActive && knowledgeSelector}
<AgentSelector
selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange}
@@ -1188,7 +1237,7 @@ export function InputBox({
compact={compact}
/>
)}
{promptPrefix && (
{!frontendRagActive && promptPrefix && (
<Tooltip
content={
promptPrefix.on
@@ -1216,7 +1265,7 @@ export function InputBox({
</PromptInputButton>
</Tooltip>
)}
<ModelSelector
{!multiRoundRagActive && <ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
>
@@ -1266,7 +1315,7 @@ export function InputBox({
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</ModelSelector>}
<PromptInputSubmit
className={cn(
"rounded-full border-border bg-background shadow-sm hover:bg-muted dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none",
@@ -1,5 +1,13 @@
import type { Message } from '@langchain/langgraph-sdk';
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
import {
ChevronDownIcon,
ClockIcon,
ExternalLinkIcon,
FileIcon,
Loader2Icon,
PencilIcon,
SearchIcon,
} from 'lucide-react';
import {
memo,
useCallback,
@@ -25,8 +33,15 @@ import {
ReasoningTrigger,
} from '@/components/ai-elements/reasoning';
import { Task, TaskTrigger } from '@/components/ai-elements/task';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import {
UploadedFilePreviewDialog,
@@ -34,7 +49,8 @@ import {
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
import { resolveArtifactURL } from '@/core/artifacts/utils';
import { useChatAskTimes } from '@/core/chat-timestamps';
import { getBackendBaseURL } from '@/core/config';
import { getBackendBaseURL } from '@/core/config';
import { readRuntimeEnv } from '@/env';
import { useI18n } from '@/core/i18n/hooks';
import {
extractContentFromMessage,
@@ -49,7 +65,12 @@ import {
applyInlineCitations,
type RuntimeDisplaySource,
} from '@/core/skill-display/runtime';
import { humanMessagePlugins } from '@/core/streamdown';
import { humanMessagePlugins } from '@/core/streamdown';
import {
getMultiRoundRagTrace,
isMultiRoundRagMessage,
type MultiRoundRagRound,
} from '@/core/threads/use-multi-round-rag';
import { cn } from '@/lib/utils';
import { CitationLink } from '../citations/citation-link';
@@ -561,18 +582,47 @@ function MessageContent_({
: baseRehypePlugins,
[baseRehypePlugins, extraRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === 'human';
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
const askTimes = useChatAskTimes();
const isHuman = message.type === 'human';
const isSingleRoundRag =
message.additional_kwargs?.single_round_rag === true;
const isMultiRoundRag = isMultiRoundRagMessage(message);
const multiRoundTrace = isMultiRoundRag
? getMultiRoundRagTrace(message)
: null;
const [selectedCitation, setSelectedCitation] =
useState<RuntimeDisplaySource | null>(null);
const resolveArtifact = useArtifactResolver(threadId);
const handleCitationClick = useCallback(
(referenceId: string) => {
const referenceIndex = Number(referenceId);
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
const source =
displaySources.find((item) => item.index === referenceIndex) ??
displaySources[referenceIndex - 1];
if (source) setSelectedCitation(source);
},
[displaySources],
);
const components = useMemo(
() => ({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith('#reference-')) {
return <CitationLink href={href} {...props} />;
}
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith('#reference-')) {
return (
<CitationLink
href={href}
{...props}
onReferenceClick={
isSingleRoundRag || isMultiRoundRag
? handleCitationClick
: undefined
}
/>
);
}
if (
typeof props.children === 'string' &&
/^citation:/i.test(props.children)
@@ -593,8 +643,14 @@ function MessageContent_({
return <a {...props} href={href} />;
},
}),
[threadId, resolveArtifact],
);
[
handleCitationClick,
isMultiRoundRag,
isSingleRoundRag,
resolveArtifact,
threadId,
],
);
// Question carries its own `client_ts` (round-trips through the checkpoint).
// The AI answer has no timestamp of its own, so it inherits the turn's ask
@@ -661,7 +717,7 @@ function MessageContent_({
}
// Reasoning-only AI message (no main response content yet)
if (!isHuman && reasoningContent && !rawContent) {
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
return (
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
@@ -707,32 +763,284 @@ function MessageContent_({
);
}
return (
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
);
}
function EmbeddedPageContextCard({
return (
<>
<AIElementMessageContent className={className}>
{filesList}
<div data-reference-batch-id={message.id} className="contents">
{multiRoundTrace ? (
<MultiRoundRagContent
trace={multiRoundTrace}
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
) : (
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
)}
</div>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
/>
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent>
{(isSingleRoundRag || isMultiRoundRag) && (
<SingleRoundRagCitationDialog
source={selectedCitation}
onOpenChange={(open) => {
if (!open) setSelectedCitation(null);
}}
/>
)}
</>
);
}
function SingleRoundRagCitationDialog({
source,
onOpenChange,
}: {
source: RuntimeDisplaySource | null;
onOpenChange: (open: boolean) => void;
}) {
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
return (
<Dialog open={!!source} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
<DialogHeader className="pr-8">
<DialogTitle className="leading-relaxed">
{source?.title || "引用资料"}
</DialogTitle>
<DialogDescription className="space-y-1 text-xs">
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
{source?.time && <span className="block">时间:{source.time}</span>}
</DialogDescription>
</DialogHeader>
{sourceUrl && (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<ExternalLinkIcon className="size-4" />
查看原文链接
</a>
)}
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
{source?.content || source?.snippet || "暂无引用内容"}
</div>
</DialogContent>
</Dialog>
);
}
function MultiRoundRagContent({
trace,
content,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
trace: NonNullable<ReturnType<typeof getMultiRoundRagTrace>>;
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const [open, setOpen] = useState(!trace.isComplete);
useEffect(() => {
if (!trace.isComplete) setOpen(true);
}, [trace.isComplete]);
return (
<div className="space-y-3">
<section className="border-border/70 overflow-hidden rounded-xl border bg-muted/20">
<button
type="button"
className="hover:bg-muted/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors"
onClick={() => setOpen((value) => !value)}
>
<div className="flex min-w-0 items-center gap-2">
{trace.isComplete ? (
<SearchIcon className="text-primary size-4 shrink-0" />
) : (
<Loader2Icon className="text-primary size-4 shrink-0 animate-spin" />
)}
<div>
<div className="text-sm font-medium">多轮检索过程</div>
<div className="text-muted-foreground text-xs">
{trace.isComplete
? `已完成 ${trace.rounds.length} 轮检索`
: '正在分析问题并检索资料'}
</div>
</div>
</div>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-transform', !open && '-rotate-90')}
/>
</button>
{open && (
<div className="border-border/60 space-y-3 border-t px-4 py-3">
{trace.rounds.map((round, index) => (
<MultiRoundRagRoundCard
key={round.id}
index={index + 1}
round={round}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
/>
))}
</div>
)}
</section>
<section className="border-border/70 rounded-xl border p-4">
<div className="mb-3 text-sm font-semibold">最终回答</div>
{content ? (
<AssistantDisplayContent
content={content}
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={rehypePlugins}
components={components}
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在生成最终回答…
</div>
)}
</section>
</div>
);
}
function MultiRoundRagRoundCard({
index,
round,
isLoading,
rehypePlugins,
components,
}: {
index: number;
round: MultiRoundRagRound;
isLoading: boolean;
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
return (
<div className="border-border/60 rounded-lg border bg-background/70 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs">
{index}
</span>
<span>{round.title}</span>
{round.isSearching && <Loader2Icon className="text-primary size-3.5 animate-spin" />}
</div>
{round.thought && (
<section className="border-border/50 bg-muted/30 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">思考内容</div>
<MarkdownContent
content={round.thought}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-muted-foreground text-sm"
/>
</section>
)}
{round.body && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">本轮正文</div>
<MarkdownContent
content={round.body}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
components={components}
className="text-sm"
/>
</section>
)}
{round.query && (
<div className="border-primary/20 bg-primary/5 text-primary mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs">
<SearchIcon className="size-3 shrink-0" />
<span className="truncate">{round.query}</span>
</div>
)}
{round.retrievals.length > 0 && (
<section className="border-border/50 mt-3 rounded-md border p-3">
<div className="text-muted-foreground mb-2 text-xs font-medium">
检索内容 · {round.retrievals.length} 条
</div>
<div className="space-y-2">
{round.retrievals.map((item, resultIndex) => (
<details
key={`${item.id ?? item.title}-${resultIndex}`}
className="border-border/60 rounded-md border px-3 py-2"
open={resultIndex === 0}
>
<summary className="cursor-pointer list-none text-sm font-medium">
<span className="mr-2 text-xs text-muted-foreground">{resultIndex + 1}.</span>
{item.title}
</summary>
<div className="text-muted-foreground mt-2 text-sm leading-6 whitespace-pre-wrap">
{item.content || '暂无资料摘要'}
</div>
{(item.source || item.time) && (
<div className="text-muted-foreground mt-2 text-xs">
{[item.source, item.time].filter(Boolean).join(' · ')}
</div>
)}
</details>
))}
</div>
</section>
)}
</div>
);
}
function resolveSingleRoundRagSourceUrl(
source: RuntimeDisplaySource | null,
): string | undefined {
if (!source) return undefined;
if (source.url && /^https?:\/\//i.test(source.url)) return source.url;
const raw = source.raw ?? {};
const recordId =
source.id ??
readString(raw.recUuid) ??
readString(raw.rec_uuid) ??
readString(raw.uuid);
const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, "");
if (!knowledgeUrl || !recordId) return undefined;
return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recordId)}`;
}
function readString(value: unknown): string | undefined {
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return undefined;
}
function EmbeddedPageContextCard({
pageContext,
}: {
pageContext: EmbeddedPageContext;
@@ -35,6 +35,7 @@ import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
@@ -239,7 +240,11 @@ export function MessageList({
const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const hasSingleRoundRag = useMemo(
() => messages.some(isSingleRoundRagMessage),
() =>
messages.some(
(message) =>
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
),
[messages],
);
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
@@ -19,3 +19,18 @@ export function isSingleRoundRagProfile(
"single_round_rag"
);
}
export function isMultiRoundRagProfile(
context: unknown,
): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile ===
"multi_round_rag"
);
}
export function isFrontendRagProfile(context: unknown): boolean {
return isSingleRoundRagProfile(context) || isMultiRoundRagProfile(context);
}
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Model {
supports_thinking?: boolean;
supports_reasoning_effort?: boolean;
/** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag";
execution_profile?: "single_round_rag" | "multi_round_rag";
}
export interface TokenUsageSettings {
@@ -71,6 +71,12 @@ test("keeps half-width syntax working", () => {
assert.equal(out.includes(citation(3)), true);
});
test("converts double-bracket RAG references", () => {
const out = applyInlineCitations("RAG answer [[1]] and [[2]].", makeSources(2));
assert.equal(out.includes(citation(1)), true);
assert.equal(out.includes(citation(2)), true);
});
test("accepts spaced and full-width square brackets", () => {
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
assert.equal(out.includes(citation(1)), true);
+64 -12
View File
@@ -244,6 +244,11 @@ const TERMINAL_RUN_STATUSES = new Set([
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call is in progress, so
// reconnect more conservatively than the terminal-status check above. The
// reconnection only replaces the local SSE reader; the backend run keeps going.
const STREAM_RECONNECT_IDLE_MS = 30_000;
const STREAM_RECONNECT_MAX_ATTEMPTS = 3;
async function fetchThreadTitleRefreshState(
threadId: string,
@@ -466,9 +471,15 @@ export function useThreadStream({
const startedRef = useRef(false);
const lastStreamActivityAtRef = useRef(0);
const streamReconciliationInFlightRef = useRef(false);
const streamReconnectAttemptsRef = useRef(0);
const streamReconnectToastShownRef = useRef(false);
const streamReconnectFailureToastShownRef = useRef(false);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false;
}, []);
const listeners = useRef({
@@ -761,13 +772,6 @@ export function useThreadStream({
threadStreamRef.current = thread;
useEffect(() => {
if (!thread.isLoading) {
// A normal terminal event reached the browser. A later run may use the
// same thread, so it must be eligible for recovery again.
streamReconciliationInFlightRef.current = false;
return;
}
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (!activeThreadId || typeof window === "undefined") {
return;
@@ -776,7 +780,7 @@ export function useThreadStream({
let disposed = false;
let refreshTimer: number | null = null;
const reconcileCompletedRun = async () => {
const reconcileRun = async () => {
if (
disposed ||
streamReconciliationInFlightRef.current ||
@@ -793,7 +797,51 @@ export function useThreadStream({
try {
const client = getAPIClient(isMock);
const run = await client.runs.get(activeThreadId, runId);
if (disposed || !TERMINAL_RUN_STATUSES.has(run.status)) {
if (disposed) {
return;
}
if (!TERMINAL_RUN_STATUSES.has(run.status)) {
// ``useStream`` retries one failed connection on its own. If that
// reader quietly stalls, however, its internal queue never gets a
// terminal error and the page remains frozen despite the backend
// continuing the run. Abort only the local reader, then join the
// same run so no work or tokens are lost server-side.
if (Date.now() - lastStreamActivityAtRef.current < STREAM_RECONNECT_IDLE_MS) {
return;
}
if (streamReconnectAttemptsRef.current >= STREAM_RECONNECT_MAX_ATTEMPTS) {
if (!streamReconnectFailureToastShownRef.current) {
streamReconnectFailureToastShownRef.current = true;
toast.error("回答仍在后台处理中,但页面连接暂未恢复;请稍后返回此会话查看结果。");
}
return;
}
streamReconnectAttemptsRef.current += 1;
// Avoid enqueueing another reconnect before this new reader has had
// enough time to receive its first event. A genuine event resets
// the attempt counter through ``markStreamActivity``.
lastStreamActivityAtRef.current = Date.now();
if (!streamReconnectToastShownRef.current) {
streamReconnectToastShownRef.current = true;
toast.info("回答连接出现波动,正在自动恢复…");
}
const stream = threadStreamRef.current;
void Promise.resolve(stream.stop())
.catch(() => {
// Stopping a stale local reader is best-effort; joining is still
// useful if the SDK already considers the stream stopped.
})
.finally(() => {
if (disposed || threadIdRef.current !== activeThreadId) return;
void stream.joinStream(runId).catch(() => {
// The status poll will either reconcile a terminal run or
// schedule the next bounded reconnect attempt.
});
});
return;
}
@@ -801,13 +849,17 @@ export function useThreadStream({
// receive any more useful events. Load the checkpoint directly before
// resetting the SDK stream; this preserves the normal onFinish side
// effects (e.g. marking a position delivery complete) even when the
// browser missed the terminal event.
// browser missed the terminal event or already reported a transport
// error and dropped ``isLoading``.
const finalState = await client.threads.getState(activeThreadId);
if (disposed || !threadStreamRef.current.isLoading) {
if (disposed) {
return;
}
storage?.removeItem(`lg:stream:${activeThreadId}`);
streamReconnectAttemptsRef.current = 0;
streamReconnectToastShownRef.current = false;
streamReconnectFailureToastShownRef.current = false;
if (run.status === "success") {
listeners.current.onFinish?.(finalState.values as AgentThreadState);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -836,7 +888,7 @@ export function useThreadStream({
};
const interval = window.setInterval(
() => void reconcileCompletedRun(),
() => void reconcileRun(),
STREAM_RECONCILIATION_POLL_MS,
);
+2 -2
View File
@@ -27,8 +27,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
knowledge_rag_enabled?: boolean;
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Direct knowledge-base RAG mode; this request never enters Lead Agent. */
execution_profile?: "single_round_rag";
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
execution_profile?: "single_round_rag" | "multi_round_rag";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string;
@@ -0,0 +1,483 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
const STORAGE_PREFIX = "workspace.multi-round-rag.";
export type MultiRoundRagRetrieval = {
id?: string;
title: string;
content: string;
source?: string;
time?: string;
recUuid?: string;
url?: string;
};
export type MultiRoundRagRound = {
id: string;
title: string;
thought: string;
/** Model text emitted for this round, before its retrieval results. */
body: string;
query: string;
isSearching: boolean;
retrievals: MultiRoundRagRetrieval[];
};
export type MultiRoundRagTrace = {
rounds: MultiRoundRagRound[];
answer: string;
isComplete: boolean;
};
type MultiRoundRagSource = {
id?: string;
title: string;
type: string;
snippet: string;
content: string;
source?: string;
time?: string;
url?: string;
raw: Record<string, unknown>;
};
type MultiRoundEvent = {
event?: unknown;
data?: unknown;
};
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Storage quota failures must not interrupt a running answer.
}
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function parseJson(value: unknown): unknown {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function parseEventLine(line: string): MultiRoundEvent | null {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) return null;
try {
return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent;
} catch {
return null;
}
}
function initialTrace(): MultiRoundRagTrace {
return {
rounds: [
{
id: createId("multi-rag-round"),
title: "正在准备检索",
thought: "",
body: "",
query: "",
isSearching: true,
retrievals: [],
},
],
answer: "",
isComplete: false,
};
}
function latestRound(trace: MultiRoundRagTrace): MultiRoundRagRound {
const current = trace.rounds.at(-1);
if (current) return current;
const next = initialTrace().rounds[0]!;
trace.rounds.push(next);
return next;
}
function addThinkingRound(trace: MultiRoundRagTrace, thought: string) {
const current = latestRound(trace);
if (current.title === "正在准备检索" && !current.thought && !current.query) {
current.title = "思考过程";
current.thought = thought;
return;
}
current.isSearching = false;
trace.rounds.push({
id: createId("multi-rag-round"),
title: "思考过程",
thought,
body: "",
query: "",
isSearching: true,
retrievals: [],
});
}
function appendRoundBody(trace: MultiRoundRagTrace, body: string) {
if (!body) return;
const current = latestRound(trace);
// Stored traces created before `body` was introduced remain readable.
current.body = `${current.body ?? ""}${body}`;
}
function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] {
const root = asRecord(parseJson(value));
const list =
(Array.isArray(root?.results) && root.results) ||
(Array.isArray(root?.organic) && root.organic) ||
[];
return list.flatMap((item, index) => {
const record = asRecord(item);
if (!record) return [];
const recUuid = asString(record.recUuid ?? record.rec_uuid);
const url = asString(record.url ?? record.link ?? record.href);
return [
{
id: asString(record.id) || recUuid || undefined,
title: asString(record.title) || `检索资料 ${index + 1}`,
content: asString(record.content ?? record.snippet ?? record.text),
source: asString(record.source ?? record.source1 ?? url) || undefined,
time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined,
recUuid: recUuid || undefined,
url: /^https?:\/\//i.test(url) ? url : undefined,
},
];
});
}
function mapReferences(value: unknown): MultiRoundRagSource[] {
const record = asRecord(value);
const list = Array.isArray(record?.references) ? record.references : [];
return list.flatMap((item, index) => {
const source = asRecord(item);
if (!source) return [];
const content = asString(source.content ?? source.snippet);
const rawUrl = asString(source.url ?? source.link ?? source.href);
const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid);
return [
{
id: asString(source.id) || recUuid || undefined,
title: asString(source.title) || `参考资料 ${index + 1}`,
type: asString(source.type) || "参考资料",
snippet: asString(source.snippet) || content,
content,
source: asString(source.source ?? source.source1) || undefined,
time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined,
url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: source,
},
];
});
}
function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace {
return {
...trace,
rounds: trace.rounds.map((round) => ({
...round,
retrievals: [...round.retrievals],
})),
};
}
function isSingleRoundRagMessage(message: Message) {
return message.additional_kwargs?.single_round_rag === true;
}
export function isMultiRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.multi_round_rag === true;
}
export function getMultiRoundRagTrace(
message: Message,
): MultiRoundRagTrace | null {
const trace = message.additional_kwargs?.multi_round_rag_trace;
return asRecord(trace)
? (trace as unknown as MultiRoundRagTrace)
: null;
}
/**
* Frontend adapter for GeneralQA's multi-round endpoint. It keeps the external
* SSE protocol outside DeerFlow and stores only its UI messages in sessionStorage.
*/
export function useMultiRoundRagMessages(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({ targetThreadId, query }: { targetThreadId: string; query: string }) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createId("multi-rag-human");
const assistantId = createId("multi-rag-ai");
const sourceId = createId("multi-rag-sources");
const trace = initialTrace();
let sources: MultiRoundRagSource[] = [];
const clientTs = new Date().toISOString();
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, multi_round_rag: true },
};
const assistantMessage: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: {
multi_round_rag: true,
multi_round_rag_trace: trace,
},
};
updateMessages(targetThreadId, (current) => [
...current,
userMessage,
assistantMessage,
]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
const refreshAssistant = () => {
const traceSnapshot = cloneTrace(trace);
updateMessages(targetThreadId, (current) => {
const next = current.filter((message) => message.id !== sourceId);
const assistantIndex = next.findIndex((message) => message.id === assistantId);
if (assistantIndex < 0) return current;
next[assistantIndex] = {
...next[assistantIndex],
content: traceSnapshot.answer,
additional_kwargs: {
...next[assistantIndex]!.additional_kwargs,
multi_round_rag: true,
multi_round_rag_trace: traceSnapshot,
reasoning_content: traceSnapshot.rounds
.map((round) => round.thought)
.filter(Boolean)
.join("\n\n") || null,
},
} as Message;
if (sources.length > 0) {
next.splice(assistantIndex, 0, {
id: sourceId,
type: "tool",
name: "multi_round_rag_references",
content: { results: sources },
additional_kwargs: {
hide_from_ui: true,
multi_round_rag: true,
},
} as unknown as Message);
}
return next;
});
};
try {
const baseUrl = readRuntimeEnv("VITE_AIPI_GENERATE_CHAT_URL") || "";
if (!baseUrl) {
throw new Error("未配置 VITE_AIPI_GENERATE_CHAT_URL,无法发起多轮 RAG 问答。");
}
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({ prompt: question }),
});
if (!response.ok) {
throw new Error(`多轮 RAG 服务请求失败(${response.status})。`);
}
if (!response.body) {
throw new Error("多轮 RAG 服务未返回流式响应。");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const event = parseEventLine(line);
if (!event) continue;
const eventType = asString(event.event);
const data = event.data;
if (eventType === "tool_call") {
const tool = asRecord(data);
const toolName = asString(tool?.tool_name);
const input = asRecord(tool?.tool_input) ?? {};
const current = latestRound(trace);
if (toolName === "show_text") {
const text = asString(input.text);
if (text.includes("<think>")) {
const thinkEnd = text.indexOf("</think>");
addThinkingRound(
trace,
(thinkEnd >= 0
? text.slice(0, thinkEnd + "</think>".length)
: text
)
.replace(/<\/?think>/g, "")
.trim(),
);
if (thinkEnd >= 0) {
const roundBody = text
.slice(thinkEnd + "</think>".length)
.trim();
if (roundBody) {
appendRoundBody(trace, roundBody);
}
}
} else {
// A non-thinking `show_text` immediately after a thought is
// this round's narrative; the terminal `message` event is
// still rendered only as the final answer below all rounds.
current.isSearching = false;
trace.answer += text;
trace.isComplete = true;
}
} else if (
toolName === "local_search" ||
toolName === "tavily_search" ||
toolName === "google_search"
) {
const queryData = asRecord(parseJson(input.result));
current.title =
toolName === "local_search" ? "本地知识检索" : "联网检索";
current.query =
asString(input.q ?? input.query) ||
asString(queryData?.query) ||
current.query;
const retrievals = mapRetrievals(input.result);
if (retrievals.length > 0) current.retrievals = retrievals;
current.isSearching = false;
} else if (toolName === "get_textid_content") {
const article = asRecord(parseJson(input.result));
const body =
asString(article?.content ?? article?.text) ||
asString(input.content);
if (body) appendRoundBody(trace, body);
current.isSearching = false;
}
refreshAssistant();
} else if (eventType === "message" && typeof data === "string") {
latestRound(trace).isSearching = false;
trace.answer += data;
trace.isComplete = true;
refreshAssistant();
} else if (eventType === "references") {
sources = mapReferences(data);
refreshAssistant();
} else if (eventType === "done") {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
}
}
}
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
refreshAssistant();
} catch (caught) {
trace.isComplete = true;
for (const round of trace.rounds) round.isSearching = false;
if (!isAbortError(caught)) {
const message =
caught instanceof Error ? caught.message : "多轮 RAG 问答失败。";
setError(message);
if (!trace.answer) trace.answer = message;
}
refreshAssistant();
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
export function isFrontendRagMessage(message: Message): boolean {
return isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message);
}
@@ -22,6 +22,13 @@ type RagSource = {
raw: Record<string, unknown>;
};
type RagHistoryEntry = {
role: "user" | "assistant";
content: string;
};
const MAX_HISTORY_MESSAGES = 12;
/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
const objects: string[] = [];
@@ -199,6 +206,44 @@ function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
function textFromMessage(message: Message): string {
if (typeof message.content === "string") {
return message.content.trim();
}
if (!Array.isArray(message.content)) {
return "";
}
return message.content
.map((part) => {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
const text = (part as { text?: unknown }).text;
return typeof text === "string" ? text : "";
})
.join("\n")
.trim();
}
/**
* The general-QA endpoint accepts the usual `{ role, content }` history
* format. Keep it scoped to completed single-round-RAG turns so a normal
* DeerFlow conversation never leaks into this external endpoint.
*/
function buildRagHistory(messages: Message[]): RagHistoryEntry[] {
return messages
.filter(
(message) =>
message.additional_kwargs?.single_round_rag === true &&
(message.type === "human" || message.type === "ai"),
)
.map((message): RagHistoryEntry => ({
role: message.type === "human" ? "user" : "assistant",
content: textFromMessage(message),
}))
.filter((entry) => entry.content.length > 0)
.slice(-MAX_HISTORY_MESSAGES);
}
export function isSingleRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.single_round_rag === true;
}
@@ -209,21 +254,29 @@ export function isSingleRoundRagMessage(message: Message): boolean {
*/
export function useSingleRoundRagMessages(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
const messagesRef = useRef(messages);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setMessages(readStoredMessages(threadId));
const stored = readStoredMessages(threadId);
messagesRef.current = stored;
setMessages(stored);
setError(null);
}, [threadId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
messagesRef.current = next;
writeStoredMessages(targetThreadId, next);
return next;
});
@@ -251,6 +304,7 @@ export function useSingleRoundRagMessages(threadId: string) {
const assistantId = createId("single-rag-ai");
const sourceId = createId("single-rag-sources");
const clientTs = new Date().toISOString();
const history = buildRagHistory(messagesRef.current);
const userMessage: Message = {
id: humanId,
type: "human",
@@ -319,7 +373,7 @@ export function useSingleRoundRagMessages(threadId: string) {
knowledge_base_name: [],
top_k: 5,
score_threshold: 0.5,
history: [],
history,
stream: true,
model_name: modelName,
temperature: 0.3,
+59 -15
View File
@@ -46,6 +46,8 @@ import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import {
isFrontendRagProfile,
isMultiRoundRagProfile,
isSingleRoundRagProfile,
SINGLE_ROUND_RAG_MODELS,
} from "@/core/models/single-round-rag";
@@ -59,6 +61,7 @@ import {
import { useThreadStream } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useMultiRoundRagMessages } from "@/core/threads/use-multi-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils";
@@ -310,19 +313,38 @@ export default function ChatPage() {
},
});
const singleRoundRag = useSingleRoundRagMessages(threadId);
const multiRoundRag = useMultiRoundRagMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
const frontendRagSelected = isFrontendRagProfile(settings.context);
const displayThread = useMemo(() => {
if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) {
if (
singleRoundRag.messages.length === 0 &&
multiRoundRag.messages.length === 0 &&
!singleRoundRag.isLoading &&
!multiRoundRag.isLoading
) {
return thread;
}
const messages = [...thread.messages, ...singleRoundRag.messages];
const messages = [
...thread.messages,
...singleRoundRag.messages,
...multiRoundRag.messages,
];
return {
...thread,
values: { ...thread.values, messages },
messages,
isLoading: thread.isLoading || singleRoundRag.isLoading,
isLoading:
thread.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
} as typeof thread;
}, [singleRoundRag.isLoading, singleRoundRag.messages, thread]);
}, [
multiRoundRag.isLoading,
multiRoundRag.messages,
singleRoundRag.isLoading,
singleRoundRag.messages,
thread,
]);
const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) =>
@@ -337,9 +359,9 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
if (singleRoundRagSelected) {
if (frontendRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。");
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
@@ -366,13 +388,23 @@ export default function ChatPage() {
.catch(() => undefined);
}
}
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
if (multiRoundRagSelected) {
await multiRoundRag.submit({ targetThreadId, query: message.text });
} else {
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。");
toast.error(
error instanceof Error
? error.message
: multiRoundRagSelected
? "多轮 RAG 问答失败。"
: "单轮 RAG 问答失败。",
);
}
})();
return;
@@ -483,6 +515,9 @@ export default function ChatPage() {
writingArtifactPath,
singleRoundRag,
singleRoundRagSelected,
multiRoundRag,
multiRoundRagSelected,
frontendRagSelected,
routeBase,
setIsNewThread,
setThreadId,
@@ -580,8 +615,12 @@ export default function ChatPage() {
singleRoundRag.stop();
return;
}
if (multiRoundRag.isLoading) {
multiRoundRag.stop();
return;
}
await thread.stop();
}, [singleRoundRag, thread]);
}, [multiRoundRag, singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
@@ -776,13 +815,18 @@ export default function ChatPage() {
: "ready"
}
context={settings.context}
extraModels={SINGLE_ROUND_RAG_MODELS}
extraModels={
singleRoundRagSelected
? SINGLE_ROUND_RAG_MODELS
: undefined
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading ||
agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading
singleRoundRag.isLoading ||
multiRoundRag.isLoading
}
onContextChange={(context) =>
setSettings("context", context)
@@ -138,6 +138,52 @@ const PRIMARY_API_PATH = "/api/position-roundtable";
const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
/**
* 生成一次写操作的幂等键(command_id)。
*
* 后端命令式状态机用 ``UNIQUE(session_id, node_key, command_id)`` 保证同一 command_id
* 只执行一次。把它在**一次调用入口**生成并固定进请求体,使得 `positionApiFetch` 的
* 主/兼容路由回退重试复用同一 command_id —— 网络层重发绝不会把同一操作执行两遍
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
*/
function newCommandId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
}
/**
* 用**同一个 command_id** 重试一个写操作至多 ``attempts`` 次(指数退避)。
*
* 后端命令幂等表保证同 command_id + 同参数只执行一次:即便第一次其实已提交成功、
* 只是响应在网络层丢失,重试也只会重放上次结果,而不会重复推进状态机(重复
* +revision / 重复解锁下游)。因此「提交产物给下一阶段」这类关键写操作可以放心
* 重试,把偶发网络错误/超时变成自愈,而不是让下一阶段永远拿不到上游产物。
*
* 返回 ``{ ok, result?, error? }``;``ok=false`` 时 ``error`` 是最后一次失败。
*/
export async function retryWithSameCommandId<T>(
operation: (commandId: string) => Promise<T>,
options?: { attempts?: number; baseDelayMs?: number },
): Promise<{ ok: true; result: T } | { ok: false; error: unknown }> {
const attempts = Math.max(1, options?.attempts ?? 3);
const baseDelay = options?.baseDelayMs ?? 400;
const commandId = newCommandId();
let lastError: unknown = null;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
return { ok: true, result: await operation(commandId) };
} catch (error) {
lastError = error;
if (attempt < attempts - 1) {
await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
}
}
}
return { ok: false, error: lastError };
}
/**
* Some intranet gateways only forward the long-established `/api/multi-agent`
* namespace. The position workspace was added later under its own top-level
@@ -383,7 +429,7 @@ export async function activatePositionSession(
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }),
body: JSON.stringify({ chain_id: chainId, command_id: newCommandId() }),
}),
"activate position session",
);
@@ -417,6 +463,7 @@ export async function bindPositionNodeThread(
sessionId: string,
nodeKey: string,
threadId: string,
commandId?: string,
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
await positionApiFetch(
@@ -424,7 +471,7 @@ export async function bindPositionNodeThread(
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: threadId }),
body: JSON.stringify({ thread_id: threadId, command_id: commandId ?? newCommandId() }),
},
),
"bind position node thread",
@@ -438,6 +485,7 @@ export async function savePositionNodeConversation(
input: {
threadId?: string | null;
conversationSnapshot: PositionConversationSnapshot;
commandId?: string;
},
): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>(
@@ -449,6 +497,7 @@ export async function savePositionNodeConversation(
body: JSON.stringify({
thread_id: input.threadId || null,
conversation_snapshot: input.conversationSnapshot,
command_id: input.commandId ?? newCommandId(),
}),
},
),
@@ -533,6 +582,8 @@ export async function completePositionNodeTurn(
latestAnswer: string;
artifactManifest?: Array<Record<string, unknown>>;
upstreamRevision?: Record<string, unknown> | null;
/** Retry-stable idempotency key; omit to auto-generate per call. */
commandId?: string;
},
): Promise<PositionRoundtableNode> {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
@@ -546,6 +597,7 @@ export async function completePositionNodeTurn(
latest_answer: input.latestAnswer,
artifact_manifest: input.artifactManifest ?? [],
upstream_revision: input.upstreamRevision ?? null,
command_id: input.commandId ?? newCommandId(),
}),
},
),
@@ -557,7 +609,7 @@ export async function completePositionNodeTurn(
export async function rejectPositionNode(
sessionId: string,
nodeKey: string,
input: { reason?: string; title?: string },
input: { reason?: string; title?: string; commandId?: string },
): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>(
await positionApiFetch(
@@ -570,6 +622,7 @@ export async function rejectPositionNode(
// workspace deliberately sends only an optional reason.
...(input.title ? { title: input.title } : {}),
reason: input.reason ?? "",
command_id: input.commandId ?? newCommandId(),
}),
},
),
@@ -27,6 +27,7 @@ import {
bindPositionNodeThread,
completePositionNodeTurn,
getPositionNodeContext,
retryWithSameCommandId,
savePositionNodeConversation,
type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions";
@@ -530,17 +531,26 @@ export function PositionNodeChat({
// Artifact listing is best-effort: a valid text answer must still be
// persisted and unlock downstream work even if files cannot be read.
}
try {
const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
// 这是「岗位完成问答 → 把产物提交给下一阶段」的关键路径。偶发网络错误/超时
// 不能让它悄悄失败(否则下一阶段永远拿不到上游产物):用同一个 command_id
// 重试,后端命令幂等表保证即使第一次其实已提交成功,重试也只重放结果,
// 绝不重复推进状态机(不会重复 +revision / 重复解锁下游)。
const delivery = await retryWithSameCommandId((commandId) =>
completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer,
artifactManifest,
upstreamRevision: upstreamRevisionRef.current,
});
commandId,
}),
);
if (delivery.ok) {
const updated = delivery.result;
onNodeUpdate(updated);
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
}
} catch (error) {
} else {
const error = delivery.error;
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
}
})();
@@ -665,14 +675,19 @@ export function PositionNodeChat({
[messageId]: textOfMessage(updatedMessage) ?? content,
}));
if (updatedMessage.type === "ai" && messageId === latestAiMessageId) {
try {
const updatedNode = await completePositionNodeTurn(sessionId, node.nodeKey, {
// 与自动交付路径同样:同一 command_id 重试,幂等保证不重复推进状态机。
const sync = await retryWithSameCommandId((commandId) =>
completePositionNodeTurn(sessionId, node.nodeKey, {
latestAnswer: content,
artifactManifest: node.artifactManifest,
upstreamRevision: node.upstreamRevision,
});
onNodeUpdate(updatedNode);
} catch (syncError) {
commandId,
}),
);
if (sync.ok) {
onNodeUpdate(sync.result);
} else {
const syncError = sync.error;
toast.warning(
`消息已保存,但节点交接摘要同步失败:${
syncError instanceof Error ? syncError.message : String(syncError)
@@ -189,6 +189,11 @@ export interface DraftMeta {
/** Auto-derived task summary (objective → first user message → timestamp). */
title: string;
furthestStep: 1 | 2 | 3;
/**
* 乐观锁版本号:写回时经 `expectedVersion` 回传;并发冲突(409)时用它识别
* 本地副本已过期,重拉最新草稿合并后重试。
*/
version: number;
/**
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
@@ -234,6 +239,33 @@ export interface DraftUpdatePayload {
step1?: DraftStep1Snapshot;
step2?: DraftStep2Snapshot;
step3?: DraftStep3Snapshot | null;
/**
* 本地副本基于的草稿版本号(乐观锁)。提供时服务端做原子条件 UPDATE;
* 版本不匹配 → 409(`DraftVersionConflictError`),调用方应重拉合并后重试。
* 省略 = 盲写(向后兼容旧调用点,不推荐)。
*/
expectedVersion?: number;
}
/**
* 乐观锁冲突(HTTP 409):服务端草稿版本比本地副本新。
* `currentVersion` 是服务端最新版本,调用方应重拉最新草稿、合并本地改动后用
* 新版本号重试——一致性来自 DB,不靠前端运气。
*/
export class DraftVersionConflictError extends Error {
readonly draftId: string;
readonly currentVersion: number | null;
constructor(draftId: string, currentVersion: number | null) {
super(`Draft ${draftId} was modified concurrently (server version ${currentVersion})`);
this.name = "DraftVersionConflictError";
this.draftId = draftId;
this.currentVersion = currentVersion;
}
}
export function isDraftVersionConflictError(error: unknown): error is DraftVersionConflictError {
return error instanceof DraftVersionConflictError;
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
@@ -256,6 +288,7 @@ export interface ServerDraftMeta {
task_id?: string | null;
title: string;
furthest_step: number;
version?: number;
chain_title?: string | null;
created_at: string | null;
updated_at: string | null;
@@ -276,6 +309,7 @@ export function toMeta(s: ServerDraftMeta): DraftMeta {
taskId: s.task_id ?? null,
title: s.title ?? "",
furthestStep: step,
version: s.version ?? 0,
chainTitle: s.chain_title ?? null,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
@@ -313,6 +347,7 @@ export function fromUpdate(p: DraftUpdatePayload) {
if (p.step1 !== undefined) out.step1 = p.step1;
if (p.step2 !== undefined) out.step2 = p.step2;
if (p.step3 !== undefined) out.step3 = p.step3;
if (p.expectedVersion !== undefined) out.expected_version = p.expectedVersion;
return out;
}
@@ -366,6 +401,23 @@ export async function createDraft(payload: DraftCreatePayload): Promise<DraftRec
return record;
}
/**
* 把 409 乐观锁冲突转成类型化错误(解析服务端最新版本号)。个人与任务草稿客户端共用,
* 保证两条 PUT 路径的冲突语义完全一致。非 409 原样放行。
*/
export async function throwIfDraftConflict(res: Response, id: string): Promise<void> {
if (res.status !== 409) return;
const raw = await res.text().catch(() => "");
let currentVersion: number | null = null;
try {
const parsed = JSON.parse(raw) as { detail?: { current_version?: number } };
currentVersion = parsed?.detail?.current_version ?? null;
} catch {
currentVersion = null;
}
throw new DraftVersionConflictError(id, currentVersion);
}
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
@@ -373,6 +425,7 @@ export async function updateDraft(id: string, patch: DraftUpdatePayload): Promis
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
await throwIfDraftConflict(res, id);
const data = await readJson<ServerDraftFull>(res, "update draft");
const record = toRecord(data);
writeYLog({
@@ -190,6 +190,12 @@ export interface StreamIntentRequest {
model?: string;
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
intentAgentId?: string;
/**
* 本回合幂等键(Phase 4 单飞):同一 (threadId, clientTurnId) 在后端至多一个 run。
* 缺省由调用方(useStep1Intent)每条消息生成;同一回合的重试 / 双击复用它,后端据此
* 去重(在跑→丢弃重复请求 / 已完成→幂等回放),杜绝并发下双发意图 / 重复澄清卡。
*/
clientTurnId?: string;
/** Optional hidden business/task context injected into the model run. */
taskContext?: Record<string, unknown> | null;
/** Step 1 用户上传的参考文件(可选)。 */
@@ -403,6 +409,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
model: req.model,
intent_agent_id: req.intentAgentId || undefined,
task_context: req.taskContext ?? undefined,
// Phase 4 单飞幂等键:缺省由后端兜底生成,但调用方应始终传(useStep1Intent 每条消息生成)。
client_turn_id: req.clientTurnId || undefined,
// 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined,
}),
@@ -11,6 +11,8 @@
* 增量事件归并。
*/
import { nanoid } from "nanoid";
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { OptType, writeYLog } from "@/core/log/y-log";
@@ -87,6 +89,9 @@ function toStartBody(p: StartJobPayload) {
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
return {
// 幂等请求 id:缺省现生成;「同一草稿至多一个活跃作业」由后端作用域唯一键保证,
// requestId 仅作审计/排查线索(重试时复用可让后端识别「同 id 但入参变了」的异常重试)。
requestId: p.requestId ?? nanoid(),
draftId: p.draftId ?? null,
// taskId 非空 → task 作业(按 task 共享、不分权,报告写回独立 task-draft 存储)。
...(p.taskId ? { taskId: p.taskId } : {}),
@@ -18,6 +18,7 @@ import {
fromCreate,
fromUpdate,
readJson,
throwIfDraftConflict,
toMeta,
toRecord,
type DraftCreatePayload,
@@ -103,6 +104,7 @@ export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Pr
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
await throwIfDraftConflict(res, id);
const data = await readJson<ServerDraftFull>(res, "update task draft");
const record = toRecord(data);
writeYLog({
@@ -24,10 +24,12 @@ import {
deleteDraft,
deriveDraftName,
getDraft,
isDraftVersionConflictError,
listDrafts,
updateDraft,
type DraftCreatePayload,
type DraftRecord,
type DraftStep2RunSnapshot,
type DraftStep2Snapshot,
type DraftStep3Snapshot,
type DraftUpdatePayload,
@@ -42,6 +44,34 @@ import {
} from "@/roundtable-planning/api/task-drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/**
* 409 冲突后的 step2 合并:以**服务端**为基准,把本地 runs 按 id upsert 回去。
*
* 这样既保住并发写手(后台作业 / 另一个标签页)新增的 runs,又保留本标签页刚产出的
* run 内容。activeRunId 若本地显式有值则以本地为准(本标签页正在看的 run),否则保留
* 服务端值。其余 step2 顶层字段以本地为准(本地是最新交互态)。一致性来自 DB 版本号,
* 合并只是把双方都不丢地拼回来。
*/
function mergeStep2OnConflict(
local: DraftStep2Snapshot,
server: DraftStep2Snapshot | null,
): DraftStep2Snapshot {
if (!server) return local;
const byId = new Map<string, DraftStep2RunSnapshot>();
for (const run of server.runs ?? []) {
if (run?.id) byId.set(run.id, run);
}
for (const run of local.runs ?? []) {
if (run?.id) byId.set(run.id, run); // 本地覆盖同 id 的服务端 run
}
return {
...server,
...local,
runs: Array.from(byId.values()),
activeRunId: local.activeRunId ?? server.activeRunId,
};
}
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
export interface DraftListItem {
id: string;
@@ -198,6 +228,10 @@ export function useDraftPersistence(
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
const currentDraftIdRef = useRef<string | null>(null);
// 当前草稿的乐观锁版本号(本地副本基于的 server version)。update 时经 expectedVersion
// 回传;冲突(409)后重拉最新草稿、合并本地改动、用新版本号重试。load/create/update 成功
// 后同步刷新,保证下一次写基于最新版本。
const draftVersionRef = useRef<number | null>(null);
// 串行化所有写操作,避免并发 create。
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
@@ -207,10 +241,12 @@ export function useDraftPersistence(
}, [currentStep]);
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
// 切换到不同草稿(或清空)时把乐观锁版本一并复位,防止拿旧草稿的版本号去写新草稿。
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
(action) => {
setCurrentDraftIdState((prev) => {
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
if (next !== currentDraftIdRef.current) draftVersionRef.current = null;
currentDraftIdRef.current = next;
return next;
});
@@ -273,14 +309,40 @@ export function useDraftPersistence(
const id = currentDraftIdRef.current;
if (id) {
const updated = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
...(step3 ? { step3 } : {}),
});
if (updated) return updated.id;
// 服务端已不存在(被删/换账号)→ 落回新建。
try {
const updated = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2,
...(step3 ? { step3 } : {}),
...(draftVersionRef.current !== null ? { expectedVersion: draftVersionRef.current } : {}),
});
if (updated) {
draftVersionRef.current = updated.version;
return updated.id;
}
// 服务端已不存在(被删/换账号)→ 落回新建。
} catch (err) {
if (!isDraftVersionConflictError(err)) throw err;
// 乐观锁冲突:另一写手(后台作业 / 另一标签页 / 重命名)已推进版本。
// 重拉最新草稿,把本地 step2.runs 合并回去,用新版本号重试一次——双方都不丢。
const latest = await apiGet(id);
if (latest) {
const mergedStep2 = mergeStep2OnConflict(step2, latest.step2);
const retried = await apiUpdate(id, {
...(furthestStep !== undefined ? { furthestStep } : {}),
step1,
step2: mergedStep2,
...(step3 ? { step3 } : {}),
expectedVersion: latest.version,
});
if (retried) {
draftVersionRef.current = retried.version;
return retried.id;
}
}
// latest 为 null(被删)或重试仍 404 → 落回新建。
}
}
const created = await apiCreate({
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
@@ -294,6 +356,7 @@ export function useDraftPersistence(
...(step3 ? { step3 } : {}),
});
currentDraftIdRef.current = created.id;
draftVersionRef.current = created.version;
setCurrentDraftId(created.id);
return created.id;
};
@@ -301,7 +364,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[apiCreate, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
);
const ensureDraft = useCallback(
@@ -340,12 +403,35 @@ export function useDraftPersistence(
const run = async (): Promise<string | null> => {
const id = currentDraftIdRef.current;
if (id) {
const updated = await apiUpdate(id, { furthestStep, step2 });
if (updated) {
currentDraftIdRef.current = updated.id;
return updated.id;
try {
const updated = await apiUpdate(id, {
furthestStep,
step2,
...(draftVersionRef.current !== null ? { expectedVersion: draftVersionRef.current } : {}),
});
if (updated) {
currentDraftIdRef.current = updated.id;
draftVersionRef.current = updated.version;
return updated.id;
}
// 服务端已不存在 → 落回新建。
} catch (err) {
if (!isDraftVersionConflictError(err)) throw err;
// 冲突 → 重拉合并 runs 后用新版本号重试一次(与 enqueueSave 同策略)。
const latest = await apiGet(id);
if (latest) {
const retried = await apiUpdate(id, {
furthestStep,
step2: mergeStep2OnConflict(step2, latest.step2),
expectedVersion: latest.version,
});
if (retried) {
currentDraftIdRef.current = retried.id;
draftVersionRef.current = retried.version;
return retried.id;
}
}
}
// 服务端已不存在 → 落回新建。
}
const step1 = getStep1Snapshot();
if (!hasSavableContent(step1, step2)) return null;
@@ -360,6 +446,7 @@ export function useDraftPersistence(
step2,
});
currentDraftIdRef.current = created.id;
draftVersionRef.current = created.version;
setCurrentDraftId(created.id);
return created.id;
};
@@ -367,7 +454,7 @@ export function useDraftPersistence(
saveQueueRef.current = next.catch(() => undefined);
return next as Promise<string | null>;
},
[apiCreate, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
);
const handleManualSave = useCallback(() => {
@@ -463,6 +550,8 @@ export function useDraftPersistence(
setStep2InitGate(draft.furthestStep >= 2);
setCurrentDraftId(draft.id);
// 必须在 setCurrentDraftId 之后:切换草稿会先把版本复位,这里再打上加载到的版本。
draftVersionRef.current = draft.version;
setIsHistoryOpen(false);
prevStepRef.current = draft.furthestStep;
setCurrentStep(draft.furthestStep);
@@ -563,8 +652,12 @@ export function useDraftPersistence(
void (async () => {
try {
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
// 不带 expectedVersion:部分更新不触碰内容字段,与自动保存并发时双方各写各的
// 字段;自动保存侧撞 409 会重拉合并重试(标题被保留),一致性由 DB 版本保证。
const updated = await apiUpdate(id, { title: trimmed });
if (updated) {
// 重命名的正是当前草稿 → 同步版本号,省掉下一次自动保存的一次 409 往返。
if (id === currentDraftIdRef.current) draftVersionRef.current = updated.version;
await refreshList();
triggerToast("已重命名");
}
@@ -447,12 +447,19 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
}
}
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
const clientTurnId =
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const res = await streamIntent({
threadId: intentThreadId,
message: trimmed,
model: selectedModelRef.current || undefined,
intentAgentId: intentAgentIdRef.current,
taskContext: taskContextRef.current,
clientTurnId,
files: fileRefs,
signal: aborter.signal,
onTextDelta: (delta) => {
@@ -153,6 +153,13 @@ export interface StartJobPayload {
* 普通会商省略。`toStartBody` 里下发为后端 `business_code`。
*/
businessCode?: string | null;
/**
* 幂等请求 id(Phase 3 并发根治)。缺省由 `toStartBody` 用 nanoid 现生成;调用方若想在
* 逻辑重试间复用同一 id(便于后端审计「同 requestId 但入参变了」的异常重试),可显式传入。
* 「同一草稿至多一个活跃作业」的真正互斥由后端按 `(draftId/userId/taskId)` 作用域的唯一活跃键
* 保证,requestId 仅作审计/排查线索,不承担正确性。
*/
requestId?: string;
}
/** 终态集合——SSE/轮询判定作业是否结束。 */
+3
View File
@@ -64,3 +64,6 @@ ROUNDTABLE_JOB_GATEWAY=inprocess
DEEPSEEK_CHAT_API_KEY=sk-686b7bc000be4b7cbed6dccb3897c411
GLM5_FP8_API_KEY=modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
SILICONFLOW_API_KEY=sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
# WeKnora admin credentials (DeerFlow server-side iframe SSO proxy)
WEKNORA_ADMIN_PASSWORD=1qaz@WSX
@@ -124,6 +124,28 @@ schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
Clients may retry the compatibility route only for a framework/proxy-level
`404 Not Found`; domain-level missing-session responses must remain real 404s.
The position-roundtable state machine is multi-worker safe: node commands
(`complete`, `bind`, conversation save, reject) and session commands
(`activate`, archive, delete) lock the parent session and affected nodes in one
transaction, then record a unique command id for retry replay. The task and
intent snapshot writes use the same parent-row lock; they are permitted only
while `status=intent_pending`, so a late “confirm intent” write cannot change a
chain that another user has already activated. Activation itself rechecks the
stored confirmed intent after taking the lock. `tests/test_position_roundtable_
commands.py` and `tests/test_position_roundtable_concurrency.py` cover the
state transitions; `tests/test_roundtable_postgres_concurrency.py` is the real
PostgreSQL acceptance suite (set `DEERFLOW_POSTGRES_CONCURRENCY_TEST_URL`).
Roundtable backend jobs use a database unique active-dedupe key plus a leased
dispatcher, never process-local start deduplication. Cancellation is two
phase (`cancel_requested` then `cancelled`): the lease owner’s watcher normally
finalizes it, while the dispatcher reaps a cancellation whose lease has
expired after a worker crash. A worker that loses lease stops its local model
run immediately. Do not reintroduce direct `executor.cancel()` from the HTTP
cancel route, because it can cancel the watcher before the database state is
finalized. The boolean defaults on these new rows must use SQLAlchemy
`false()` rather than numeric `0`, so fresh PostgreSQL schemas can be created.
Position roundtable selects the separately seeded built-in agent
`position-roundtable-intent` by passing `intent_agent_id` to the shared
`/api/intent/init` and `/api/intent/stream` endpoints. Its request lifecycle,
@@ -163,6 +163,25 @@ The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username`
Position-roundtable history supports two modes. A non-empty `external_task_id` from the frontend route's `taskId` creates a row with `task_scoped = true`, so all sessions, nodes and persisted conversation snapshots for that task are shared across users; `user_id` is then creator audit metadata only. When the field is absent or empty, the original per-user personal-session behavior remains unchanged. Alembic revision `20260808_01` adds `task_scoped` with the safe default `false` for historical rows plus the task/update-time index.
### Roundtable concurrency guarantees
Position-roundtable node completion, rejection, thread binding and activation
are database transactions with row locking and retry-stable command ids. This
keeps simultaneous seat completion from leaving a downstream stage locked.
Task and intent snapshots are frozen when the chain becomes active, so a late
request from another user cannot alter the confirmed input. Background
roundtable jobs use a database unique active-job key and renewable worker
leases; cancelled jobs are finalized by the lease holder or recovered after an
expired lease, allowing users to start a new job after a worker failure.
The ordinary unit tests use SQLite. To verify deployed database semantics with
real PostgreSQL row locks and independent connections, set
`DEERFLOW_POSTGRES_CONCURRENCY_TEST_URL` and run:
```bash
PYTHONPATH=. uv run --no-sync pytest tests/test_roundtable_postgres_concurrency.py -q
```
AI writing supports a sample-imitation mode: the frontend can upload or paste a sample article, the Gateway extracts text for document files, and the `ai_writing` graph runs a `sample_analyzer` node before normal intent/research/outline/draft stages. The resulting style profile is injected into outline and draft prompts with explicit guardrails against copying source sentences, facts, names, or data.
### Custom Agents
@@ -255,23 +255,23 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
from deerflow.persistence.position_roundtable import make_position_roundtable_store
app.state.position_roundtable_store = make_position_roundtable_store(sf)
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
from deerflow.persistence.position_roles import make_position_role_store
app.state.position_role_store = make_position_role_store(sf)
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
from deerflow.persistence.business_mapping import make_business_mapping_store
@@ -293,30 +293,30 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.dashboard_session_store = make_dashboard_session_store(sf)
from deerflow.persistence.task_buttons import make_task_button_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.integrations.weknora.runtime import register_llmwiki_store
from deerflow.persistence.llmwiki import make_llmwiki_store
app.state.llmwiki_store = make_llmwiki_store(sf)
register_llmwiki_store(app.state.llmwiki_store)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
# Task-scoped artifact submission status: shared by task; actor is audit-only.
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
from deerflow.persistence.task_buttons import make_task_button_store
app.state.task_button_store = make_task_button_store(sf)
from deerflow.integrations.weknora.runtime import register_llmwiki_store
from deerflow.persistence.llmwiki import make_llmwiki_store
app.state.llmwiki_store = make_llmwiki_store(sf)
register_llmwiki_store(app.state.llmwiki_store)
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
app.state.taskcop_task_store = make_taskcop_task_store(sf)
from deerflow.persistence.task_reports import make_task_report_store
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
# Task-scoped artifact submission status: shared by task; actor is audit-only.
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -332,6 +332,13 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# (Phase 2 后台编排图写进度用)。
_register_roundtable_job_default_store(app.state.roundtable_job_store)
# 意图回合单飞 store(Phase 4):``/api/intent/stream`` 据此对 (thread_id,
# client_turn_id) 跨 worker 单飞 + 幂等回放,杜绝并发双发意图。sf 为 None(无 DB)
# 时为 None,路由侧判空跳过单飞、走原流程。
from deerflow.persistence.intent_turns import make_intent_turn_store
app.state.intent_turn_store = make_intent_turn_store(sf)
# 圆桌会商「诊断日志」store:会商全流程各处报错 / 关键事件落库,供管理员诊断页筛选。
# 同样登记进程级默认仓储——harness 侧编排引擎 / 进程内网关跨边界写诊断用。
from deerflow.persistence.roundtable_diagnostics import (
@@ -393,18 +400,32 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# task 作业把最终报告写回**独立的** task-draft 存储(不分权)。
task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
)
# Phase 3 dispatcher:跨 worker 领取 queued/租约过期作业 + outbox 补偿器。
# 每个 worker 进程都跑一个;谁先 claim_job 成功谁执行(DB 条件 UPDATE 互斥),
# 崩溃的 worker 租约过期后由其它 worker 接管。路由 start/resume 后 nudge 立即唤醒。
from app.gateway.roundtable_job_dispatcher import RoundtableJobDispatcher
app.state.roundtable_job_dispatcher = RoundtableJobDispatcher(
app.state.roundtable_job_store,
app.state.roundtable_job_executor,
draft_store=getattr(app.state, "roundtable_draft_store", None),
task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
)
app.state.roundtable_job_dispatcher.start()
else:
app.state.roundtable_job_executor = None
app.state.roundtable_job_dispatcher = None
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
# RunManager with store backing for persistence
app.state.run_manager = RunManager(store=app.state.run_store)
from deerflow.runtime import make_redis_concurrency_gate
app.state.concurrency_gate = make_redis_concurrency_gate(config)
try:
yield
@@ -412,16 +433,19 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
from deerflow.persistence.notifications import set_notification_store
set_notification_store(None)
dispatcher = getattr(app.state, "roundtable_job_dispatcher", None)
if dispatcher is not None:
await dispatcher.stop()
queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
if queue is not None:
from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
await queue.stop()
set_auto_ingest_queue(None)
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
await close_engine()
gate = getattr(app.state, "concurrency_gate", None)
if gate is not None:
await gate.aclose()
# ---------------------------------------------------------------------------
@@ -447,12 +471,12 @@ get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run
get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
def get_concurrency_gate(request: Request):
"""Return the optional cross-worker concurrency gate."""
return getattr(request.app.state, "concurrency_gate", None)
def get_store(request: Request):
@@ -624,34 +648,34 @@ _cached_local_provider: LocalAuthProvider | None = None
_cached_repo: SQLiteUserRepository | None = None
def get_request_access_token(request: Request) -> str | None:
"""Extract access token from cookie or Authorization header.
Priority:
1. ``access_token`` cookie (browser session mode)
2. ``Authorization: Bearer <token>`` header (API client mode)
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
"""
cookie_token = request.cookies.get("access_token")
if cookie_token:
return cookie_token
authorization = request.headers.get("authorization", "").strip()
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer":
token = token.strip()
if token:
return token
from app.gateway.proxy_path import app_relative_path
path = app_relative_path(request).rstrip("/")
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
query_token = (request.query_params.get("access_token") or "").strip()
if query_token:
return query_token
return None
def get_request_access_token(request: Request) -> str | None:
"""Extract access token from cookie or Authorization header.
Priority:
1. ``access_token`` cookie (browser session mode)
2. ``Authorization: Bearer <token>`` header (API client mode)
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
"""
cookie_token = request.cookies.get("access_token")
if cookie_token:
return cookie_token
authorization = request.headers.get("authorization", "").strip()
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer":
token = token.strip()
if token:
return token
from app.gateway.proxy_path import app_relative_path
path = app_relative_path(request).rstrip("/")
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
query_token = (request.query_params.get("access_token") or "").strip()
if query_token:
return query_token
return None
def get_local_provider() -> LocalAuthProvider:
@@ -0,0 +1,355 @@
"""圆桌后台作业 dispatcher(Phase 3):跨 worker 领取 + 崩溃接管 + outbox 补偿。
把「谁来跑这个作业」从「发起请求的那个 worker 的进程内存」收敛到 **DB 租约**:
- **领取(claim)**:后台循环定期 ``list_claimable``(``queued`` 新作业/续跑,或 ``running``
但租约过期的僵尸),对每个候选用 ``claim_job`` 的**原子条件 UPDATE** 抢占 —— 多 worker
并发扫到同一候选时只有一个 ``rowcount==1``,其余拿到 None 自动跳过。一致性来自 DB,
不是进程内锁。
- **心跳**:领取成功后交给 ``RoundtableJobExecutor`` 跑,执行器内部定期 ``renew_lease``;
worker 崩溃则心跳停止,租约最长一个 TTL 后过期,下一轮循环在其它 worker 重新领取
(``attempt+1``)—— 崩溃可接管。
- **启动扫描**:本 worker 启动时立即跑一轮 ``dispatch_once``,尽快接走历史遗留的
queued / 租约过期作业(不用等第一个轮询周期)。
- **outbox 补偿器**:``draft_persist_pending=1`` 的作业(后台研讨结果因乐观锁冲突没能写回
草稿)在这里被重新投影到草稿 step2.runs,成功后清标记 —— 「研讨结果不丢」由 DB 标记驱动。
所有 worker 都跑同一个 dispatcher;谁先 ``claim_job`` 成功谁执行,天然幂等、互斥。
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from typing import Any
from app.gateway.roundtable_job_executor import (
_LEASE_TTL_SECONDS,
WORKER_ID,
params_from_snapshot,
)
logger = logging.getLogger(__name__)
# dispatcher 轮询间隔(秒)。路由 start/resume 成功后会 ``nudge()`` 立即唤醒,故正常路径
# 几乎无感延迟;该间隔只是「没有 nudge 时的兜底」,兼作崩溃接管的发现周期。
_DISPATCH_POLL_INTERVAL = 3.0
# outbox 补偿器轮询间隔(秒)。pending 标记是低频异常路径,慢一点无妨。
_COMPENSATE_INTERVAL = 10.0
# 单轮最多领取的作业数(防止一次扫太多把事件循环占死)。
_CLAIM_BATCH = 20
class RoundtableJobDispatcher:
"""跨 worker 的圆桌后台作业调度器(每个 worker 进程一个实例,lifespan 起停)。"""
def __init__(
self,
store,
executor,
*,
draft_store=None,
task_draft_store=None,
worker_id: str | None = None,
) -> None:
self._store = store
self._executor = executor
# outbox 补偿器投影草稿用(个人草稿 / task 草稿两个存储)。
self._draft_store = draft_store
self._task_draft_store = task_draft_store
self._worker_id = worker_id or WORKER_ID
self._wake = asyncio.Event()
self._task: asyncio.Task | None = None
self._stopping = False
self._last_compensate = datetime.now(UTC)
# ── 生命周期 ─────────────────────────────────────────────────────────────
def start(self) -> None:
"""启动后台循环(lifespan startup 调用)。幂等。"""
if self._task is not None and not self._task.done():
return
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="roundtable-job-dispatcher")
logger.info("[roundtable-jobs] dispatcher started (worker=%s)", self._worker_id)
async def stop(self) -> None:
"""停止后台循环(lifespan shutdown 调用)。"""
self._stopping = True
self._wake.set()
if self._task is not None:
self._task.cancel()
with suppress(asyncio.CancelledError, Exception):
await self._task
self._task = None
def nudge(self) -> None:
"""立即唤醒一轮领取(路由 start/resume 成功后调用,降低启动延迟)。"""
self._wake.set()
# ── 主循环 ───────────────────────────────────────────────────────────────
async def _loop(self) -> None:
# 启动即扫一轮:尽快接管历史遗留 queued / 租约过期(僵尸)作业。
await self._safe_dispatch()
while not self._stopping:
try:
await asyncio.wait_for(self._wake.wait(), timeout=_DISPATCH_POLL_INTERVAL)
except TimeoutError:
pass
self._wake.clear()
if self._stopping:
break
await self._safe_dispatch()
# outbox 补偿器按自己的节奏跑(低频)。
if (datetime.now(UTC) - self._last_compensate).total_seconds() >= _COMPENSATE_INTERVAL:
self._last_compensate = datetime.now(UTC)
await self._safe_compensate()
async def _safe_dispatch(self) -> None:
try:
await self.dispatch_once()
except Exception: # noqa: BLE001 — 单轮失败不能杀死循环,下轮重试
logger.exception("[roundtable-jobs] dispatch round failed")
async def _safe_compensate(self) -> None:
try:
await self.compensate_once()
except Exception: # noqa: BLE001
logger.exception("[roundtable-jobs] compensate round failed")
# ── 领取 ─────────────────────────────────────────────────────────────────
async def dispatch_once(self) -> int:
"""扫一轮可领取作业并逐个条件抢占。返回本轮成功启动的作业数。"""
now = datetime.now(UTC)
await self._reap_cancel_requested(now)
try:
candidate_ids = await self._store.list_claimable(now=now, limit=_CLAIM_BATCH)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] list_claimable failed", exc_info=True)
return 0
if not candidate_ids:
return 0
started = 0
lease_until = now + timedelta(seconds=_LEASE_TTL_SECONDS)
for job_id in candidate_ids:
try:
row = await self._store.claim_job(
job_id, lease_owner=self._worker_id, lease_until=lease_until
)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] claim_job failed for %s", job_id, exc_info=True)
continue
if row is None:
# 别的 worker 先领了 / 状态已变 —— 条件 UPDATE rowcount=0,跳过即可。
continue
params = params_from_snapshot(
row.get("input_snapshot"), job_id=job_id, lease_owner=self._worker_id
)
if params is None:
# 快照缺失/损坏:无法重建执行参数 —— 标记 error 并释放活跃键,避免僵死占位。
logger.error(
"[roundtable-jobs] job %s claimed but snapshot missing/corrupt; marking error",
job_id,
)
with suppress(Exception):
await self._store.update_progress(
job_id,
lease_owner=self._worker_id,
status="error",
error="input_snapshot 缺失或损坏,无法在后台重建研讨",
)
continue
self._executor.start_job(params)
started += 1
logger.info(
"[roundtable-jobs] worker=%s claimed job %s (attempt=%s, resume=%s)",
self._worker_id, job_id, row.get("attempt"), params.is_resume,
)
return started
async def _reap_cancel_requested(self, now: datetime) -> int:
"""收口持租 worker 崩溃后遗留的 ``cancel_requested`` 作业。
有效租约仍在时,必须让持租 worker 的取消看门狗收口;无租约或租约过期时,已经
不存在可信的持租者,此处通过 ``finalize_cancel`` 的条件 UPDATE 安全地回收。
"""
try:
candidate_ids = await self._store.list_cancel_reapable(
now=now, limit=_CLAIM_BATCH
)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] list_cancel_reapable failed", exc_info=True)
return 0
finalized = 0
for job_id in candidate_ids:
try:
row = await self._store.finalize_cancel(job_id)
except Exception: # noqa: BLE001
logger.warning(
"[roundtable-jobs] finalize orphaned cancellation failed for %s",
job_id,
exc_info=True,
)
continue
if row is not None:
finalized += 1
logger.info(
"[roundtable-jobs] reaped orphaned cancellation for job %s", job_id
)
return finalized
# ── outbox 补偿器 ────────────────────────────────────────────────────────
def _draft_store_for(self, task_id: str | None):
"""task 作业 → task-draft 存储;普通作业 → 个人草稿存储(与执行器同规则)。"""
return self._task_draft_store if task_id else self._draft_store
async def compensate_once(self) -> int:
"""把 ``draft_persist_pending=1`` 的作业结果投影回草稿,成功后清标记。
投影逻辑与执行器 ``_write_run_to_draft`` 的终态写一致:读最新草稿 → 按
``run_id=job-<jobId>`` upsert 一个 run(用作业行里的 dialogues/step3/status)→
带 ``expected_version`` 乐观锁写回。写成功(或草稿已含该 run 的终态)即清标记。
返回本轮成功投影的作业数。
"""
try:
pending = await self._store.list_draft_persist_pending(limit=_CLAIM_BATCH)
except Exception: # noqa: BLE001
logger.warning("[roundtable-jobs] list_draft_persist_pending failed", exc_info=True)
return 0
if not pending:
return 0
projected = 0
for job in pending:
job_id = job.get("id")
draft_id = job.get("draft_id")
draft_store = self._draft_store_for(job.get("task_id"))
if not draft_id or draft_store is None:
# 无草稿可投影(如纯 task 作业但 task_store 未配)——直接清标记,避免死循环。
with suppress(Exception):
await self._store.clear_draft_persist_pending(job_id)
continue
try:
ok = await self._project_job_to_draft(draft_store, job)
except Exception: # noqa: BLE001
logger.warning(
"[roundtable-jobs] compensate project failed for job %s", job_id, exc_info=True
)
continue
if ok:
with suppress(Exception):
await self._store.clear_draft_persist_pending(job_id)
projected += 1
logger.info("[roundtable-jobs] compensated draft write for job %s", job_id)
return projected
async def _project_job_to_draft(self, draft_store: Any, job: dict[str, Any]) -> bool:
"""把单个作业的最新状态投影成草稿 step2.runs 里的一个 run。成功返回 True。
与执行器不同:这里没有 ``params``(作业可能跑在别的 worker / 已结束),只能用作业行
里已落库的 ``dialogues``/``step3``/``status`` 重建 run —— 这些正是「研讨结果」本体,
足够让前端加载草稿时看到完整的后台研讨记录与报告。
"""
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
job_id = job.get("id")
draft_id = job.get("draft_id")
user_id = job.get("user_id")
run_id = f"job-{job_id}"
status = job.get("status") or "running"
dialogues = job.get("dialogues") or []
step3 = job.get("step3")
# task 草稿不分权:get/update 不传 user_id;个人草稿按 user。
is_task = bool(job.get("task_id"))
for _attempt in range(3):
try:
draft = (
await draft_store.get_draft(draft_id)
if is_task
else await draft_store.get_draft(draft_id, user_id)
)
except Exception: # noqa: BLE001
draft = None
if draft is None:
# 草稿已被删除 —— 无处投影,视作完成(清标记)。
return True
expected_version = draft.get("version")
step2 = dict(draft.get("step2") or {})
runs = list(step2.get("runs") or [])
idx = next((i for i, r in enumerate(runs) if r.get("id") == run_id), -1)
created = runs[idx].get("createdAt") if idx >= 0 else (job.get("created_at") or datetime.now(UTC).isoformat())
prev_step3 = runs[idx].get("step3") if idx >= 0 else None
effective_step3 = step3 if step3 is not None else prev_step3
# 从 dialogues 里提取席位信息(尽量还原 selectedAgents)。
agents_seen: list[dict[str, str]] = []
seen_ids: set[str] = set()
for d in dialogues:
aid = str(d.get("agentId") or "")
if aid and aid not in seen_ids and d.get("role") == "seat":
seen_ids.add(aid)
agents_seen.append({"agent_id": aid, "name": str(d.get("name") or aid)})
run = {
"id": run_id,
"title": "后台研讨",
"createdAt": created,
"source": job.get("orchestration_mode") or "recommend",
"selectedAgents": agents_seen,
"threadIds": job.get("thread_ids"),
"coordinatorName": job.get("coordinator_name"),
"step2RoundtableDialogues": dialogues,
"lastLeaderContent": next(
(d.get("content") for d in reversed(dialogues) if d.get("role") == "leader"), ""
) or "",
"hasConsensus": status == "done",
"consensusPercentage": int(job.get("consensus_percentage") or (100 if status == "done" else 0)),
"budgetLimit": 0,
"orchestrationMode": job.get("orchestration_mode") or "recommend",
"chain": job.get("chain"),
"orchestrationPlan": job.get("orchestration_plan"),
"step3": effective_step3,
"jobId": job_id,
"jobStatus": status,
}
runs = [run if i == idx else r for i, r in enumerate(runs)] if idx >= 0 else [*runs, run]
step2["runs"] = runs
step2["activeRunId"] = run_id
has_report = effective_step3 is not None
update_kwargs: dict[str, Any] = {
"step2": step2,
"furthest_step": 3 if (status == "done" and has_report) else 2,
}
if has_report:
update_kwargs["step3"] = effective_step3
try:
if is_task:
await draft_store.update_draft(
draft_id, expected_version=expected_version, **update_kwargs
)
else:
await draft_store.update_draft(
draft_id, user_id, expected_version=expected_version, **update_kwargs
)
return True
except DraftConcurrentWriteError:
# 并发写冲突 —— 重读最新草稿再合并重试(与执行器同款乐观锁重试)。
continue
except Exception: # noqa: BLE001
logger.warning(
"[roundtable-jobs] compensate update_draft failed for %s", draft_id, exc_info=True
)
return False
# 乐观锁重试耗尽:保留 pending 标记,等下一轮补偿器再试。
return False
@@ -16,9 +16,12 @@ from __future__ import annotations
import asyncio
import logging
import os
import uuid as _uuid
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from deerflow.agents.roundtable_orchestrator import (
@@ -27,10 +30,34 @@ from deerflow.agents.roundtable_orchestrator import (
run_orchestration,
)
from deerflow.persistence.roundtable_diagnostics import DiagnosticsRecorder, get_default_store
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
# 本 worker 进程的唯一标识(Phase 3 租约持有者)。pid 保证同机多 worker 进程互不相同,
# 追加随机后缀避免 pid 复用导致的极端撞车。跨进程「谁在跑这个作业」由它写进 lease_owner。
WORKER_ID = f"w-{os.getpid()}-{_uuid.uuid4().hex[:8]}"
# 租约 TTL 与心跳间隔(Phase 3)。心跳每 _HEARTBEAT_INTERVAL 秒续租一次,把 lease_until
# 推到 now + _LEASE_TTL_SECONDS。只要 worker 活着,租约永远不过期、别的 worker 抢不走;
# worker 崩溃后心跳停止,最长 _LEASE_TTL_SECONDS 后租约过期,dispatcher 在其它 worker 上
# 重新领取(attempt+1)—— 崩溃可接管的一致性来自 DB 租约而非进程内存。
# 约束:_LEASE_TTL_SECONDS 必须明显大于 _HEARTBEAT_INTERVAL(留足续租余量)。
_LEASE_TTL_SECONDS = 60
_HEARTBEAT_INTERVAL = 20.0
# 跨进程取消轮询间隔(秒)。cancel 端点把 ``status='cancel_requested'`` 写进 DB(权威意图),
# 实际跑作业的 worker 上的看门狗协程定期读 DB 检测到后,收口 ``cancelled`` 终态并对本进程的
# 编排 task 调 ``cancel()``。多 worker 下 cancel 请求可能打到非执行 worker,靠此机制最长延迟
# 一个轮询周期即可让幽灵作业停下。
_CANCEL_POLL_INTERVAL = 5.0
# 草稿乐观锁重试次数。每次尝试都带 expected_version(冲突则重读重试);重试全部耗尽后
# **绝不退化为无版本检查的 last-write-wins 盲写**,而是给作业置 draft_persist_pending
# 标记,由 outbox 补偿器(Phase 3)投影结果到草稿——一致性来自 DB 标记而非运气。
_DRAFT_WRITE_RETRIES = 3
# 「后台挂起」的 Step3 自动产出(方案总结报告 md + 结果绘制 HTML)总开关。
# 历史:2026-06-05 因后台 start_run 路径报告不稳定临时禁用;2026-06-12 定位到根因是
# ``_enforce_agent_access`` 对进程内虚拟 request 的 ``request.state`` 裸访问(AttributeError
@@ -203,6 +230,115 @@ class StartParams:
# 深链接业务码(rwfx→6BF 等)。透传给 run_orchestration → build_summary_prompt:写实业务额外注入
# 「业务链完整性核对」,让后台总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。
business_code: str | None = None
# Phase 3 租约持有者(= dispatcher 领取时写入的 worker_id)。执行器据此:① 所有进度落库
# 带 lease_owner(update_progress 租约门,非持租者的过期写被拒);② 心跳续租;③ 看门狗
# 检测到 cancel_requested 时以持租者身份收口 cancelled。None = 旧路径(不走租约门)。
lease_owner: str | None = None
# ── Phase 3:input_snapshot 序列化 / 反序列化 ─────────────────────────────────
#
# 路由 start/resume 时把整份启动入参序列化成 JSON 存进 ``input_snapshot``;dispatcher
# 在**任意 worker** 领取 queued/租约过期作业后,据此无损重建 StartParams —— 这是「跨进程
# 可接管 / 崩溃可恢复」的前提(不再依赖发起请求的那个 worker 的进程内存)。
#
# 快照 schema 版本 ``_SNAPSHOT_VERSION``:将来字段变更时升版并兼容读旧版。
_SNAPSHOT_VERSION = 1
def build_start_snapshot(
*,
user_id: str,
agents: list[SeatRef],
seed_message: str,
model: str | None = None,
mode: str = "recommend",
intent_text: str = "",
thread_ids: dict[str, str] | None = None,
coordinator_name: str = "roundtable-coordinator",
draft_id: str | None = None,
task_id: str | None = None,
is_resume: bool = False,
chain: dict[str, Any] | None = None,
orchestration_plan: dict[str, Any] | None = None,
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED,
seat_thinking_enabled: bool | None = None,
seat_reasoning_effort: str | None = None,
seat_subagent_enabled: bool = False,
seat_skill_directives: dict[str, bool] | None = None,
seat_modes: dict[str, str] | None = None,
gather_first: bool = False,
business_code: str | None = None,
) -> dict[str, Any]:
"""把一次启动入参序列化为可 JSON 落库的快照 dict(snake_case,对齐 StartParams)。"""
return {
"v": _SNAPSHOT_VERSION,
"user_id": user_id,
"agents": [{"agent_id": a.agent_id, "name": a.name} for a in agents],
"seed_message": seed_message,
"model": model,
"mode": mode,
"intent_text": intent_text,
"thread_ids": thread_ids,
"coordinator_name": coordinator_name,
"draft_id": draft_id,
"task_id": task_id,
"is_resume": is_resume,
"chain": chain,
"orchestration_plan": orchestration_plan,
"enable_report": enable_report,
"seat_thinking_enabled": seat_thinking_enabled,
"seat_reasoning_effort": seat_reasoning_effort,
"seat_subagent_enabled": seat_subagent_enabled,
"seat_skill_directives": dict(seat_skill_directives or {}),
"seat_modes": dict(seat_modes or {}),
"gather_first": bool(gather_first),
"business_code": business_code,
}
def params_from_snapshot(
snapshot: dict[str, Any] | None, *, job_id: str, lease_owner: str | None
) -> StartParams | None:
"""从 ``input_snapshot`` 无损重建 StartParams。快照缺失 / 损坏返回 None(调用方跳过)。"""
if not snapshot or not isinstance(snapshot, dict):
return None
try:
agents = [
SeatRef(str(a.get("agent_id") or ""), str(a.get("name") or a.get("agent_id") or ""))
for a in (snapshot.get("agents") or [])
if isinstance(a, dict) and a.get("agent_id")
]
if not agents:
return None
return StartParams(
job_id=job_id,
user_id=str(snapshot.get("user_id") or ""),
agents=agents,
seed_message=str(snapshot.get("seed_message") or ""),
model=snapshot.get("model"),
mode=str(snapshot.get("mode") or "recommend"),
intent_text=str(snapshot.get("intent_text") or ""),
thread_ids=snapshot.get("thread_ids"),
coordinator_name=str(snapshot.get("coordinator_name") or "roundtable-coordinator"),
draft_id=snapshot.get("draft_id"),
task_id=snapshot.get("task_id"),
is_resume=bool(snapshot.get("is_resume")),
chain=snapshot.get("chain"),
orchestration_plan=snapshot.get("orchestration_plan"),
enable_report=bool(snapshot.get("enable_report", ROUNDTABLE_BACKGROUND_REPORT_ENABLED)),
seat_thinking_enabled=snapshot.get("seat_thinking_enabled"),
seat_reasoning_effort=snapshot.get("seat_reasoning_effort"),
seat_subagent_enabled=bool(snapshot.get("seat_subagent_enabled")),
seat_skill_directives=dict(snapshot.get("seat_skill_directives") or {}),
seat_modes=dict(snapshot.get("seat_modes") or {}),
gather_first=bool(snapshot.get("gather_first")),
business_code=snapshot.get("business_code"),
lease_owner=lease_owner,
)
except Exception: # noqa: BLE001 — 快照损坏不应让 dispatcher 崩溃
logger.exception("failed to rebuild StartParams from snapshot for job %s", job_id)
return None
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
@@ -259,7 +395,10 @@ class RoundtableJobExecutor:
thread_ids, coordinator_name = await init()
try:
await self._store.update_progress(
params.job_id, thread_ids=thread_ids, coordinator_name=coordinator_name
params.job_id,
lease_owner=params.lease_owner,
thread_ids=thread_ids,
coordinator_name=coordinator_name,
)
except Exception: # noqa: BLE001
logger.warning("persist thread_ids for job %s failed", params.job_id, exc_info=True)
@@ -277,22 +416,52 @@ class RoundtableJobExecutor:
agents=params.agents,
on_dialogue=_on_dialogue if (self._draft_store_for(params) and params.draft_id) else None,
recorder=diag,
# Phase 3:进度落库带租约持有者 —— update_progress 租约门只放行持租者的写,
# 非持租者(被接管/已取消)的过期进度写被 DB 条件 UPDATE 拒绝。
lease_owner=params.lease_owner,
)
result = await run_orchestration(
gateway=gateway,
progress=progress,
agents=params.agents,
thread_ids=thread_ids,
coordinator_name=coordinator_name,
model=params.model,
seed_message=params.seed_message,
mode=params.mode,
intent_text=params.intent_text,
enable_report=params.enable_report,
plan=params.orchestration_plan,
gather_first=params.gather_first,
business_code=params.business_code,
# Phase 3 心跳续租:活着就持续把 lease_until 推后,保证本 worker 的租约不过期、
# 别的 worker 抢不走;本协程随编排 task 生死(finally 里 cancel)。崩溃则心跳停,
# 租约最长 _LEASE_TTL_SECONDS 后过期 → dispatcher 在其它 worker 接管。
main_task = asyncio.current_task()
heartbeat = (
asyncio.create_task(self._heartbeat(params.job_id, params.lease_owner, main_task))
if params.lease_owner
else None
)
# 跨进程取消看门狗:run_orchestration 是一个不透明长 await(多轮 LLM),
# 无法在其内部插检查点。改为起一个并行的看门狗协程,定期读 DB 的 job status;
# cancel 端点把 status=cancel_requested 写进 DB(权威意图),看门狗检测到后以**持租者**
# 身份收口 cancelled 终态(清唯一活跃键 + 租约)并对本编排 task 调 cancel() →
# CancelledError 在 run_orchestration 的下一个 await 点抛出并上抛。
# 这让 cancel 在多 worker 下跨进程生效——cancel 打到非执行 worker 也无妨。
watcher = asyncio.create_task(
self._watch_cancellation(params.job_id, main_task, params.lease_owner)
)
try:
result = await run_orchestration(
gateway=gateway,
progress=progress,
agents=params.agents,
thread_ids=thread_ids,
coordinator_name=coordinator_name,
model=params.model,
seed_message=params.seed_message,
mode=params.mode,
intent_text=params.intent_text,
enable_report=params.enable_report,
plan=params.orchestration_plan,
gather_first=params.gather_first,
business_code=params.business_code,
)
finally:
watcher.cancel()
with suppress(asyncio.CancelledError, Exception):
await watcher
if heartbeat is not None:
heartbeat.cancel()
with suppress(asyncio.CancelledError, Exception):
await heartbeat
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
await self._write_run_to_draft(
params,
@@ -302,16 +471,18 @@ class RoundtableJobExecutor:
step3=result.step3,
thread_ids=thread_ids,
)
# step3 也挂 job(API / 弹窗用)。
# step3 也挂 job(API / 弹窗用)。带租约门:仅持租者可写。
if result.status == "done" and result.step3:
try:
await self._store.update_progress(params.job_id, step3=result.step3)
await self._store.update_progress(
params.job_id, lease_owner=params.lease_owner, step3=result.step3
)
except Exception: # noqa: BLE001
logger.warning("persist step3 to job %s failed", params.job_id, exc_info=True)
except asyncio.CancelledError:
# 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛
# CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由
# 路由的 cancel 端点(executor.cancel + store.update_progress)权威写入。
# 取消看门狗(或租约过期后的 dispatcher 回收)权威写入。
logger.info("roundtable job %s cancelled", params.job_id)
raise
except Exception as exc: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
@@ -324,6 +495,97 @@ class RoundtableJobExecutor:
finally:
reset_current_user(token)
async def _heartbeat(
self, job_id: str, lease_owner: str, main_task: asyncio.Task
) -> None:
"""定期续租:把 ``lease_until`` 推到 now + TTL,声明「本 worker 仍在跑、仍持租」。
只要主任务活着且续租成功,别的 worker 的 dispatcher 看到租约未过期就不会抢;主任务
结束(或本协程被 cancel)后不再续租,租约自然到期。续租失败(租约已被接管 / 状态已变)
说明本 worker 已不是持租者,必须立即停止本地编排,避免过期 worker 继续消耗模型资源;
其任何迟到进度写也会被租约门拒绝。
"""
try:
while not main_task.done():
await asyncio.sleep(_HEARTBEAT_INTERVAL)
if main_task.done():
return
try:
ok = await self._store.renew_lease(
job_id,
lease_owner=lease_owner,
lease_until=datetime.now(UTC) + timedelta(seconds=_LEASE_TTL_SECONDS),
)
except Exception: # noqa: BLE001 — DB 抖动不中断心跳,下轮重试
logger.warning("roundtable job %s renew_lease failed", job_id, exc_info=True)
continue
if not ok:
logger.info(
"roundtable job %s: renew_lease rejected (lease lost or status changed)",
job_id,
)
if not main_task.done():
main_task.cancel()
return
except asyncio.CancelledError:
# 编排结束 → _run 的 finally cancel 了心跳 → 安静退出。
return
async def _watch_cancellation(
self, job_id: str, main_task: asyncio.Task, lease_owner: str | None
) -> None:
"""定期轮询 DB 检测跨进程取消标志(``cancel_requested``),并以持租者身份收口。
多 worker 下 cancel 请求可能打到**非执行** worker,该 worker 的 ``cancel()`` 在
``_tasks`` 里找不到此 job(返回 False)。但 cancel 端点始终把 ``status='cancel_requested'``
写进 DB(权威意图)。本看门狗跑在**实际执行作业的 worker** 上,检测到后:
1. 以**持租者**身份 ``finalize_cancel``——条件 UPDATE 把 cancel_requested 收口为终态
``cancelled``,同时清唯一活跃键 + 租约(一致性来自 DB,绝不双收口);
2. 对本地编排 task 调 ``cancel()``,让 ``run_orchestration`` 在下一个 await 点抛
``CancelledError`` 干净退出。
DB 读失败时静默重试(不中断看门狗)。兼容旧数据:若 DB 里已是终态 ``cancelled``
(旧版 cancel 端点直写),直接取消本地 task。
"""
try:
while not main_task.done():
await asyncio.sleep(_CANCEL_POLL_INTERVAL)
if main_task.done():
return
try:
# user_id=None → 不按 user 过滤,纯读 status(看门狗不关心归属)。
row = await self._store.get(job_id, user_id=None)
except Exception: # noqa: BLE001 — DB 读失败不中断看门狗,下轮重试
continue
status = (row.get("status") or "") if row is not None else ""
if status == "cancel_requested":
logger.info(
"roundtable job %s: detected cancel_requested in DB, finalizing "
"cancelled and cancelling orchestration task",
job_id,
)
# 以持租者身份收口;非持租者(理论上不该发生)则退化为无租约条件收口。
try:
await self._store.finalize_cancel(job_id, lease_owner=lease_owner)
except Exception: # noqa: BLE001 — 收口失败不阻断本地取消
logger.warning("roundtable job %s finalize_cancel failed", job_id, exc_info=True)
if not main_task.done():
main_task.cancel()
return
if status == "cancelled":
# 兼容:旧路径已直接把 cancelled 写进 DB —— 本地 task 照样停下。
logger.info(
"roundtable job %s: detected cancelled in DB, cancelling orchestration task",
job_id,
)
if not main_task.done():
main_task.cancel()
return
except asyncio.CancelledError:
# 编排正常结束 → _run 的 finally cancel 了看门狗 → 安静退出。
return
async def _write_run_to_draft(
self,
params: StartParams,
@@ -338,77 +600,106 @@ class RoundtableJobExecutor:
读现有 step2 → 按 run_id=``job-<jobId>`` upsert(保留其它手动 run)→ 设为 activeRun
→ update_draft。前端加载草稿即可像手动跑一样 hydrate 出对话。
带乐观锁重试:**每次**读-改-写都带上 ``expected_version``,若并发写导致版本冲突
(``DraftConcurrentWriteError``)则重新读取最新草稿、重新合并 run 再试。重试全部
耗尽后**绝不**退化为无版本检查的 last-write-wins 盲写,而是给作业置
``draft_persist_pending`` 标记(由 Phase 3 outbox 补偿器投影结果到草稿)——
消除后台作业与前台 PUT 并发时的 step2.runs 丢失更新,且一致性来自 DB 标记而非运气。
"""
draft_store = self._draft_store_for(params)
if draft_store is None or not params.draft_id:
return
# 这些只依赖 params/dialogues,重试间不变——提前算好。
idx_by_agent = {a.agent_id: i for i, a in enumerate(params.agents)}
s2_dialogues = [_to_step2_dialogue(d, idx_by_agent) for d in dialogues]
last_leader = next(
(d.get("content") for d in reversed(dialogues) if d.get("role") == "leader"), ""
)
run_id = f"job-{params.job_id}"
try:
draft = await draft_store.get_draft(params.draft_id, params.user_id)
except Exception: # noqa: BLE001
draft = None
step2 = dict((draft or {}).get("step2") or {})
runs = list(step2.get("runs") or [])
idx = next((i for i, r in enumerate(runs) if r.get("id") == run_id), -1)
created = runs[idx].get("createdAt") if idx >= 0 else datetime.now(UTC).isoformat()
prev_step3 = runs[idx].get("step3") if idx >= 0 else None
# 本次有效 step3:新报告优先,否则保留上次(running 中间态 step3=None 不抹掉已存的)。
effective_step3 = step3 if step3 is not None else prev_step3
run = {
"id": run_id,
"title": "后台研讨",
"createdAt": created,
"source": params.mode,
"selectedAgents": [{"agent_id": a.agent_id, "name": a.name} for a in params.agents],
# 后台真建的 leader/seat 线程是真实持久的——存进去,前端加载后能像手动跑一样
# 按 thread 打开各席位产物(HTTP outputs 兜底)。hydrate 是纯展示、绝不 resume,
# 不会触发重跑(init 闸门 + isRoundTableRunning=false 双重拦截)。空则存 None。
"threadIds": (thread_ids or None),
"coordinatorName": params.coordinator_name,
"step2RoundtableDialogues": s2_dialogues,
"lastLeaderContent": last_leader or "",
"hasConsensus": status == "done",
"consensusPercentage": int(consensus),
"budgetLimit": 0,
# 从各席位 write_file 调用重建 stub,使加载草稿后点开产物的虚拟预览可用。
"seatStubMessages": _seat_stub_messages(dialogues),
"orchestrationMode": params.mode,
"chain": params.chain,
# dag 模式分层计划:让前端加载草稿后能像手动跑一样按 stage 画流程图。
"orchestrationPlan": params.orchestration_plan,
"step3": effective_step3,
"jobId": params.job_id,
"jobStatus": status,
}
runs = [run if i == idx else r for i, r in enumerate(runs)] if idx >= 0 else [*runs, run]
step2["runs"] = runs
step2["activeRunId"] = run_id
# 顶层 draft.step3 也写一份(同 Step2 把对话写进 run 的同一次落库一并带上)。
# 前端 hydrateStep3 用**顶层** draft.step3 重建报告气泡(结果绘制的聊天记录 + HTML 预览);
# 只存 run 内 step3 时,加载会被 hydrateStep3(draft.step3=None) 覆盖成空 → 报告聊天记录丢失。
# furthest_step 跟随**是否真有 step3**:done 且有报告→3(落到结果绘制);
# 否则停在 2(Step2 研讨)。后台禁用报告时 step3=None → 停在 Step2,用户可在前端手动画报告。
has_report = effective_step3 is not None
update_kwargs: dict[str, Any] = {"step2": step2, "furthest_step": 3 if (status == "done" and has_report) else 2}
if has_report:
update_kwargs["step3"] = effective_step3
try:
await draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs)
except Exception as exc: # noqa: BLE001 — 写草稿失败不影响作业本身
logger.warning("write run to draft %s failed", params.draft_id, exc_info=True)
from deerflow.persistence.roundtable_diagnostics import record_diagnostic
await record_diagnostic(
scope="background", stage="job", level="error", event="draft_write_failed",
message=f"研讨结果写回草稿失败(status={status}):{exc}",
detail={"type": type(exc).__name__, "error": str(exc)},
job_id=params.job_id, draft_id=params.draft_id, task_id=params.task_id, user_id=params.user_id,
)
for attempt in range(_DRAFT_WRITE_RETRIES):
# 读最新草稿(冲突重试时重新读,拿到别的小伙伴刚写的 runs)。
try:
draft = await draft_store.get_draft(params.draft_id, params.user_id)
except Exception: # noqa: BLE001
draft = None
expected_version = (draft or {}).get("version")
step2 = dict((draft or {}).get("step2") or {})
runs = list(step2.get("runs") or [])
idx = next((i for i, r in enumerate(runs) if r.get("id") == run_id), -1)
created = runs[idx].get("createdAt") if idx >= 0 else datetime.now(UTC).isoformat()
prev_step3 = runs[idx].get("step3") if idx >= 0 else None
# 本次有效 step3:新报告优先,否则保留上次(running 中间态 step3=None 不抹掉已存的)。
effective_step3 = step3 if step3 is not None else prev_step3
run = {
"id": run_id,
"title": "后台研讨",
"createdAt": created,
"source": params.mode,
"selectedAgents": [{"agent_id": a.agent_id, "name": a.name} for a in params.agents],
"threadIds": (thread_ids or None),
"coordinatorName": params.coordinator_name,
"step2RoundtableDialogues": s2_dialogues,
"lastLeaderContent": last_leader or "",
"hasConsensus": status == "done",
"consensusPercentage": int(consensus),
"budgetLimit": 0,
"seatStubMessages": _seat_stub_messages(dialogues),
"orchestrationMode": params.mode,
"chain": params.chain,
"orchestrationPlan": params.orchestration_plan,
"step3": effective_step3,
"jobId": params.job_id,
"jobStatus": status,
}
runs = [run if i == idx else r for i, r in enumerate(runs)] if idx >= 0 else [*runs, run]
step2["runs"] = runs
step2["activeRunId"] = run_id
has_report = effective_step3 is not None
update_kwargs: dict[str, Any] = {
"step2": step2,
"furthest_step": 3 if (status == "done" and has_report) else 2,
}
if has_report:
update_kwargs["step3"] = effective_step3
# 每次尝试都带 expected_version(乐观锁);绝不降级为无版本检查的盲写。
try:
await draft_store.update_draft(
params.draft_id, params.user_id,
expected_version=expected_version,
**update_kwargs,
)
return # 写入成功
except DraftConcurrentWriteError:
if attempt < _DRAFT_WRITE_RETRIES - 1:
logger.debug(
"roundtable draft %s version conflict (attempt %d/%d), retrying",
params.draft_id, attempt + 1, _DRAFT_WRITE_RETRIES,
)
continue
# 重试全部耗尽:不盲写,置 pending 标记交由 outbox 补偿器投影。
logger.warning(
"roundtable draft %s: version conflicts exhausted after %d attempts, "
"marking job %s draft_persist_pending",
params.draft_id, _DRAFT_WRITE_RETRIES, params.job_id,
)
with suppress(Exception):
await self._store.mark_draft_persist_pending(params.job_id)
return
except Exception as exc: # noqa: BLE001 — 写草稿失败不影响作业本身
logger.warning("write run to draft %s failed", params.draft_id, exc_info=True)
from deerflow.persistence.roundtable_diagnostics import record_diagnostic
await record_diagnostic(
scope="background", stage="job", level="error", event="draft_write_failed",
message=f"研讨结果写回草稿失败(status={status}):{exc}",
detail={"type": type(exc).__name__, "error": str(exc)},
job_id=params.job_id, draft_id=params.draft_id, task_id=params.task_id, user_id=params.user_id,
)
return
def cancel(self, job_id: str) -> bool:
"""请求取消一个在跑的作业。返回是否命中在跑任务。"""
@@ -52,10 +52,14 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
@@ -89,6 +93,37 @@ from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"])
# ── Phase 4:意图回合跨 worker 单飞 + 幂等 ─────────────────────────────────────
# 进程内 RunManager 是每 worker 内存态,多 worker 下同 thread 的两个请求落到不同
# worker 会各启一个 run(无 409)。单飞只能靠 DB:intent_turns 表对 (thread_id,
# client_turn_id) 的唯一键 + 条件 UPDATE 抢占。前端每回合生成稳定 client_turn_id,
# 重试 / 双击复用,杜绝「同一回合被启两个 run → 双发意图 / 重复澄清卡」。
_WORKER_ID = f"w-{os.getpid()}-{uuid.uuid4().hex[:8]}"
_INTENT_TURN_LEASE_TTL_SECONDS = 120
def _intent_message_hash(message: str, files: list, task_context: dict | None) -> str:
"""回合内容指纹:message + files + task_context 的 sha256。
检测「同一 client_turn_id 但内容变了」的客户端 bug(→ conflict)。文件按
(filename, path) 归一,避免临时字段(size/status)造成假性不一致。
"""
file_repr = sorted(
(str(f.get("filename") or ""), str(f.get("path") or "")) for f in (files or [])
)
blob = json.dumps(
{"message": message, "files": file_repr, "task_context": task_context or {}},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def _get_intent_turn_store(request: Request):
"""意图回合单飞 store(未配置 DB 时为 None → 跳过单飞,走原流程)。"""
return getattr(request.app.state, "intent_turn_store", None)
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
INTENT_AGENT_ID = "roundtable-intent"
_SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
@@ -485,6 +520,60 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
async with _make_loopback_client(request) as client:
try:
# —— Phase 4:意图回合跨 worker 单飞 + 幂等 ——
# 同一 (thread_id, client_turn_id) 至多一个 run:重复 / 双击 / 网络重发
# 命中既有行即幂等返回,绝不重复启 run(杜绝双发意图 / 重复澄清卡)。
turn_store = _get_intent_turn_store(request)
client_turn_id = str(payload.get("client_turn_id") or "").strip() or uuid.uuid4().hex
msg_hash = _intent_message_hash(message, files, task_context)
claim_action = "run" # 无 store 时走原流程
if turn_store is not None:
claim = await turn_store.try_claim(
thread_id=thread_id,
client_turn_id=client_turn_id,
message_hash=msg_hash,
lease_owner=_WORKER_ID,
lease_until=datetime.now(UTC)
+ timedelta(seconds=_INTENT_TURN_LEASE_TTL_SECONDS),
)
claim_action = claim.get("action") or "run"
if claim_action == "replay":
cached = (claim.get("row") or {}).get("result_json")
if isinstance(cached, dict):
yield f"data: {json.dumps(cached, ensure_ascii=False)}\n\n"
return
# 缓存缺失/损坏 → 降级重跑(极少见)。
claim_action = "run"
elif claim_action == "duplicate":
# 既有回合仍在跑且租约有效:本请求是重复 in-flight,丢弃
# (原请求正在流式吐结果)。前端按 duplicate 标记忽略。
dup = {"status": "asking", "content": "", "summary": None, "duplicate": True}
yield f"data: {json.dumps(dup, ensure_ascii=False)}\n\n"
return
elif claim_action == "conflict":
err_conflict = {
"status": "error",
"content": "意图回合幂等键冲突:同一 client_turn_id 但消息内容不同。",
"summary": None,
}
yield f"data: {json.dumps(err_conflict, ensure_ascii=False)}\n\n"
return
async def _record_terminal(frame: dict[str, Any]) -> None:
"""终态帧落库(仅本 worker 赢得领取时),供后续重复请求幂等回放。"""
if turn_store is None or claim_action != "run":
return
try:
await turn_store.record_result(
thread_id=thread_id,
client_turn_id=client_turn_id,
lease_owner=_WORKER_ID,
status="error" if frame.get("status") == "error" else "done",
result_json=frame,
)
except Exception:
logger.warning("intent_turn record_result failed", exc_info=True)
# —— 澄清轮次硬上限兜底 ——
# 通用会商保持原来的 3 次 ask 工具调用上限;岗位会商允许一个
# AI turn 同时产生多张独立协助卡,所以必须按 AI 消息(一次
@@ -657,6 +746,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"content": ready_text,
"summary": summary,
}
await _record_terminal(final_frame)
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
@@ -710,6 +800,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"content": fallback_msg,
"summary": None,
}
await _record_terminal(final_frame)
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
@@ -724,6 +815,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"clarifications": clarification_payloads,
"summary": None,
}
await _record_terminal(final_frame)
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
@@ -733,6 +825,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"content": content,
"summary": None,
}
await _record_terminal(final_frame)
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
except HTTPException as exc:
@@ -766,6 +859,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"summary": None,
"error_detail": error_detail,
}
await _record_terminal(err)
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
except Exception as exc:
diagnostic = _diagnose_exception(exc, context="intent_stream")
@@ -781,6 +875,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
"summary": None,
"error_detail": diagnostic,
}
await _record_terminal(err)
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
return StreamingResponse(
@@ -1116,9 +1116,16 @@ async def init_structure_thread(request: Request) -> dict[str, str]:
# 回环 /runs/stream 在**响应头阶段**返回这些状态码时,视为「单 worker + 单 sqlite 锁被
# 并发打满」造成的瞬时失败,退避重试(详见 _stream_upstream 内注释):
# 409 同 thread 抢占竞态;429 限流;500/503/504 高并发下 start_run 里 DB/checkpoint 写抖动。
# 429 限流;500/503/504 高并发下 start_run 里 DB/checkpoint 写抖动。
# 不含 400/401/403/404/422 等确定性错误——那些重试也没用,立即上报。
_UPSTREAM_RETRYABLE_STATUSES = frozenset({409, 429, 500, 502, 503, 504})
#
# ⚠️ 409 **不在**重试集合里。409 = 同 thread 已有活跃 run(``multitask_strategy=reject``
# 拒绝)。此时重发**不是**幂等的:LangGraph 会把同一条 human message 再追加一次 → 用户
# 这一轮被处理两遍(重复澄清卡 / 双发意图),或两次退避后仍 502 —— 这正是用户报的
# 「确定意图接口报错」。故 409 必须透传:由上层(intent_turns 去重 / 前端按 client_turn_id
# 等待)决定是等待还是放弃,而不是盲目重发。其余(rollback)调用方本就靠抢占取消在跑 run,
# 几乎不触 409,移除亦无碍。
_UPSTREAM_RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
# 重试次数(不含首次)。总尝试 = 1 + _UPSTREAM_MAX_RETRIES。
_UPSTREAM_MAX_RETRIES = 2
# 指数退避基数(秒):0.4 → 0.8 → …,足够让在跑的席位 run 让出 sqlite 锁,又不至于明显拖慢。
@@ -14,11 +14,28 @@ import asyncio
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.persistence.position_roundtable.delivery import (
artifact_signature as _artifact_signature,
)
from deerflow.persistence.position_roundtable.delivery import (
is_intent_position_node as _is_intent_position_by_id,
)
from deerflow.persistence.position_roundtable.delivery import (
is_json_artifact as _is_json_artifact,
)
from deerflow.persistence.position_roundtable.delivery import (
is_markdown_artifact as _is_markdown_artifact,
)
from deerflow.persistence.position_roundtable.delivery import (
session_intent_position_id as _chain_intent_position_id,
)
from deerflow.persistence.position_roundtable.sql import SessionWriteConflictError
from deerflow.runtime.user_context import get_effective_user_id
# The application mounts this router below both `/api` (canonical) and the
@@ -69,6 +86,8 @@ class NodeResponse(BaseModel):
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
conversation_snapshot: dict[str, Any] | None = None
revision: int = 0
# 节点乐观锁版本号(任意字段变更自增;revision 仅计真实交付)。
version: int = 0
upstream_revision: dict[str, Any] | None = None
rejection_count: int = 0
last_rejection: dict[str, Any] | None = None
@@ -96,6 +115,8 @@ class SessionResponse(BaseModel):
action_plan_snapshot: dict[str, Any] | None = None
action_plan_conversation: dict[str, Any] | None = None
status: str
# Session 级乐观锁版本号:每次命令式写操作自增,前端可回传做 CAS。
version: int = 0
created_at: str | None = None
updated_at: str | None = None
@@ -132,20 +153,26 @@ class ConversationStateUpdateRequest(BaseModel):
thread_id: str | None = Field(default=None, max_length=128)
conversation_snapshot: dict[str, Any] = Field(default_factory=dict)
# Client-generated idempotency key; one click/retry reuses the same id so
# the command executes exactly once. Absent → server generates one (legacy).
command_id: str | None = Field(default=None, max_length=64)
class ActivateRequest(BaseModel):
chain_id: str = Field(min_length=1, max_length=64)
command_id: str | None = Field(default=None, max_length=64)
class NodeThreadRequest(BaseModel):
thread_id: str = Field(min_length=1, max_length=128)
command_id: str | None = Field(default=None, max_length=64)
class CompleteTurnRequest(BaseModel):
latest_answer: str = Field(default="", max_length=500_000)
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
upstream_revision: dict[str, Any] | None = None
command_id: str | None = Field(default=None, max_length=64)
class RejectNodeRequest(BaseModel):
@@ -154,6 +181,7 @@ class RejectNodeRequest(BaseModel):
# default so neither field turns a valid rejection into a 422/400.
title: str = Field(default="产物驳回", max_length=120)
reason: str = Field(default="", max_length=5_000)
command_id: str | None = Field(default=None, max_length=64)
class ActionPlanDictionaryOption(BaseModel):
@@ -261,6 +289,38 @@ async def _require_writable_session(
return store, user_id, session
def _command_id_or_new(command_id: str | None) -> str:
"""Use the client's idempotency key, or mint one for legacy clients."""
value = (command_id or "").strip()
return value or uuid4().hex
def _command_result_or_raise(result: dict[str, Any]) -> dict[str, Any]:
"""Return an ok command result, or translate a rejected command into HTTP."""
if result.get("ok"):
return result
raise HTTPException(
status_code=int(result.get("http_status") or 409),
detail=result.get("detail") or "命令执行被拒绝",
)
async def _update_session_or_raise(
store: Any,
session_id: str,
user_id: str,
**changes: Any,
) -> dict[str, Any]:
"""Apply a locked session snapshot write and map stale-state races to 409."""
try:
row = await store.update_session(session_id, user_id, **changes)
except SessionWriteConflictError as exc:
raise HTTPException(status_code=409, detail=exc.detail) from exc
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return row
def _project_session(row: dict[str, Any]) -> SessionResponse:
return SessionResponse(**row)
@@ -400,18 +460,15 @@ def _is_intent_position_node(
node: dict[str, Any],
intent_position_id: str | None = None,
) -> bool:
position_id = str(node.get("position_id") or "").strip()
if intent_position_id:
return position_id == intent_position_id
# Existing persisted sessions predate the configurable directory. Retain
# their historical interpretation until they are archived/recreated.
return position_id.lower() in {"intelligence", "intel", "intent"}
"""Node-dict wrapper over the shared harness decision (see delivery.py)."""
return _is_intent_position_by_id(
str(node.get("position_id") or "").strip(), intent_position_id
)
def _session_intent_position_id(session: dict[str, Any]) -> str | None:
chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
value = chain.get("_intent_position_id")
return value.strip() if isinstance(value, str) and value.strip() else None
"""Session-dict wrapper over the shared harness decision (see delivery.py)."""
return _chain_intent_position_id(session.get("chain_snapshot"))
async def _configured_intent_position_id(request: Request) -> str:
@@ -437,62 +494,11 @@ async def _configured_intent_position_id(request: Request) -> str:
return "intelligence"
def _is_markdown_artifact(artifact: Any) -> bool:
if not isinstance(artifact, dict):
return False
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
return False
return (
"markdown" in mime
or path.endswith(".md")
or path.endswith(".markdown")
or name.endswith(".md")
or name.endswith(".markdown")
)
def _node_has_markdown_delivery(node: dict[str, Any]) -> bool:
artifacts = node.get("artifact_manifest")
return isinstance(artifacts, list) and any(_is_markdown_artifact(item) for item in artifacts)
def _markdown_delivery_changed(
previous_artifacts: Any,
incoming_artifacts: list[dict[str, Any]],
) -> bool:
"""Whether this turn rewrote a Markdown deliverable, not just chatted.
A completed node can receive normal follow-up Q&A. That must not bump its
effective delivery revision or invalidate every downstream stage unless a
Markdown report really changed. The standard artifact listing includes
`modified_at` and `size_bytes`, which together make a stable lightweight
version marker without reading a potentially large file again.
"""
old = previous_artifacts if isinstance(previous_artifacts, list) else []
old_signatures = {
(
str(item.get("path") or item.get("artifact_url") or ""),
str(item.get("modified_at") or item.get("modifiedAt") or ""),
str(item.get("size_bytes") or item.get("sizeBytes") or ""),
)
for item in old
if isinstance(item, dict) and _is_markdown_artifact(item)
}
new_signatures = {
(
str(item.get("path") or item.get("artifact_url") or ""),
str(item.get("modified_at") or item.get("modifiedAt") or ""),
str(item.get("size_bytes") or item.get("sizeBytes") or ""),
)
for item in incoming_artifacts
if isinstance(item, dict) and _is_markdown_artifact(item)
}
return bool(new_signatures - old_signatures)
def _intent_string_list(intent: dict[str, Any], key: str) -> list[str]:
"""Return a clean, display-safe string list from a persisted intent field."""
value = intent.get(key)
@@ -907,26 +913,6 @@ def _source_revisions(nodes: list[dict[str, Any]]) -> dict[str, int]:
return {str(node["node_key"]): int(node.get("revision") or 0) for node in _delivered_nodes(nodes)}
def _is_json_artifact(artifact: Any) -> bool:
if not isinstance(artifact, dict):
return False
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
name = str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
mime = str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
if path and not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
return False
return "json" in mime or path.endswith(".json") or name.endswith(".json")
def _artifact_signature(artifact: dict[str, Any]) -> tuple[str, str, str]:
"""Use server-returned path/mtime/size to detect an actually rewritten file."""
return (
str(artifact.get("path") or artifact.get("artifact_url") or ""),
str(artifact.get("modified_at") or artifact.get("modifiedAt") or ""),
str(artifact.get("size_bytes") or artifact.get("sizeBytes") or ""),
)
def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Persist display metadata plus any database recovery payload."""
refs: list[dict[str, Any]] = []
@@ -1276,7 +1262,8 @@ async def update_task_snapshot(
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
) -> SessionResponse:
store, user_id, current = await _require_writable_session(request, session_id)
row = await store.update_session(
row = await _update_session_or_raise(
store,
session_id,
user_id,
task_snapshot=_task_snapshot_for_scope(
@@ -1284,9 +1271,8 @@ async def update_task_snapshot(
body.task_snapshot,
task_scoped=bool(current.get("task_scoped")),
),
allowed_statuses={"intent_pending"},
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@@ -1295,15 +1281,15 @@ async def update_intent_snapshot(
request: Request, session_id: str, body: IntentUpdateRequest
) -> SessionResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
row = await store.update_session(
row = await _update_session_or_raise(
store,
session_id,
user_id,
intent_thread_id=body.intent_thread_id,
intent_snapshot=body.intent_snapshot,
intent_conversation=body.conversation_snapshot,
allowed_statuses={"intent_pending"},
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@@ -1312,14 +1298,13 @@ async def update_summary_conversation(
request: Request, session_id: str, body: ConversationStateUpdateRequest
) -> SessionResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
row = await store.update_session(
row = await _update_session_or_raise(
store,
session_id,
user_id,
summary_thread_id=body.thread_id,
summary_conversation=body.conversation_snapshot,
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@@ -1328,14 +1313,13 @@ async def update_action_plan_conversation(
request: Request, session_id: str, body: ConversationStateUpdateRequest
) -> SessionResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
row = await store.update_session(
row = await _update_session_or_raise(
store,
session_id,
user_id,
action_plan_thread_id=body.thread_id,
action_plan_conversation=body.conversation_snapshot,
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@@ -1374,34 +1358,60 @@ async def activate_session(
current.get("intent_snapshot"),
intent_position_id=intent_position_id,
)
activated = await store.activate_session(
session_id,
user_id,
chain_id=body.chain_id,
chain_snapshot=chain_snapshot,
nodes=nodes,
# 命令式状态机:锁 session + 锁内判定「已激活→链相同幂等/链不同 409」,
# 消除原 activate_session 的 check-then-insert 竞态(并发双击 → 唯一键冲突 500)。
result = _command_result_or_raise(
await store.apply_session_command(
session_id,
user_id,
command_id=_command_id_or_new(body.command_id),
command_type="activate",
payload={
"chain_id": body.chain_id,
"chain_snapshot": chain_snapshot,
"nodes": nodes,
},
)
)
return SessionDetailResponse(
**result["session"],
nodes=[_project_node(node) for node in result["nodes"]],
)
if activated is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
row, node_rows = activated
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
@router.post("/sessions/{session_id}/archive", response_model=SessionResponse)
async def archive_session(request: Request, session_id: str) -> SessionResponse:
row = await _get_store(request).archive_session(session_id, _current_user_id(request))
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
store = _get_store(request)
user_id = _current_user_id(request)
# 命令式状态机:已归档幂等返回,锁内串行化并发归档。每次操作一个稳定的
# command_id(URL 无 body),用 session 级固定派生键,重复请求天然重放。
result = _command_result_or_raise(
await store.apply_session_command(
session_id,
user_id,
command_id=f"archive-{session_id}",
command_type="archive",
payload={},
)
)
return _project_session(result["session"])
@router.delete("/sessions/{session_id}")
async def delete_session(request: Request, session_id: str) -> dict[str, bool]:
deleted = await _get_store(request).delete_session(
session_id, _current_user_id(request)
store = _get_store(request)
user_id = _current_user_id(request)
# 命令式状态机:命令记录无外键(保留供重放),删除后同 command_id 重试 → 重放
# ``{deleted: True}``,避免已删会话再次请求报 404 让前端误判失败。
_command_result_or_raise(
await store.apply_session_command(
session_id,
user_id,
command_id=f"delete-{session_id}",
command_type="delete",
payload={},
)
)
if not deleted:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return {"deleted": True}
@@ -1418,23 +1428,19 @@ async def bind_node_thread(
request: Request, session_id: str, node_key: str, body: NodeThreadRequest
) -> NodeResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
current = await store.get_node(session_id, node_key, user_id)
if current is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
if current.get("invalidated_by"):
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
if current.get("thread_id") and current["thread_id"] != body.thread_id:
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
row = await store.update_node(
session_id,
node_key,
user_id,
thread_id=body.thread_id,
status="running" if current.get("status") == "ready" else current.get("status", "locked"),
# 命令式状态机:锁内判定「是否解锁 / 是否已被其它线程占用」并绑定。
# 原实现在事务外读取节点状态做判定(TOCTOU),并发下两个请求可同时通过校验。
result = _command_result_or_raise(
await store.apply_node_command(
session_id,
node_key,
user_id,
command_id=_command_id_or_new(body.command_id),
command_type="bind",
payload={"thread_id": body.thread_id},
)
)
return _project_node(row)
return _project_node(result["node"])
@router.put(
@@ -1448,25 +1454,21 @@ async def update_node_conversation(
body: ConversationStateUpdateRequest,
) -> NodeResponse:
store, user_id, session = await _require_writable_session(request, session_id)
current = await store.get_node(session_id, node_key, user_id)
if current is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if (
body.thread_id
and current.get("thread_id")
and current["thread_id"] != body.thread_id
):
raise HTTPException(status_code=409, detail="Position node is bound to another thread")
row = await store.update_node(
session_id,
node_key,
user_id,
thread_id=body.thread_id if body.thread_id else current.get("thread_id"),
conversation_snapshot=body.conversation_snapshot,
# 命令式状态机:线程归属校验移入 FOR UPDATE 锁内。
result = _command_result_or_raise(
await store.apply_node_command(
session_id,
node_key,
user_id,
command_id=_command_id_or_new(body.command_id),
command_type="conversation",
payload={
"thread_id": body.thread_id,
"conversation_snapshot": body.conversation_snapshot,
},
)
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
return _project_node(row)
return _project_node(result["node"])
@router.post("/sessions/{session_id}/nodes/{node_key}/context")
@@ -1483,126 +1485,56 @@ async def complete_node_turn(
node = await store.get_node(session_id, node_key, user_id)
if node is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if node["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
if node.get("invalidated_by"):
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
# Artifact listing is best-effort on the client. Never erase a previously
# delivered report merely because one listing request transiently failed.
effective_artifacts = body.artifact_manifest or list(node.get("artifact_manifest") or [])
# 产物内容持久化是文件 IO,保持在数据库事务之外(避免长事务)。清单为空表示
# 「本轮无新清单」,由锁内回退到节点当前清单——绝不在事务外用过期快照兜底。
persisted: list[dict[str, Any]] | None = None
if body.artifact_manifest:
effective_artifacts = await _persist_artifact_contents(
persisted = await _persist_artifact_contents(
node.get("thread_id"),
effective_artifacts,
body.artifact_manifest,
)
has_markdown = any(_is_markdown_artifact(item) for item in effective_artifacts)
markdown_changed = _markdown_delivery_changed(node.get("artifact_manifest"), effective_artifacts)
requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
next_status = (
"done"
if _is_intent_position_node(node, _session_intent_position_id(session))
else "done"
if has_markdown and (not requires_fresh_delivery or markdown_changed)
else "running"
)
# Effective revisions are delivery revisions, not every conversational
# answer. This prevents a harmless follow-up on a completed seat from
# invalidating all later stages just because its old Markdown file still
# appears in the artifact list.
new_effective_delivery = (
next_status == "done"
and (
_is_intent_position_node(node, _session_intent_position_id(session))
or node.get("status") != "done"
or markdown_changed
# 命令式状态机:能否完成 / 是否新交付 / revision+1 / 失效下游 / 解锁下一 stage,
# 全部在单一 FOR UPDATE 事务内基于锁定行判定并原子落库(apply_node_command)。
# 原实现先读快照算好 next_status 再进事务,两个并发请求会基于同一过期快照各自
# 判定,后写覆盖先写 → 下一 stage 卡 locked / invalidated_by 残留 → 断链。
result = _command_result_or_raise(
await store.apply_node_command(
session_id,
node_key,
user_id,
command_id=_command_id_or_new(body.command_id),
command_type="complete",
payload={
"latest_answer": body.latest_answer,
"artifact_manifest": persisted,
"upstream_revision": body.upstream_revision,
},
)
)
row = await store.update_node(
session_id,
node_key,
user_id,
status=next_status,
latest_answer=body.latest_answer,
artifact_manifest=effective_artifacts,
upstream_revision=body.upstream_revision,
invalidated_by=None,
increment_revision=new_effective_delivery,
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
# A node has no coordinator. Its effective delivery alone drives the frozen
# chain: all parallel seats in the current stage must finish before the
# next stage is made writable. A plain conversational follow-up keeps the
# existing downstream deliveries valid; only a newly written Markdown
# report advances the revision and makes later stages stale.
nodes = await store.list_nodes(session_id, user_id)
if nodes is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
if new_effective_delivery and node.get("revision", 0) > 0:
for downstream in nodes:
if (
downstream["stage_index"] > row["stage_index"]
and downstream["status"] in {"ready", "running", "done", "stale", "rejected", "error"}
):
has_material = (
downstream.get("revision", 0) > 0
or bool(downstream.get("latest_answer"))
or bool(downstream.get("artifact_manifest"))
)
await store.update_node(
session_id,
downstream["node_key"],
user_id,
status="stale" if has_material else "locked",
invalidated_by=row["node_key"],
)
nodes = await store.list_nodes(session_id, user_id) or []
current_stage = [item for item in nodes if item["stage_index"] == row["stage_index"]]
later_stages = sorted(
{item["stage_index"] for item in nodes if item["stage_index"] > row["stage_index"]}
)
if row["status"] == "done" and current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
next_stage = later_stages[0]
for next_node in nodes:
if next_node["stage_index"] == next_stage and next_node["status"] in {"locked", "stale"}:
await store.update_node(
session_id,
next_node["node_key"],
user_id,
status="ready" if next_node["status"] == "locked" else "stale",
invalidated_by=None,
)
return _project_node(row)
return _project_node(result["node"])
@router.post("/sessions/{session_id}/nodes/{node_key}/reject", response_model=SessionDetailResponse)
async def reject_node(
request: Request, session_id: str, node_key: str, body: RejectNodeRequest
) -> SessionDetailResponse:
title = body.title.strip() or "产物驳回"
reason = body.reason.strip()
store, user_id, _ = await _require_writable_session(request, session_id)
current = await store.get_node(session_id, node_key, user_id)
if current is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked":
raise HTTPException(status_code=409, detail="当前节点尚未解锁,暂无可驳回的交付产物")
if current.get("revision", 0) <= 0 and not current.get("latest_answer") and not current.get("artifact_manifest"):
raise HTTPException(status_code=409, detail="当前节点暂无可驳回的交付产物")
rejected = await store.reject_node(
session_id,
node_key,
user_id,
title=title,
reason=reason,
rejected_by=user_id,
# 命令式状态机:可驳回性判定 + 驳回 + 下游失效在同一 FOR UPDATE 事务内完成,
# 并 bump session/node version。同 command_id 的重复驳回原样重放、不重复计数。
result = _command_result_or_raise(
await store.apply_node_command(
session_id,
node_key,
user_id,
command_id=_command_id_or_new(body.command_id),
command_type="reject",
payload={"title": body.title, "reason": body.reason},
)
)
return SessionDetailResponse(
**result["session"],
nodes=[_project_node(node) for node in result["nodes"]],
)
if rejected is None:
raise HTTPException(status_code=404, detail="Position roundtable node not found")
row, node_rows = rejected
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
async def _prepare_builtin_run(
@@ -1706,7 +1638,8 @@ async def _complete_builtin_run(
_artifact_signature(previous_primary) != _artifact_signature(new_primary)
)
if kind == "summary":
row = await store.update_session(
row = await _update_session_or_raise(
store,
session_id,
user_id,
summary_thread_id=body.thread_id,
@@ -1717,15 +1650,14 @@ async def _complete_builtin_run(
status="active",
)
else:
row = await store.update_session(
row = await _update_session_or_raise(
store,
session_id,
user_id,
action_plan_thread_id=body.thread_id,
action_plan_snapshot=snapshot,
status="completed",
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
node_rows = await store.list_nodes(session_id, user_id) or []
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
@@ -19,6 +19,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt, Stri
from app.gateway.deps import get_current_user
from deerflow.persistence.roundtable_artifact_submissions import RoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict
router = APIRouter(prefix="/api/roundtable/tasks", tags=["roundtable-artifact-submissions"])
@@ -53,6 +54,7 @@ class ArtifactSubmissionRequest(BaseModel):
submitted: bool
submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id"))
task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id"))
expected_version: int | None = Field(default=None, ge=0, validation_alias=AliasChoices("expectedVersion", "expected_version"))
artifact: ArtifactReferenceInput
@field_validator("task_id", "submission_id", "task_delivery_id", mode="before")
@@ -82,6 +84,7 @@ class ArtifactSubmissionResponse(BaseModel):
id: str
task_id: str = Field(serialization_alias="taskId")
version: int = Field(default=0)
submitted: bool
submitted_at: str | None = Field(default=None, serialization_alias="submittedAt")
cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt")
@@ -131,6 +134,7 @@ def _response(row: dict[str, Any]) -> ArtifactSubmissionResponse:
return ArtifactSubmissionResponse(
id=str(row["id"]),
task_id=str(row["task_id"]),
version=int(row.get("version", 0)),
submitted=bool(row["submitted"]),
submitted_at=row.get("submitted_at"),
cancelled_at=row.get("cancelled_at"),
@@ -178,19 +182,43 @@ async def set_artifact_submission(
# a thread reference a saved state could never be previewed correctly.
if not artifact["thread_id"]:
raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true")
row = await store.submit(
path_task_id,
artifact,
task_delivery_id=body.task_delivery_id,
actor_id=actor_id,
)
try:
row = await store.submit(
path_task_id,
artifact,
task_delivery_id=body.task_delivery_id,
actor_id=actor_id,
expected_version=body.expected_version,
)
except SubmissionVersionConflict as conflict:
raise HTTPException(
status_code=409,
detail={
"code": "version_conflict",
"message": "提交产物版本冲突:另一写入已先提交,请刷新后重试。",
"current_version": conflict.row.get("version", 0),
"submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False),
},
) from conflict
else:
row = await store.cancel(
path_task_id,
artifact,
submission_id=body.submission_id,
actor_id=actor_id,
)
try:
row = await store.cancel(
path_task_id,
artifact,
submission_id=body.submission_id,
actor_id=actor_id,
expected_version=body.expected_version,
)
except SubmissionVersionConflict as conflict:
raise HTTPException(
status_code=409,
detail={
"code": "version_conflict",
"message": "取消产物版本冲突:另一写入已先提交,旧取消不能撤销新提交。",
"current_version": conflict.row.get("version", 0),
"submission": _response(conflict.row).model_dump(by_alias=True, exclude_none=False),
},
) from conflict
if row is None:
raise HTTPException(status_code=404, detail="Artifact submission not found")
return _response(row)
@@ -32,6 +32,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -47,6 +48,8 @@ class DraftMetaResponse(BaseModel):
task_id: str | None = None
title: str = ""
furthest_step: int = 1
# 乐观锁版本号:PUT 时经 expected_version 回传,冲突 → 409 + current_version。
version: int = 0
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
chain_title: str | None = None
created_at: datetime | str | None = None
@@ -88,6 +91,9 @@ class DraftUpdateRequest(BaseModel):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
# 乐观锁:客户端基于的草稿版本号。缺省(旧客户端)= 盲写兼容;
# 提供时版本不匹配 → 409,body 带 current_version 供重拉合并。
expected_version: int | None = Field(default=None, ge=0)
class RecommendPickSchema(BaseModel):
@@ -204,7 +210,18 @@ async def update_draft(request: Request, draft_id: str, body: DraftUpdateRequest
# Build kwargs that distinguish "omitted" from "explicitly None". The store
# uses a sentinel; here we pass only the fields the client actually sent.
fields = body.model_dump(exclude_unset=True)
row = await store.update_draft(draft_id, user_id, **fields)
try:
row = await store.update_draft(draft_id, user_id, **fields)
except DraftConcurrentWriteError as err:
# 版本冲突:detail 里带 current_version,前端据此重拉最新草稿、本地合并后重试。
raise HTTPException(
status_code=409,
detail={
"error": "draft_version_conflict",
"message": f"草稿已被并发修改(expected={err.expected}, actual={err.actual})",
"current_version": err.actual,
},
) from err
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return DraftResponse(**row)
@@ -18,6 +18,7 @@ Routes(prefix ``/api/roundtable-jobs``)::
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
@@ -25,11 +26,11 @@ from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.roundtable_job_executor import StartParams
from app.gateway.roundtable_job_executor import build_start_snapshot
from deerflow.agents.roundtable_orchestrator import SeatRef
from deerflow.runtime.user_context import get_effective_user_id
@@ -54,6 +55,10 @@ class JobAgent(BaseModel):
class StartJobRequest(BaseModel):
# Phase 3 客户端幂等键:前端为每次「后台挂起」生成一个 requestId,网络重试**复用同一
# requestId**。服务端据此审计 / 排查;「同草稿不双跑」本身由 active_dedupe_key 唯一索引
# 保证(requestId 只是锦上添花的请求级指纹)。缺省(旧前端)→ 服务端生成一个。
requestId: str | None = Field(default=None, max_length=64)
draftId: str | None = Field(default=None, max_length=64)
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
@@ -151,6 +156,20 @@ def _get_executor(request: Request):
return executor
def _get_dispatcher(request: Request):
"""Phase 3 dispatcher(可能为 None:store 未配置时)。nudge 前先判空。"""
return getattr(request.app.state, "roundtable_job_dispatcher", None)
def _request_hash(body: StartJobRequest) -> str:
"""启动入参指纹(sha256)——审计/排查「同 requestId 但入参变了」的异常重试。"""
canonical = json.dumps(
body.model_dump(mode="json", exclude={"requestId"}),
ensure_ascii=False, sort_keys=True, default=str,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _pending_nodes(agents: list[JobAgent]) -> list[dict[str, Any]]:
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
return [
@@ -194,32 +213,63 @@ def _seat_refs_from_nodes(nodes: list[dict[str, Any]] | None) -> list[SeatRef]:
@router.post("", response_model=RoundtableJobResponse, status_code=201)
async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobResponse:
async def start_job(
request: Request, body: StartJobRequest, response: Response
) -> RoundtableJobResponse:
"""启动一个后台作业(Phase 3:唯一活跃键去重 + dispatcher 领取执行)。
流程:
1. 把整份启动入参序列化成 ``input_snapshot`` 落库(跨 worker 重建 StartParams 的前提);
2. ``try_create_or_get_active`` 直接 INSERT 并占用 ``active_dedupe_key`` 唯一索引 ——
冲突(同草稿已有活跃作业)即幂等返回它(``created=False`` → HTTP 200),不再新建;
3. 新建成功(HTTP 201,status=queued)后 ``nudge`` dispatcher,由它 ``claim_job``
原子领取并在本(或其它)worker 执行 —— **不再由路由直接拉起进程内任务**。
「同一草稿至多一个活跃作业」由 DB 唯一索引强制,跨 worker / 并发 / 重试天然互斥,
不依赖 GET_LOCK 命名锁或进程内 asyncio。
"""
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
if not body.agents:
raise HTTPException(status_code=400, detail="agents is required")
# 防重复挂起(服务端幂等闸门):同一草稿已有未终态作业(queued/running/awaiting_input)
# 时直接返回它、不再新建。前端的「手动挂起按钮 / 离开页面自动挂起 / beforeunload 刷新
# 兜底」可能对同一研讨多次触发本端点 —— 客户端去重只能挡住同页内的重复,跨触发源 /
# 跨标签页的并发只能靠这里兜住,否则同一研讨会被双跑。
task_id = (body.taskId or "").strip() or None
if body.draftId:
# task 作业按 task_id 去重(跨用户共享);个人作业按 user 去重。
existing = await store.get_by_draft(body.draftId, user_id=user_id, task_id=task_id)
if existing is not None and (existing.get("status") or "") not in _TERMINAL:
return _to_response(existing)
draft_id = (body.draftId or "").strip() or None
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
seed_message = (body.seedMessage or body.intent or "").strip()
# 整份启动入参快照 —— dispatcher 在任意 worker 领取后据此无损重建 StartParams。
snapshot = build_start_snapshot(
user_id=user_id,
agents=agents,
seed_message=seed_message,
model=body.model,
mode=body.orchestrationMode,
intent_text=body.intent,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
draft_id=draft_id,
task_id=task_id,
is_resume=False,
chain=body.chain,
orchestration_plan=body.orchestrationPlan,
seat_thinking_enabled=body.thinking_enabled,
seat_reasoning_effort=body.reasoning_effort,
seat_subagent_enabled=body.subagent_enabled,
seat_skill_directives=dict(body.seat_skill_directives or {}),
seat_modes=dict(body.seat_modes or {}),
gather_first=bool(body.gather_first),
business_code=(body.business_code or None),
)
request_id = (body.requestId or "").strip() or uuid4().hex
job_id = uuid4().hex
row = await store.create(
id=job_id,
draft_id=body.draftId,
row, created = await store.try_create_or_get_active(
draft_id=draft_id,
user_id=user_id,
task_id=task_id,
id=job_id,
orchestration_mode=body.orchestrationMode,
coordinator_name=body.coordinatorName,
thread_ids=body.threadIds,
@@ -227,32 +277,21 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
orchestration_plan=body.orchestrationPlan,
dispatch_chain=_pending_nodes(body.agents),
status="queued",
request_id=request_id,
request_hash=_request_hash(body),
input_snapshot=snapshot,
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
agents=agents,
seed_message=(body.seedMessage or body.intent or "").strip(),
model=body.model,
mode=body.orchestrationMode,
intent_text=body.intent,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
draft_id=body.draftId,
task_id=task_id,
chain=body.chain,
orchestration_plan=body.orchestrationPlan,
seat_thinking_enabled=body.thinking_enabled,
seat_reasoning_effort=body.reasoning_effort,
seat_subagent_enabled=body.subagent_enabled,
seat_skill_directives=dict(body.seat_skill_directives or {}),
seat_modes=dict(body.seat_modes or {}),
gather_first=bool(body.gather_first),
business_code=(body.business_code or None),
)
)
if created:
# 新建(queued):交给 dispatcher 领取执行。
response.status_code = 201
else:
# 命中已有活跃作业:幂等返回(不重复建 / 不重复跑)。
response.status_code = 200
# 任一分支都唤醒 dispatcher:新建需被领取;幂等命中时若既有作业还在 queued、未被任何
# worker 领走(如 dispatcher 正在轮询间隙),nudge 确保它被立刻 claim,无感延迟。
dispatcher = _get_dispatcher(request)
if dispatcher is not None:
dispatcher.nudge()
return _to_response(row)
@@ -310,48 +349,73 @@ async def stream_job(request: Request, job_id: str, task_id: str | None = None)
async def resume_job(
request: Request, job_id: str, body: ResumeJobRequest, task_id: str | None = None
) -> RoundtableJobResponse:
"""回答澄清后续跑(Phase 3:条件 UPDATE 串行 + dispatcher 领取)。
``queue_for_resume`` 用**原子条件 UPDATE**(WHERE status='awaiting_input')把作业翻成
queued —— 并发 / 重复的 resume 只有一个能成功(其余返回 None → 409),杜绝「同一澄清
被续跑两次」的双跑。成功后清租约 + 写含续跑语义的新 snapshot,``nudge`` dispatcher 由
它在任意 worker 领取执行;**路由不再直接拉起进程内任务**。
"""
store = _get_store(request)
executor = _get_executor(request)
tid = (task_id or "").strip() or None
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id, task_id=tid)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
if (row.get("status") or "") != "awaiting_input":
raise HTTPException(status_code=409, detail="Job is not awaiting input")
answer = body.answer.strip()
if not answer:
raise HTTPException(status_code=400, detail="answer is required")
# task 作业不分权:放开写入归属校验(user_id=None);并以**原作业所有者**身份续跑,
# 保证编排上下文 / 写回 task-draft 用一致的 user 上下文。
# task 作业不分权:放开写入归属校验(user_id=None)。
write_user_id = None if tid else user_id
run_user_id = row.get("user_id") or user_id
updated = await store.update_progress(
job_id, user_id=write_user_id, status="running", pending_clarification=None
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=run_user_id,
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
seed_message=_RESUME_PREFIX + answer,
model=None,
mode=row.get("orchestration_mode") or "recommend",
intent_text=answer,
thread_ids=row.get("thread_ids"),
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
draft_id=row.get("draft_id"),
task_id=tid or row.get("task_id"),
is_resume=True,
chain=row.get("chain"),
orchestration_plan=row.get("orchestration_plan"),
)
# 续跑 snapshot:优先复用原 input_snapshot(含席位/模式/链条等完整入参),覆盖续跑三件套
# (is_resume / seed_message / intent_text),并用作业行里**最新**的 thread_ids /
# coordinator_name(首跑建线程后由执行器写回,比原 snapshot 里的更新)。旧作业无 snapshot
# 时从作业行字段兜底重建。
base_snapshot = row.get("input_snapshot") or {}
if not isinstance(base_snapshot, dict):
base_snapshot = {}
resume_snapshot: dict[str, Any] = {
"v": base_snapshot.get("v", 1),
"user_id": run_user_id,
"agents": base_snapshot.get("agents")
or [{"agent_id": n.get("agentId"), "name": n.get("name")} for n in (row.get("dispatch_chain") or [])],
"seed_message": _RESUME_PREFIX + answer,
"model": base_snapshot.get("model"),
"mode": row.get("orchestration_mode") or base_snapshot.get("mode") or "recommend",
"intent_text": answer,
"thread_ids": row.get("thread_ids"),
"coordinator_name": row.get("coordinator_name") or "roundtable-coordinator",
"draft_id": row.get("draft_id"),
"task_id": tid or row.get("task_id"),
"is_resume": True,
"chain": row.get("chain"),
"orchestration_plan": row.get("orchestration_plan"),
"enable_report": base_snapshot.get("enable_report", True),
"seat_thinking_enabled": base_snapshot.get("seat_thinking_enabled"),
"seat_reasoning_effort": base_snapshot.get("seat_reasoning_effort"),
"seat_subagent_enabled": base_snapshot.get("seat_subagent_enabled", False),
"seat_skill_directives": base_snapshot.get("seat_skill_directives") or {},
"seat_modes": base_snapshot.get("seat_modes") or {},
"gather_first": base_snapshot.get("gather_first", False),
"business_code": base_snapshot.get("business_code"),
}
updated = await store.queue_for_resume(
job_id, input_snapshot=resume_snapshot, user_id=write_user_id, task_id=tid
)
return _to_response(updated or row)
if updated is None:
# 条件 UPDATE 未命中:作业已不在 awaiting_input(被并发 resume 抢先 / 已续跑 / 已终态)。
raise HTTPException(status_code=409, detail="Job is not awaiting input")
dispatcher = _get_dispatcher(request)
if dispatcher is not None:
dispatcher.nudge()
return _to_response(updated)
async def _require_admin(request: Request) -> None:
@@ -395,8 +459,19 @@ async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupRespon
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
async def cancel_job(request: Request, job_id: str, task_id: str | None = None) -> RoundtableJobResponse:
"""取消作业(Phase 3:两段式 cancel)。
1. ``request_cancel``:原子条件 UPDATE(可取消态 → ``cancel_requested``)—— 权威意图落库,
重复 cancel 幂等(已 cancel_requested/cancelled/终态时返回 None,直接回现状)。
2. **无持租者**(queued / awaiting_input,执行器未在跑)→ 路由立刻 ``finalize_cancel``
收口为终态 ``cancelled``(清唯一活跃键 + 租约)。
3. **有持租者**(running)→ 保留 ``cancel_requested``,由持租者的 DB 看门狗收口
``cancelled``;本接口先回 ``cancel_requested``(SSE 随后见翻转)。租约过期前未能
收口(例如 worker 崩溃)时,dispatcher 会回收该取消请求。
「已取消」终态永远由持租者或 finalize 收口,避免与执行器的进度写竞态。
"""
store = _get_store(request)
executor = _get_executor(request)
tid = (task_id or "").strip() or None
user_id = _current_user_id(request)
@@ -404,7 +479,20 @@ async def cancel_job(request: Request, job_id: str, task_id: str | None = None)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
executor.cancel(job_id)
# task 作业不分权:放开写入归属校验。
updated = await store.update_progress(job_id, user_id=(None if tid else user_id), status="cancelled")
return _to_response(updated or row)
write_user_id = None if tid else user_id
requested = await store.request_cancel(job_id, user_id=write_user_id, task_id=tid)
if requested is None:
# 已不可取消(终态 / 已 cancel_requested):幂等返回现状,不当作错误。
fresh = await store.get(job_id, user_id=user_id, task_id=tid)
return _to_response(fresh or row)
if not requested.get("lease_owner"):
# 无持租者:路由直接收口(queued / awaiting_input)。
finalized = await store.finalize_cancel(job_id)
return _to_response(finalized or requested)
# 有持租者(running):不要直接 task.cancel()。直接取消会中断当前 worker 的看门狗,
# 使 DB 永久遗留在 cancel_requested;让 DB 看门狗以持租者身份收口,崩溃时由 dispatcher
# 在租约到期后回收,才能保证跨进程取消最终完成。
return _to_response(requested)
@@ -28,6 +28,7 @@ from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -42,6 +43,8 @@ class TaskDraftMetaResponse(BaseModel):
task_id: str
title: str = ""
furthest_step: int = 1
# 乐观锁版本号:PUT 时经 expected_version 回传,冲突 → 409 + current_version。
version: int = 0
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
chain_title: str | None = None
created_at: datetime | str | None = None
@@ -77,6 +80,9 @@ class TaskDraftUpdateRequest(BaseModel):
step1: dict[str, Any] | None = None
step2: dict[str, Any] | None = None
step3: dict[str, Any] | None = None
# 乐观锁:客户端基于的草稿版本号。缺省(旧客户端)= 盲写兼容;
# 提供时版本不匹配 → 409,body 带 current_version 供重拉合并。
expected_version: int | None = Field(default=None, ge=0)
# ── helpers ──────────────────────────────────────────────────────────────
@@ -175,7 +181,18 @@ async def get_draft(request: Request, draft_id: str) -> TaskDraftResponse:
async def update_draft(request: Request, draft_id: str, body: TaskDraftUpdateRequest) -> TaskDraftResponse:
store = _get_store(request)
fields = body.model_dump(exclude_unset=True)
row = await store.update_draft(draft_id, **fields)
try:
row = await store.update_draft(draft_id, **fields)
except DraftConcurrentWriteError as err:
# 版本冲突:detail 里带 current_version,前端据此重拉最新草稿、本地合并后重试。
raise HTTPException(
status_code=409,
detail={
"error": "draft_version_conflict",
"message": f"草稿已被并发修改(expected={err.expected}, actual={err.actual})",
"current_version": err.actual,
},
) from err
if row is None:
raise HTTPException(status_code=404, detail="Draft not found")
return TaskDraftResponse(**row)
+10 -3
View File
@@ -364,16 +364,23 @@ auth_login:
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
database:
backend: sqlite
backend: postgres
postgres_url: $POSTGRES_DATABASE_URL
sqlite_dir: .deer-flow/data
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
echo_sql: false
checkpointer:
type: sqlite
connection_string: .deer-flow/data/deerflow.db
type: postgres
connection_string: $POSTGRES_CHECKPOINT_URL
use_postgres: true
# PostgreSQL 优先;SQLite 仅作历史记录读取兜底,确认稳定后可移除。
dual_read_enabled: true
dual_read_sqlite_paths:
- .deer-flow/data/deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
@@ -54,6 +54,7 @@ class JobProgressWriter:
event_sink: EventSink = None,
on_dialogue: Any | None = None,
recorder: Any | None = None,
lease_owner: str | None = None,
) -> None:
self._store = store
self._job_id = job_id
@@ -65,6 +66,9 @@ class JobProgressWriter:
# 每轮 leader/seat 模型返回后回调(dialogues, consensus)—— 执行器据此把对话
# 写进草稿(和手动跑同一存取路径)。
self._on_dialogue = on_dialogue
# Phase 3 租约持有者:进度落库随 ``update_progress(lease_owner=...)`` 下发,store 的
# 租约门只放行持租者的写(一致性来自 DB 条件 UPDATE)。None = 兼容旧路径(不走租约门)。
self._lease_owner = lease_owner
self._state: dict[str, str] = {a.agent_id: _PENDING for a in agents}
self._spoke_cycle: dict[str, int] = {}
self._consensus = 0
@@ -100,7 +104,11 @@ class JobProgressWriter:
fields.setdefault("dialogues", self._dialogues)
if self._store is not None:
try:
await self._store.update_progress(self._job_id, **fields)
# 带租约持有者时走 store 的租约门(原子条件 UPDATE);返回 None 表示写被拒
# (非持租者 / 已取消)——进度落库 best-effort,不据此中断编排。
await self._store.update_progress(
self._job_id, lease_owner=self._lease_owner, **fields
)
except Exception: # noqa: BLE001 — 进度落库失败不能打断编排
logger.warning("roundtable job progress persist failed", exc_info=True)
if self._event_sink is not None:
@@ -91,6 +91,17 @@ class DatabaseConfig(BaseModel):
"SQLAlchemy default is 30."
),
)
pool_recycle: int = Field(
default=1800,
description=(
"Recycle pooled connections after this many seconds (postgres/mysql only). "
"Prevents stale-connection errors when a firewall, pgbouncer, or MySQL "
"wait_timeout silently drops idle connections. pool_pre_ping catches most "
"cases, but pool_recycle is the belt-and-suspenders safety net. "
"Multi-worker sizing: each worker opens up to (pool_size + max_overflow) "
"connections; total across workers must stay below the DB's max_connections."
),
)
# -- Derived helpers (not user-configured) --
@@ -130,7 +130,7 @@ async def _run_alembic_upgrade(url: str) -> None:
try:
await asyncio.wait_for(asyncio.to_thread(_do_upgrade), timeout=timeout_s)
logger.info("Alembic migrations applied (or already up-to-date)")
except asyncio.TimeoutError:
except TimeoutError:
logger.error(
"Alembic upgrade did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait — check SHOW FULL PROCESSLIST for a blocking transaction.",
@@ -213,12 +213,18 @@ def _ensure_orm_columns_sync(sync_conn) -> None:
This sweep compares each existing table against the ORM metadata and issues
``ALTER TABLE … ADD COLUMN`` for every missing column that can be added
safely (nullable, or carrying a server_default). Indexes covering a newly
added column are created as well. Works on sqlite/mysql/postgres — the
column DDL is compiled through the active dialect, so portable types
(``PortableJSON``/``PortableLongText``/``BeijingDateTime``) resolve to the
right backend type. Everything is per-column best-effort: a failure is
logged and startup continues.
added column are created as well. Table-level ``UniqueConstraint`` objects
that are declared on the model but missing from the table are also created
(as a ``CREATE UNIQUE INDEX`` of the same name, which is what the migrations
emit too, and works on SQLite/MySQL/PostgreSQL alike) — without this a newly
declared uniqueness rule (e.g. ``active_dedupe_key``) would silently be absent
on an existing database, weakening dedupe.
Works on sqlite/mysql/postgres — the column DDL is compiled through the
active dialect, so portable types (``PortableJSON``/``PortableLongText``/
``BeijingDateTime``) resolve to the right backend type. Everything is
per-column best-effort: a failure is logged and startup continues.
"""
from sqlalchemy import UniqueConstraint
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.schema import CreateColumn
@@ -273,6 +279,48 @@ def _ensure_orm_columns_sync(sync_conn) -> None:
except Exception:
logger.exception("Failed to auto-create index %s on %s", idx.name, table.name)
# ── 表级 UNIQUE 约束补齐 ──────────────────────────────────────────────────
# create_all 只为全新表建 UniqueConstraint;既有表上新增声明的唯一约束(如
# roundtable_jobs.active_dedupe_key)会静默缺失,去重/幂等的正确性就靠它,故
# 这里显式补建。迁移可能用 CREATE UNIQUE INDEX(同名)实现唯一性,故同时查
# get_unique_constraints 与 get_indexes 的名字集合,避免重复建。
for table in Base.metadata.sorted_tables:
if table.name not in existing_tables:
continue
named_uqs = [
c for c in table.constraints
if isinstance(c, UniqueConstraint) and c.name is not None
]
if not named_uqs:
continue
try:
existing_uq_names = {
uq["name"] for uq in inspector.get_unique_constraints(table.name) if uq.get("name")
}
except Exception:
existing_uq_names = set()
try:
existing_idx_names = {
ix["name"] for ix in inspector.get_indexes(table.name) if ix.get("name")
}
except Exception:
existing_idx_names = set()
for uq in named_uqs:
if uq.name in existing_uq_names or uq.name in existing_idx_names:
continue
# 用 CREATE UNIQUE INDEX(而非 ALTER TABLE ADD CONSTRAINT):迁移本身也用
# CREATE UNIQUE INDEX,且它在 SQLite/MySQL/PostgreSQL 都受支持(SQLite 不
# 支持 ALTER TABLE ADD CONSTRAINT),功能等价地强制唯一性。
cols = ", ".join(preparer.quote(c.name) for c in uq.columns)
try:
sync_conn.exec_driver_sql(
f"CREATE UNIQUE INDEX {preparer.quote(uq.name)} "
f"ON {preparer.quote(table.name)} ({cols})"
)
logger.info("Auto-added unique index %s on %s", uq.name, table.name)
except Exception:
logger.exception("Failed to add unique index %s on %s", uq.name, table.name)
def _is_mysql_unknown_database_error(exc: Exception) -> bool:
message = str(exc).lower()
@@ -287,6 +335,7 @@ async def init_engine(
pool_size: int = 5,
max_overflow: int = 10,
pool_timeout: int = 30,
pool_recycle: int = 1800,
sqlite_dir: str = "",
) -> None:
"""Create the async engine and session factory, then auto-create tables.
@@ -298,6 +347,8 @@ async def init_engine(
pool_size: Base connection pool size (postgres/mysql).
max_overflow: Extra connections allowed beyond pool_size under load (postgres/mysql).
pool_timeout: Seconds to wait for a free pooled connection before erroring (postgres/mysql).
pool_recycle: Recycle connections after N seconds (postgres/mysql). Prevents stale
connections dropped by firewalls / MySQL wait_timeout / pgbouncer.
sqlite_dir: Directory to create for SQLite (ensured to exist).
"""
global _engine, _session_factory, _engine_loop
@@ -366,6 +417,7 @@ async def init_engine(
max_overflow=max_overflow,
pool_timeout=pool_timeout,
pool_pre_ping=True,
pool_recycle=pool_recycle,
json_serializer=_json_serializer,
)
elif backend == "mysql":
@@ -376,7 +428,7 @@ async def init_engine(
max_overflow=max_overflow,
pool_timeout=pool_timeout,
pool_pre_ping=True,
pool_recycle=1800,
pool_recycle=pool_recycle,
json_serializer=_json_serializer,
)
else:
@@ -407,7 +459,7 @@ async def init_engine(
await _auto_create_postgres_db(url)
# Rebuild engine against the now-existing database
await _engine.dispose()
_engine = create_async_engine(url, echo=echo, pool_size=pool_size, max_overflow=max_overflow, pool_timeout=pool_timeout, pool_pre_ping=True, json_serializer=_json_serializer)
_engine = create_async_engine(url, echo=echo, pool_size=pool_size, max_overflow=max_overflow, pool_timeout=pool_timeout, pool_pre_ping=True, pool_recycle=pool_recycle, json_serializer=_json_serializer)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -423,7 +475,7 @@ async def init_engine(
max_overflow=max_overflow,
pool_timeout=pool_timeout,
pool_pre_ping=True,
pool_recycle=1800,
pool_recycle=pool_recycle,
json_serializer=_json_serializer,
)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
@@ -460,6 +512,7 @@ async def init_engine_from_config(config) -> None:
pool_size=config.pool_size,
max_overflow=getattr(config, "max_overflow", 10),
pool_timeout=getattr(config, "pool_timeout", 30),
pool_recycle=getattr(config, "pool_recycle", 1800),
sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "",
)
@@ -0,0 +1,25 @@
"""Intent-turn single-flight persistence (Phase 4).
Exposes a store factory; ``app/gateway/deps.py`` registers it on
``app.state.intent_turn_store`` at startup. The intent router reads it from
``request.app.state`` (no cross-boundary default needed — intent runs only in the
gateway process).
"""
from __future__ import annotations
from deerflow.persistence.intent_turns.model import IntentTurnRow
from deerflow.persistence.intent_turns.sql import IntentTurnRepository
def make_intent_turn_store(session_factory) -> IntentTurnRepository | None:
if session_factory is None:
return None
return IntentTurnRepository(session_factory)
__all__ = [
"IntentTurnRow",
"IntentTurnRepository",
"make_intent_turn_store",
]
@@ -0,0 +1,60 @@
"""ORM model for intent-determination turn single-flight (Phase 4).
一条 ``intent_turns`` 行 = 第一步「确定意图」对话的**一个回合**对 ``(thread_id,
client_turn_id)`` 的占用记录。前端为每个回合生成稳定的 ``client_turn_id``,重试 / 双击 /
网络层重发都复用它;后端用 ``UNIQUE(thread_id, client_turn_id)`` 做跨 worker 单飞:
* 同一 ``(thread_id, client_turn_id)`` 至多一条行 —— INSERT 冲突即幂等命中:
- 已 ``done`` → 原样回放缓存的终态帧,**不再启 run**(杜绝双发意图 / 重复澄清卡);
- 在跑且租约未过期 → 该请求是重复的 in-flight 请求,丢弃(原请求仍在流式吐结果);
- 在跑但租约过期(worker 崩溃)→ 抢占续跑;
- ``message_hash`` 不同(同一 client_turn_id 却是不同内容)→ 409 客户端 bug。
一致性只来自 DB 唯一键 + 条件 UPDATE(租约 CAS),不依赖进程内 ``RunManager``
(后者是每进程内存态,多 worker 下看不到彼此的 run,无法单飞)。
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableLongText
class IntentTurnRow(Base):
__tablename__ = "intent_turns"
__table_args__ = (
# 跨 worker 单飞的根:同一 (thread_id, client_turn_id) 至多一条。INSERT 冲突即幂等。
UniqueConstraint("thread_id", "client_turn_id", name="uq_intent_turns_thread_client"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
thread_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
# 前端每回合生成的稳定幂等键;重试 / 双击复用同一个。
client_turn_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 本回合 message 的 sha256:检测「同 client_turn_id 但内容变了」的客户端 bug(→ 409)。
message_hash: Mapped[str] = mapped_column(String(64), nullable=False)
# running(在跑)/ done(已出终态帧,可回放)/ error。
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", server_default="running"
)
# 终态帧的完整 JSON(done/error 时写入):重复请求命中时原样回放,不再启 run。
result_json: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# 租约:领走的 worker 标记;过期(worker 崩溃)后可被条件 UPDATE 抢占续跑。
lease_owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
lease_until: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,225 @@
"""SQLAlchemy-backed intent-turn single-flight storage (Phase 4).
跨 worker 单飞 + 幂等回放,一致性只来自 DB(唯一键 + 条件 UPDATE),不依赖进程内
``RunManager``(多 worker 下彼此不可见)。两条核心路径:
* ``try_claim`` —— INSERT 占用 ``(thread_id, client_turn_id)``;唯一键冲突即幂等命中,
按「已 done→回放 / 在跑且租约有效→丢弃 / 租约过期或 error→抢占续跑 / 内容不一致→409」分流。
* ``record_result`` —— 持租者把终态帧写回(条件 UPDATE 要求租约匹配),清租约。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker
from deerflow.persistence.intent_turns.model import IntentTurnRow
def _loads(value: Any) -> Any:
if isinstance(value, str) and value:
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
return value
class IntentTurnRepository:
def __init__(self, session_factory: async_sessionmaker) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: IntentTurnRow | None) -> dict[str, Any] | None:
if row is None:
return None
return {
"id": row.id,
"thread_id": row.thread_id,
"client_turn_id": row.client_turn_id,
"message_hash": row.message_hash,
"status": row.status,
"result_json": _loads(row.result_json),
"error": row.error,
"lease_owner": row.lease_owner,
"lease_until": row.lease_until.isoformat() if row.lease_until else None,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
async def get(self, thread_id: str, client_turn_id: str) -> dict[str, Any] | None:
stmt = select(IntentTurnRow).where(
IntentTurnRow.thread_id == thread_id,
IntentTurnRow.client_turn_id == client_turn_id,
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalar_one_or_none()
return self._row_to_dict(row)
async def try_claim(
self,
*,
thread_id: str,
client_turn_id: str,
message_hash: str,
lease_owner: str,
lease_until: datetime,
) -> dict[str, Any]:
"""占用一个意图回合 —— 跨 worker 单飞 + 幂等的入口。
返回 ``{"action": ..., "row": {...}}``,``action`` 取值:
* ``"run"`` —— 本调用方赢得占用(新建 / 抢占续跑),应启 run 并在终态写回。
* ``"replay"`` —— 该回合已 ``done``,原样回放 ``row.result_json``,**不再启 run**。
* ``"duplicate"``—— 该回合仍在跑且租约有效(或刚被别处抢占),本请求是重复的
in-flight 请求,应丢弃(原请求仍在流式吐结果)。
* ``"conflict"`` —— 同一 ``client_turn_id`` 但 ``message_hash`` 不同 → 客户端 bug,409。
"""
# 1) 先尝试 INSERT 占用。
new_id = uuid4().hex
try:
async with self._sf() as session:
session.add(
IntentTurnRow(
id=new_id,
thread_id=thread_id,
client_turn_id=client_turn_id,
message_hash=message_hash,
status="running",
lease_owner=lease_owner,
lease_until=lease_until,
)
)
await session.commit()
row = await self.get(thread_id, client_turn_id)
return {"action": "run", "row": row}
except IntegrityError:
# 唯一键 (thread_id, client_turn_id) 冲突 → 幂等命中,读回既有行分流。
pass
existing = await self.get(thread_id, client_turn_id)
if existing is None:
# 极端竞态:冲突与回读之间行被清掉。降级为「可新建」再插一次(best-effort)。
return {"action": "run", "row": None}
# 2) 内容指纹不一致 → 客户端 bug(同 client_turn_id 却换了 message)。
if existing.get("message_hash") != message_hash:
return {"action": "conflict", "row": existing}
status = existing.get("status") or "running"
# 3) 已完成 → 幂等回放终态帧。
if status == "done":
return {"action": "replay", "row": existing}
# 4) 在跑或出错 → 尝试条件 UPDATE 抢占续跑(error 强制;running 要求租约已过期)。
reclaimed = await self._conditional_reclaim(
thread_id=thread_id,
client_turn_id=client_turn_id,
prev_status=status,
lease_owner=lease_owner,
lease_until=lease_until,
)
if reclaimed is not None:
return {"action": "run", "row": reclaimed}
# 5) 抢占失败:仍在跑且租约有效(或刚被别处抢占)→ 本请求是重复 in-flight,丢弃。
return {"action": "duplicate", "row": existing}
async def _conditional_reclaim(
self,
*,
thread_id: str,
client_turn_id: str,
prev_status: str,
lease_owner: str,
lease_until: datetime,
) -> dict[str, Any] | None:
"""原子条件 UPDATE 抢占:``error`` 强制续跑;``running`` 仅当租约已过期。
成功则置 ``status='running'``、写新租约,返回行;否则 None(仍在跑 / 刚被抢占)。
"""
now = datetime.now(UTC)
where = [
IntentTurnRow.thread_id == thread_id,
IntentTurnRow.client_turn_id == client_turn_id,
]
if prev_status == "error":
where.append(IntentTurnRow.status == "error")
else:
where.extend(
[
IntentTurnRow.status == "running",
IntentTurnRow.lease_until.is_not(None),
IntentTurnRow.lease_until < now,
]
)
values = {
"status": "running",
"lease_owner": lease_owner,
"lease_until": lease_until,
"updated_at": now,
}
async with self._sf() as session:
result = await session.execute(
update(IntentTurnRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = (
await session.execute(
select(IntentTurnRow).where(
IntentTurnRow.thread_id == thread_id,
IntentTurnRow.client_turn_id == client_turn_id,
)
)
).scalar_one_or_none()
return self._row_to_dict(row)
async def record_result(
self,
*,
thread_id: str,
client_turn_id: str,
lease_owner: str,
status: str,
result_json: Any = None,
error: str | None = None,
) -> bool:
"""持租者把终态帧写回(条件 UPDATE 要求租约匹配),并清租约。
只有赢得 ``try_claim``(``action='run'``)的调用方才应调用,且 ``lease_owner``
必须与领取时一致 —— 防止「抢占续跑的新 worker」被「旧 worker 的迟到收尾」覆盖。
"""
now = datetime.now(UTC)
values: dict[str, Any] = {
"status": status,
"lease_owner": None,
"lease_until": None,
"updated_at": now,
}
if result_json is not None:
values["result_json"] = json.dumps(result_json, ensure_ascii=False)
if error is not None:
values["error"] = error
where = [
IntentTurnRow.thread_id == thread_id,
IntentTurnRow.client_turn_id == client_turn_id,
IntentTurnRow.lease_owner == lease_owner,
IntentTurnRow.status == "running",
]
async with self._sf() as session:
result = await session.execute(
update(IntentTurnRow).where(*where).values(**values)
)
if result.rowcount == 0:
return False
await session.commit()
return True
@@ -0,0 +1,61 @@
"""Add optimistic-lock version column to position_roundtable_nodes.
Revision ID: 20260813_01
Revises: 20260808_01
Create Date: 2026-08-13
背景:``complete_node_turn`` 把「本节点 done → 下游 invalidate → 解锁下一 stage」
拆成多个独立事务的 ``update_node``,并发下后写覆盖先写,节点状态被打错(卡 locked /
invalidated_by 残留),导致下一阶段 ``_handoff_context`` 拿不到上游产物(断链)。
新增 ``version`` 列供 ``update_node`` 做乐观锁(``WHERE version=?``),并把状态转移
收敛进单一 ``FOR UPDATE`` 事务(``recompute_chain_state``),彻底消除 TOCTOU。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260813_01"
down_revision = "20260808_01"
branch_labels = None
depends_on = None
_TABLE = "position_roundtable_nodes"
_COLUMN = "version"
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return any(
column["name"] == column_name
for column in inspect(op.get_bind()).get_columns(table_name)
)
def upgrade() -> None:
if not _has_table(_TABLE):
return
if not _has_column(_TABLE, _COLUMN):
op.add_column(
_TABLE,
sa.Column(
_COLUMN,
sa.Integer(),
nullable=False,
server_default="0",
),
)
def downgrade() -> None:
if not _has_table(_TABLE):
return
if _has_column(_TABLE, _COLUMN):
op.drop_column(_TABLE, _COLUMN)
@@ -0,0 +1,48 @@
"""Add optimistic-lock ``version`` column to both roundtable draft tables.
Revision ID: 20260813_02
Revises: 20260813_01
Create Date: 2026-08-13
为 ``roundtable_drafts``(个人草稿)与 ``roundtable_task_drafts``(任务草稿)各加一列
``version INTEGER NOT NULL DEFAULT 0``,配合 ``update_draft(expected_version=...)`` 乐观锁,
消除后台作业 ``_write_run_to_draft`` 的 step2.runs 读-改-写丢失更新。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260813_02"
down_revision = "20260813_01"
branch_labels = None
depends_on = None
_TABLES = ("roundtable_drafts", "roundtable_task_drafts")
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
if table_name not in inspect(bind).get_table_names():
return False
return any(
column["name"] == column_name
for column in inspect(bind).get_columns(table_name)
)
def upgrade() -> None:
for table in _TABLES:
if not _has_column(table, "version"):
op.add_column(
table,
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
for table in _TABLES:
if _has_column(table, "version"):
op.drop_column(table, "version")
@@ -0,0 +1,88 @@
"""Add session version column and command idempotency table.
Revision ID: 20260813_03
Revises: 20260813_02
Create Date: 2026-08-13
岗位会商并发根治(命令式状态机)的两项 DB 支撑:
1. ``position_roundtable_sessions.version`` — session 级乐观锁版本号,保护
「确定意图 / 激活链 / 归档」等 session 级写操作,配合 ``apply_node_command``
的单一 ``FOR UPDATE`` 事务消除两个用户同时激活/归档同一 session 的竞态。
2. ``position_roundtable_commands`` — 命令幂等记录表。网络重试 / 双击 /
beforeunload beacon 可能把同一命令送达多次;靠 ``UNIQUE(session_id,
node_key, command_id)`` 让数据库天然去重,第二次到达时直接返回上次
``result_json``,绝不重复推进状态机。``payload_hash`` 防止客户端误复用
command_id 发送不同参数(同 command_id 但 hash 不同 → 409)。
``create_all`` 会为全新 DB 建表/补列;本迁移只补丁已存在的 DB。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260813_03"
down_revision = "20260813_02"
branch_labels = None
depends_on = None
_SESSIONS_TABLE = "position_roundtable_sessions"
_COMMANDS_TABLE = "position_roundtable_commands"
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return any(
column["name"] == column_name
for column in inspect(op.get_bind()).get_columns(table_name)
)
def upgrade() -> None:
# 1) sessions.version
if _has_table(_SESSIONS_TABLE) and not _has_column(_SESSIONS_TABLE, "version"):
op.add_column(
_SESSIONS_TABLE,
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
)
# 2) position_roundtable_commands
# result_json 与模型的 PortableLongText 对齐:MySQL 用 LONGTEXT,其余用 TEXT。
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
if not _has_table(_COMMANDS_TABLE):
op.create_table(
_COMMANDS_TABLE,
sa.Column("id", sa.String(length=64), primary_key=True),
sa.Column("session_id", sa.String(length=64), nullable=False),
sa.Column("node_key", sa.String(length=256), nullable=False, server_default=""),
sa.Column("command_id", sa.String(length=64), nullable=False),
sa.Column("command_type", sa.String(length=32), nullable=False),
sa.Column("payload_hash", sa.String(length=64), nullable=False),
sa.Column("result_json", long_text, nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint(
"session_id", "node_key", "command_id",
name="uq_position_roundtable_commands_session_node_cmd",
),
)
op.create_index(
"ix_position_roundtable_commands_session_id",
_COMMANDS_TABLE,
["session_id"],
)
def downgrade() -> None:
if _has_table(_COMMANDS_TABLE):
op.drop_table(_COMMANDS_TABLE)
if _has_table(_SESSIONS_TABLE) and _has_column(_SESSIONS_TABLE, "version"):
op.drop_column(_SESSIONS_TABLE, "version")
@@ -0,0 +1,51 @@
"""Add ``draft_persist_pending`` flag to roundtable jobs.
Revision ID: 20260813_04
Revises: 20260813_03
Create Date: 2026-08-13
后台作业 ``_write_run_to_draft`` 的乐观锁重试全部耗尽后,不再退化为
last-write-wins 盲写,而是把 ``draft_persist_pending`` 置 1;由 outbox 补偿器
(Phase 3)负责投影结果到草稿,保证「研讨结果不丢」的一致性来自 DB 标记而非运气。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260813_04"
down_revision = "20260813_03"
branch_labels = None
depends_on = None
_TABLE = "roundtable_jobs"
def _has_column(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
if table_name not in inspect(bind).get_table_names():
return False
return any(
column["name"] == column_name
for column in inspect(bind).get_columns(table_name)
)
def upgrade() -> None:
if not _has_column(_TABLE, "draft_persist_pending"):
op.add_column(
_TABLE,
sa.Column(
"draft_persist_pending",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
if _has_column(_TABLE, "draft_persist_pending"):
op.drop_column(_TABLE, "draft_persist_pending")
@@ -0,0 +1,115 @@
"""Add lease / dedupe / snapshot columns to roundtable jobs (Phase 3).
Revision ID: 20260813_05
Revises: 20260813_04
Create Date: 2026-08-13
把「后台作业不双跑 / 崩溃可接管 / resume·cancel 串行」的一致性收敛到 DB:
- ``active_dedupe_key``:**唯一索引**。作业活跃(非终态)时存
``sha256(scope)``(scope = draft_id + task_id|user_id),进入终态时清 NULL。
MySQL/SQLite 的唯一索引都允许多个 NULL —— 于是「同一 scope 至多一个活跃作业」
由唯一键强制,INSERT 冲突即幂等命中,**不再依赖 GET_LOCK 命名锁**。
- ``request_id`` / ``request_hash``:前端启动请求的幂等键与入参指纹(审计 /
排查用;去重本身由 active_dedupe_key 负责)。
- ``input_snapshot``:启动入参的完整 JSON 快照 —— dispatcher 在**任意 worker**
领取 queued/租约过期作业时据此重建 StartParams(跨进程可接管的前提)。
- ``lease_owner`` / ``lease_until``:租约。条件 UPDATE 抢占(queued 或租约过期),
心跳续租;worker 崩溃后租约自然过期,其它 worker 接管 —— 恢复能力来自 DB 而非
进程内存。
- ``attempt`` / ``version``:接管次数计数 + 行级 CAS 版本(条件 UPDATE 乐观锁)。
``draft_persist_pending`` 已在 20260813_04 加入,此处不重复。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects import mysql
revision = "20260813_05"
down_revision = "20260813_04"
branch_labels = None
depends_on = None
_TABLE = "roundtable_jobs"
_DEDUPE_INDEX = "uq_roundtable_jobs_active_dedupe_key"
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return any(
column["name"] == column_name
for column in inspect(op.get_bind()).get_columns(table_name)
)
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
try:
return any(ix["name"] == index_name for ix in inspect(op.get_bind()).get_indexes(table_name))
except Exception: # pragma: no cover - 方言差异兜底
return False
def _longtext() -> sa.types.TypeEngine:
"""SQLite/PostgreSQL 用 TEXT(无上限),MySQL 用 LONGTEXT(避免 64KB 截断)。"""
return sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
def upgrade() -> None:
if not _has_table(_TABLE):
return
if not _has_column(_TABLE, "request_id"):
op.add_column(_TABLE, sa.Column("request_id", sa.String(64), nullable=True))
if not _has_column(_TABLE, "request_hash"):
op.add_column(_TABLE, sa.Column("request_hash", sa.String(64), nullable=True))
if not _has_column(_TABLE, "active_dedupe_key"):
op.add_column(_TABLE, sa.Column("active_dedupe_key", sa.String(64), nullable=True))
if not _has_index(_TABLE, _DEDUPE_INDEX):
op.create_index(_DEDUPE_INDEX, _TABLE, ["active_dedupe_key"], unique=True)
if not _has_column(_TABLE, "input_snapshot"):
op.add_column(_TABLE, sa.Column("input_snapshot", _longtext(), nullable=True))
if not _has_column(_TABLE, "lease_owner"):
op.add_column(_TABLE, sa.Column("lease_owner", sa.String(64), nullable=True))
if not _has_column(_TABLE, "lease_until"):
op.add_column(_TABLE, sa.Column("lease_until", sa.DateTime(), nullable=True))
if not _has_column(_TABLE, "attempt"):
op.add_column(
_TABLE,
sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("0")),
)
if not _has_column(_TABLE, "version"):
op.add_column(
_TABLE,
sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("0")),
)
def downgrade() -> None:
if not _has_table(_TABLE):
return
if _has_index(_TABLE, _DEDUPE_INDEX):
op.drop_index(_DEDUPE_INDEX, table_name=_TABLE)
for col in (
"version",
"attempt",
"lease_until",
"lease_owner",
"input_snapshot",
"active_dedupe_key",
"request_hash",
"request_id",
):
if _has_column(_TABLE, col):
op.drop_column(_TABLE, col)
@@ -0,0 +1,77 @@
"""intent_turns: intent-determination turn single-flight table (Phase 4).
Revision ID: 20260813_06
Revises: 20260813_05
Create Date: 2026-08-13
新建 ``intent_turns`` 表:第一步「确定意图」对话每回合对 ``(thread_id, client_turn_id)``
的占用记录。前端每回合生成稳定 ``client_turn_id``(重试 / 双击复用),后端用
``UNIQUE(thread_id, client_turn_id)`` 跨 worker 单飞 + 幂等回放,杜绝并发下「同一回合
被启两个 run → 双发意图 / 重复澄清卡」(用户报的「确定意图接口报错」)。
为什么需要它:进程内 ``RunManager`` 是每 worker 内存态,多 worker 下看不到彼此的 run,
同 thread 的两个请求若落到不同 worker 会各启一个 run(没有任何 409)—— 单飞只能靠 DB。
防变动既有库:全新部署由 ``create_all`` 建表;既有库由本迁移 + ``_ensure_orm_columns_sync``
兜底(表不存在则建)。所有步骤带 ``_has_table``/``_has_index`` 守卫,可重复执行。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260813_06"
down_revision = "20260813_05"
branch_labels = None
depends_on = None
_TABLE = "intent_turns"
_UQ = "uq_intent_turns_thread_client"
def _has_table(name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return name in insp.get_table_names()
def _has_index(table: str, name: str) -> bool:
bind = op.get_bind()
insp = sa.inspect(bind)
return any(ix["name"] == name for ix in insp.get_indexes(table))
def upgrade() -> None:
if _has_table(_TABLE):
# 既有库(极少见,纯防御):补唯一索引即可。
if not _has_index(_TABLE, _UQ):
op.create_index(_UQ, _TABLE, ["thread_id", "client_turn_id"], unique=True)
return
op.create_table(
_TABLE,
sa.Column("id", sa.String(length=64), nullable=False),
sa.Column("thread_id", sa.String(length=128), nullable=False),
sa.Column("client_turn_id", sa.String(length=64), nullable=False),
sa.Column("message_hash", sa.String(length=64), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default=sa.text("running")),
sa.Column("result_json", sa.Text().with_variant(sa.dialects.mysql.LONGTEXT(), "mysql"), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("lease_owner", sa.String(length=64), nullable=True),
sa.Column("lease_until", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_intent_turns_thread_id", _TABLE, ["thread_id"])
op.create_index(_UQ, _TABLE, ["thread_id", "client_turn_id"], unique=True)
def downgrade() -> None:
if _has_index(_TABLE, _UQ):
op.drop_index(_UQ, table_name=_TABLE)
if _has_index(_TABLE, "ix_intent_turns_thread_id"):
op.drop_index("ix_intent_turns_thread_id", table_name=_TABLE)
if _has_table(_TABLE):
op.drop_table(_TABLE)
@@ -0,0 +1,42 @@
"""Add version column to roundtable_artifact_submissions for CAS.
Revision ID: 20260813_07
Revises: 20260813_06
Create Date: 2026-08-13
Phase 5 CAS: submit/cancel 带 expected_version,并发时旧 cancel 不得撤销新提交。
version 列有 server_default='0',既有行自动得 0,无需数据回填。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260813_07"
down_revision = "20260813_06"
branch_labels = None
depends_on = None
_TABLE = "roundtable_artifact_submissions"
_COL = "version"
def _has_column(bind, table: str, column: str) -> bool:
inspector = sa.inspect(bind)
return any(col["name"] == column for col in inspector.get_columns(table))
def upgrade() -> None:
bind = op.get_bind()
if not _has_column(bind, _TABLE, _COL):
op.add_column(
_TABLE,
sa.Column(_COL, sa.Integer(), nullable=False, server_default=sa.text("0")),
)
def downgrade() -> None:
bind = op.get_bind()
if _has_column(bind, _TABLE, _COL):
op.drop_column(_TABLE, _COL)
@@ -40,6 +40,7 @@ from deerflow.persistence.concurrency.model import ConcurrencySampleRow
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.intent_turns.model import IntentTurnRow
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
@@ -147,6 +148,7 @@ __all__ = [
"TaskCopTaskRow",
"TaskReportRow",
"EmbedSessionRow",
"IntentTurnRow",
"ThreadMetaRow",
"ThreadShareRow",
"ToolCallMetricRow",
@@ -3,12 +3,14 @@
from __future__ import annotations
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableCommandRow,
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
from deerflow.persistence.position_roundtable.sql import PositionRoundtableRepository
__all__ = [
"PositionRoundtableCommandRow",
"PositionRoundtableNodeRow",
"PositionRoundtableRepository",
"PositionRoundtableSessionRow",
@@ -0,0 +1,117 @@
"""Pure delivery-decision helpers for position-roundtable nodes.
These functions decide whether a turn delivered a real Markdown report, whether
the delivery changed, and whether a node is the special intent position. They
are intentionally **pure** (no DB / no app imports) so the exact same logic can
run both:
- in the API router (for read-only projections and validation), and
- inside ``PositionRoundtableRepository.apply_node_command`` — *under the row
locks* — where every state-machine decision must be computed from the locked
rows, never from a stale pre-transaction snapshot.
Keeping one implementation avoids the classic drift where the router's decision
and the store's transition disagree under concurrency.
"""
from __future__ import annotations
from typing import Any
_OUTPUTS_PREFIX = "mnt/user-data/outputs/"
def _artifact_path(artifact: dict[str, Any]) -> str:
return str(artifact.get("path") or artifact.get("artifact_url") or "").strip().lower()
def _artifact_name(artifact: dict[str, Any]) -> str:
return str(artifact.get("name") or artifact.get("filename") or "").strip().lower()
def _artifact_mime(artifact: dict[str, Any]) -> str:
return str(artifact.get("mime_type") or artifact.get("mimeType") or "").strip().lower()
def _in_outputs(path: str) -> bool:
"""Only files under the thread outputs dir count as deliverables."""
return not path or path.replace("\\", "/").lstrip("/").startswith(_OUTPUTS_PREFIX)
def is_markdown_artifact(artifact: Any) -> bool:
if not isinstance(artifact, dict):
return False
path = _artifact_path(artifact)
name = _artifact_name(artifact)
mime = _artifact_mime(artifact)
if not _in_outputs(path):
return False
return (
"markdown" in mime
or path.endswith(".md")
or path.endswith(".markdown")
or name.endswith(".md")
or name.endswith(".markdown")
)
def is_json_artifact(artifact: Any) -> bool:
if not isinstance(artifact, dict):
return False
path = _artifact_path(artifact)
name = _artifact_name(artifact)
mime = _artifact_mime(artifact)
if not _in_outputs(path):
return False
return "json" in mime or path.endswith(".json") or name.endswith(".json")
def artifact_signature(artifact: dict[str, Any]) -> tuple[str, str, str]:
"""Use server-returned path/mtime/size to detect an actually rewritten file."""
return (
str(artifact.get("path") or artifact.get("artifact_url") or ""),
str(artifact.get("modified_at") or artifact.get("modifiedAt") or ""),
str(artifact.get("size_bytes") or artifact.get("sizeBytes") or ""),
)
def markdown_delivery_changed(
previous_artifacts: Any,
incoming_artifacts: list[dict[str, Any]],
) -> bool:
"""Whether this turn rewrote a Markdown deliverable, not just chatted.
A completed node can receive normal follow-up Q&A. That must not bump its
effective delivery revision or invalidate every downstream stage unless a
Markdown report really changed. The standard artifact listing includes
``modified_at`` and ``size_bytes``, which together make a stable lightweight
version marker without reading a potentially large file again.
"""
old = previous_artifacts if isinstance(previous_artifacts, list) else []
old_signatures = {
artifact_signature(item) for item in old if is_markdown_artifact(item)
}
new_signatures = {
artifact_signature(item) for item in incoming_artifacts if is_markdown_artifact(item)
}
return bool(new_signatures - old_signatures)
def is_intent_position_node(
position_id: str | None,
intent_position_id: str | None = None,
) -> bool:
"""Whether a node is the special intent position (answers need no report)."""
position = str(position_id or "").strip()
if intent_position_id:
return position == intent_position_id
# Existing persisted sessions predate the configurable directory. Retain
# their historical interpretation until they are archived/recreated.
return position.lower() in {"intelligence", "intel", "intent"}
def session_intent_position_id(chain_snapshot: Any) -> str | None:
"""Read the frozen intent-position id from an activated chain snapshot."""
chain = chain_snapshot if isinstance(chain_snapshot, dict) else {}
value = chain.get("_intent_position_id")
return value.strip() if isinstance(value, str) and value.strip() else None
@@ -60,6 +60,10 @@ class PositionRoundtableSessionRow(Base):
# intent_pending -> active -> completed / archived. The router owns legal
# transitions; the DB keeps it as a concise, portable string.
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
# 乐观并发控制版本号:保护「确定意图 / 激活链 / 归档」等 session 级变更。
# 每次 session 写操作自增;传入 expected_version 时做 ``WHERE version=?`` CAS,
# 命中 0 行即并发冲突(消除两个用户同时激活/归档同一 session 的竞态)。
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
@@ -101,6 +105,11 @@ class PositionRoundtableNodeRow(Base):
# position Session removes every recovery snapshot through the FK cascade.
conversation_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 乐观并发控制版本号:每次 update_node 自增;传入 expected_version 时做
# ``WHERE version=?`` 校验,命中 0 行即并发冲突。与 revision(业务交付计数)
# 刻意解耦——revision 仅在真正交付新 Markdown 报告时 +1,version 在任意字段
# 变更时 +1,专用于消除「complete_node_turn 多步状态机」并发盲写丢失更新。
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
@@ -114,3 +123,38 @@ class PositionRoundtableNodeRow(Base):
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
class PositionRoundtableCommandRow(Base):
"""命令幂等记录:同一次客户端操作(由 command_id 标识)只执行一次。
网络重试 / 双击 / beforeunload beacon 可能把同一命令送达多次。靠
``UNIQUE(session_id, node_key, command_id)`` 让数据库天然去重:第二次到达时
命中已有记录,直接返回 ``result_json``(上次结果),绝不重复推进状态机。
``payload_hash`` 防止客户端误复用 command_id 发送不同参数——同 command_id 但
payload 不同 → 409。session 级命令(activate/archive)node_key 存空串。
"""
__tablename__ = "position_roundtable_commands"
__table_args__ = (
UniqueConstraint(
"session_id", "node_key", "command_id",
name="uq_position_roundtable_commands_session_node_cmd",
),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# 命令作用的节点;session 级命令(activate/archive/delete)存空串 ""。
node_key: Mapped[str] = mapped_column(String(256), nullable=False, default="")
# 客户端生成的幂等键(一次点击/重试共用)。
command_id: Mapped[str] = mapped_column(String(64), nullable=False)
# complete / bind / reject / conversation / activate / archive / delete
command_type: Mapped[str] = mapped_column(String(32), nullable=False)
# 规范化 payload 的 sha256;同 command_id 但 hash 不同 → 409(防误复用)。
payload_hash: Mapped[str] = mapped_column(String(64), nullable=False)
# 上次执行结果的 JSON 序列化,重放时原样返回。
result_json: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
@@ -2,6 +2,7 @@
from __future__ import annotations
import hashlib
import json
from datetime import UTC, datetime
from typing import Any
@@ -11,7 +12,14 @@ from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.position_roundtable.delivery import (
is_intent_position_node,
is_markdown_artifact,
markdown_delivery_changed,
session_intent_position_id,
)
from deerflow.persistence.position_roundtable.model import (
PositionRoundtableCommandRow,
PositionRoundtableNodeRow,
PositionRoundtableSessionRow,
)
@@ -42,6 +50,85 @@ def _has_material(row: PositionRoundtableNodeRow) -> bool:
return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
def _has_confirmed_intent_snapshot(intent: Any) -> bool:
"""Return whether a persisted Step-1 snapshot may activate a chain.
This duplicates only the storage-level shape check used by the router so
activation can re-check the prerequisite *after* locking the session row.
A router-only pre-check leaves a TOCTOU window where another user can
overwrite the intent before the chain topology is frozen.
"""
if not isinstance(intent, dict):
return False
required_sections = ("coreGoals", "riskWarnings", "keyPoints", "strategicSignificance")
if any(key in intent for key in required_sections):
if not all(isinstance(intent.get(key), list) for key in required_sections):
return False
return bool(intent["coreGoals"] or intent["strategicSignificance"])
objective = intent.get("objective") or intent.get("goal")
return bool(isinstance(objective, str) and objective.strip() and isinstance(intent.get("constraints"), list))
class NodeConcurrentWriteError(Exception):
"""节点被并发改写,调用方持有的 version 已过期(乐观锁冲突)。
由 ``update_node(expected_version=...)`` / ``recompute_chain_state`` 在检测到
version 不匹配时抛出。调用方可捕获后重新读取最新状态重算(幂等重入)。
"""
def __init__(
self,
node_key: str,
*,
expected: int | None = None,
actual: int | None = None,
) -> None:
self.node_key = node_key
self.expected = expected
self.actual = actual
super().__init__(
f"concurrent write detected on position node {node_key}"
+ (f" (expected version {expected}, got {actual})" if expected is not None else "")
)
class SessionWriteConflictError(Exception):
"""Session-level state changed after a route's optimistic pre-check.
The public routes translate this into HTTP 409. Keeping the exception at
the persistence boundary prevents a stale intent/task save from reviving
an archived session or changing the frozen input of an active chain.
"""
def __init__(self, detail: str) -> None:
self.detail = detail
super().__init__(detail)
class CommandConflictError(Exception):
"""命令在锁内判定为业务冲突(→ HTTP 409),事务已回滚、未改任何状态。
与「幂等重放」严格区分:重放返回上次成功结果;冲突说明状态机拒绝该命令
(节点未解锁 / 已绑定其它线程 / 同 command_id 携带了不同参数等)。
``detail`` 是面向用户的提示,router 直接转成 409。
"""
def __init__(self, detail: str) -> None:
self.detail = detail
super().__init__(detail)
def _payload_hash(command_type: str, payload: dict[str, Any]) -> str:
"""规范化命令参数的 sha256,用于「同 command_id 是否同一参数」判定。"""
normalized = json.dumps(
{"type": command_type, "payload": payload},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
class PositionRoundtableRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@@ -70,6 +157,7 @@ class PositionRoundtableRepository:
"action_plan_snapshot": _loads(row.action_plan_snapshot),
"action_plan_conversation": _loads(row.action_plan_conversation),
"status": row.status,
"version": row.version,
"created_at": _time(row.created_at),
"updated_at": _time(row.updated_at),
}
@@ -90,6 +178,7 @@ class PositionRoundtableRepository:
"artifact_manifest": _loads(row.artifact_manifest) or [],
"conversation_snapshot": _loads(row.conversation_snapshot),
"revision": row.revision,
"version": row.version,
"upstream_revision": _loads(row.upstream_revision),
"rejection_count": row.rejection_count or 0,
"last_rejection": _loads(row.last_rejection),
@@ -294,11 +383,32 @@ class PositionRoundtableRepository:
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
action_plan_conversation: dict[str, Any] | None | object = _UNSET,
status: str | object = _UNSET,
allowed_statuses: set[str] | None = None,
) -> dict[str, Any] | None:
"""Update one session snapshot under the same row lock as commands.
Snapshot persistence used to do an unlocked ORM ``get`` followed by a
write. That permitted a stale "confirm intent" save to land after a
concurrent activation/archival, or allowed two different snapshot
surfaces to overwrite each other's unseen values. Acquiring the
parent row lock serializes all session writes with node/session
commands. ``allowed_statuses`` additionally freezes task/intent
inputs once the business chain has become active.
"""
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
row = (
await session.execute(
select(PositionRoundtableSessionRow)
.where(PositionRoundtableSessionRow.id == session_id)
.with_for_update()
)
).scalar_one_or_none()
if row is None or not self._can_access(row, user_id):
return None
if row.status == "archived":
raise SessionWriteConflictError("该岗位协同会话已归档,仅可查看历史记录")
if allowed_statuses is not None and row.status not in allowed_statuses:
raise SessionWriteConflictError("会话已启动,不能再修改已冻结的任务或意图")
if task_snapshot is not _UNSET:
row.task_snapshot = _dumps(task_snapshot)
if intent_thread_id is not _UNSET:
@@ -321,6 +431,7 @@ class PositionRoundtableRepository:
row.action_plan_conversation = _dumps(action_plan_conversation)
if status is not _UNSET:
row.status = str(status)
row.version += 1
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
@@ -425,6 +536,7 @@ class PositionRoundtableRepository:
upstream_revision: dict[str, Any] | object = _UNSET,
last_rejection: dict[str, Any] | None | object = _UNSET,
invalidated_by: str | None | object = _UNSET,
expected_version: int | None = None,
increment_revision: bool = False,
increment_rejection: bool = False,
) -> dict[str, Any] | None:
@@ -436,9 +548,17 @@ class PositionRoundtableRepository:
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
).limit(1)
# 乐观锁:传入 expected_version 时加行锁并校验,杜绝「检查后到提交之间
# 被并发改写」的 TOCTOU。recompute_chain_state 不走这里(它自管事务)。
if expected_version is not None:
stmt = stmt.with_for_update()
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
if expected_version is not None and row.version != expected_version:
raise NodeConcurrentWriteError(
node_key, expected=expected_version, actual=row.version
)
if thread_id is not _UNSET:
row.thread_id = thread_id or None
if status is not _UNSET:
@@ -455,6 +575,8 @@ class PositionRoundtableRepository:
row.last_rejection = _dumps(last_rejection)
if invalidated_by is not _UNSET:
row.invalidated_by = invalidated_by or None
# 任意字段变更都 bump version(乐观锁单调递增),与业务交付 revision 解耦。
row.version += 1
if increment_revision:
row.revision += 1
if increment_rejection:
@@ -464,6 +586,734 @@ class PositionRoundtableRepository:
await session.refresh(row)
return self._node_dict(row)
async def recompute_chain_state(
self,
session_id: str,
node_key: str,
user_id: str,
*,
next_status: str,
latest_answer: str | None,
artifact_manifest: list[dict[str, Any]] | None,
upstream_revision: dict[str, Any] | None,
new_effective_delivery: bool,
) -> dict[str, Any] | None:
"""在单一 ``FOR UPDATE`` 事务内原子完成「本节点交付 → 下游失效 → 解锁下一 stage」。
把原来 router 里由若干个独立提交的 ``update_node`` 拼成的状态转移收敛成一个
事务:先锁住本 session 全部节点行(MySQL ``SELECT ... FOR UPDATE``),再在锁
内重算所有节点状态,一次提交。同一 session 的并发 ``complete_node_turn`` 因此被
数据库行锁串行化,彻底消除 TOCTOU / 丢失更新导致的「下一 stage 卡 locked /
invalidated_by 残留 → 下一阶段拿不到上游产物」断链。
幂等可重入:解锁不假设「最后一个 seat 负责」,而是遍历每个「全 done」stage 补
解锁其紧邻下一 stage 的 locked 节点;多次调用结果一致。``next_status`` /
``new_effective_delivery`` 等业务判定由 router 基于调用前快照算好传入,本方法
只负责把它们原子地落到节点状态机上。
"""
async with self._sf() as session:
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or not self._can_access(parent, user_id):
return None
# 锁住本 session 全部节点行,串行化并发状态转移(行锁持有至 commit)。
rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(
PositionRoundtableNodeRow.stage_index,
PositionRoundtableNodeRow.seat_index,
)
.with_for_update()
)
).scalars().all()
if not rows:
return None
by_key = {r.node_key: r for r in rows}
current = by_key.get(node_key)
if current is None:
return None
now = datetime.now(UTC)
# 本次交付前的 revision(用于判断「此前已交付过 → 新交付需失效下游」)。
old_revision = current.revision
# ① 落本节点交付状态。
current.status = next_status
current.latest_answer = latest_answer
if artifact_manifest is not None:
current.artifact_manifest = _dumps(artifact_manifest)
if upstream_revision is not None:
current.upstream_revision = _dumps(upstream_revision)
current.invalidated_by = None
if new_effective_delivery:
current.revision += 1
current.version += 1
current.updated_at = now
# ② + ③ 与命令式状态机共用同一套下游失效 / 幂等解锁实现,避免逻辑漂移。
self._invalidate_downstream(rows, current, new_effective_delivery, old_revision, now)
self._recompute_unlocks(rows, now)
await session.commit()
await session.refresh(current)
return self._node_dict(current)
# ── command-style state machine ──────────────────────────────────────────
async def apply_node_command(
self,
session_id: str,
node_key: str,
user_id: str,
*,
command_id: str,
command_type: str,
payload: dict[str, Any] | None = None,
expected_session_version: int | None = None,
) -> dict[str, Any]:
"""命令式状态机:所有节点级写命令(complete/bind/conversation/reject)的唯一入口。
单事务内依次完成:
① ``SELECT ... FOR UPDATE`` 锁住 session 行(可选 ``expected_session_version``
CAS,命中失败即 409);
② 命令幂等判定——``UNIQUE(session_id, node_key, command_id)`` 命中已有记录:
参数一致(payload_hash 相同)→ 原样重放上次结果;参数不同 → 409;
③ 锁住本 session 全部节点行,**基于锁内状态**做所有业务判定
(能否完成 / 是否新交付 / 能否驳回),绝不信任调用方传入的过期快照;
④ 应用状态转移(本节点更新 → 下游失效 → 幂等重算解锁);
⑤ 写入命令幂等记录(含结果 JSON),一次提交。
返回 dict:
- ``ok=True`` — ``replayed`` 标记是否幂等重放;``node`` 为目标节点新状态,
``reject`` 命令额外返回 ``nodes``(全部节点)与 ``session``;
- ``ok=False`` — ``http_status``(404/409)+ ``detail``;事务已回滚,状态未变。
一致性只来自数据库:并发命令被 session/节点行锁串行化,重试/双击/beacon 由
命令唯一键去重。进程内任何缓存都不参与正确性。
"""
payload = payload or {}
payload_hash = _payload_hash(command_type, payload)
now = datetime.now(UTC)
async def _run(session: AsyncSession) -> dict[str, Any]:
parent = (
await session.execute(
select(PositionRoundtableSessionRow)
.where(PositionRoundtableSessionRow.id == session_id)
.with_for_update()
)
).scalar_one_or_none()
if parent is None or not self._can_access(parent, user_id):
return {"ok": False, "http_status": 404, "detail": "Position roundtable session not found"}
if expected_session_version is not None and parent.version != expected_session_version:
return {
"ok": False,
"http_status": 409,
"detail": "会话状态已更新,请刷新后重试",
"session": self._session_dict(parent),
}
# 归档后的会话拒绝一切写命令(锁内复检,杜绝路由预检与提交之间的窗口)。
if parent.status == "archived":
return {
"ok": False,
"http_status": 409,
"detail": "该岗位协同会话已归档,仅可查看历史记录",
}
# ② 命令幂等:同 command_id 已执行过 → 重放上次结果(或 409)。
existing = (
await session.execute(
select(PositionRoundtableCommandRow).where(
PositionRoundtableCommandRow.session_id == session_id,
PositionRoundtableCommandRow.node_key == (node_key or ""),
PositionRoundtableCommandRow.command_id == command_id,
)
)
).scalars().first()
if existing is not None:
if existing.payload_hash != payload_hash:
return {
"ok": False,
"http_status": 409,
"detail": "相同操作标识携带了不同参数,已拒绝执行",
}
return {
"ok": True,
"replayed": True,
"command_type": command_type,
**(_loads(existing.result_json) or {}),
}
# ③ 锁住全部节点行(行锁持有至 commit),串行化同 session 并发命令。
rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == session_id)
.order_by(
PositionRoundtableNodeRow.stage_index,
PositionRoundtableNodeRow.seat_index,
)
.with_for_update()
)
).scalars().all()
by_key = {r.node_key: r for r in rows}
if command_type == "complete":
result = self._apply_complete(by_key, node_key, parent, payload, now)
elif command_type == "bind":
result = self._apply_bind(by_key, node_key, payload, now)
elif command_type == "conversation":
result = self._apply_conversation(by_key, node_key, payload, now)
elif command_type == "reject":
result = self._apply_reject(by_key, node_key, parent, payload, user_id, now)
else:
return {"ok": False, "http_status": 400, "detail": f"unknown command type: {command_type}"}
if not result.get("ok", True):
return result
# ⑤ 写命令幂等记录(与状态转移同事务提交)。
stored = {k: v for k, v in result.items() if k != "ok"}
session.add(
PositionRoundtableCommandRow(
id=uuid4().hex,
session_id=session_id,
node_key=node_key or "",
command_id=command_id,
command_type=command_type,
payload_hash=payload_hash,
result_json=_dumps(stored),
created_at=now,
)
)
parent.version += 1
parent.updated_at = now
result["replayed"] = False
result["command_type"] = command_type
return result
try:
async with self._sf() as session:
outcome = await _run(session)
if outcome.get("ok", False):
await session.commit()
else:
await session.rollback()
return outcome
except IntegrityError:
# 命令唯一键并发竞态:另一连接已用同一 command_id 提交。读回其结果重放。
async with self._sf() as session:
existing = (
await session.execute(
select(PositionRoundtableCommandRow).where(
PositionRoundtableCommandRow.session_id == session_id,
PositionRoundtableCommandRow.node_key == (node_key or ""),
PositionRoundtableCommandRow.command_id == command_id,
)
)
).scalars().first()
if existing is not None:
if existing.payload_hash != payload_hash:
return {
"ok": False,
"http_status": 409,
"detail": "相同操作标识携带了不同参数,已拒绝执行",
}
return {
"ok": True,
"replayed": True,
"command_type": command_type,
**(_loads(existing.result_json) or {}),
}
raise
# ── command handlers(全部在 FOR UPDATE 锁内调用,入参为锁定的行) ─────────
def _apply_complete(
self,
by_key: dict[str, PositionRoundtableNodeRow],
node_key: str,
parent: PositionRoundtableSessionRow,
payload: dict[str, Any],
now: datetime,
) -> dict[str, Any]:
"""complete-turn:锁内判定能否完成 / 是否新交付,原子落状态机。"""
current = by_key.get(node_key)
if current is None:
return {"ok": False, "http_status": 404, "detail": "Position roundtable node not found"}
if current.status == "locked":
return {"ok": False, "http_status": 409, "detail": "上游节点尚未完成,当前节点未解锁"}
if current.invalidated_by:
return {
"ok": False,
"http_status": 409,
"detail": "上游产物已被驳回或重做,当前节点需等待前序节点重新完成",
}
# 请求未带产物清单 → 沿用锁内的当前清单(绝不用调用方的过期快照兜底)。
incoming = payload.get("artifact_manifest")
current_manifest = _loads(current.artifact_manifest) or []
effective_artifacts = incoming if isinstance(incoming, list) and incoming else current_manifest
latest_answer = payload.get("latest_answer")
upstream_revision = payload.get("upstream_revision")
has_markdown = any(is_markdown_artifact(item) for item in effective_artifacts)
markdown_changed = markdown_delivery_changed(current_manifest, effective_artifacts)
requires_fresh_delivery = current.status in {"rejected", "stale"}
intent_position_id = session_intent_position_id(_loads(parent.chain_snapshot))
is_intent_node = is_intent_position_node(current.position_id, intent_position_id)
next_status = (
"done"
if is_intent_node
else "done"
if has_markdown and (not requires_fresh_delivery or markdown_changed)
else "running"
)
# 有效 revision 是「交付 revision」,不是每一次对话应答。已完成节点上的普通
# 追问,不能仅因其旧 Markdown 仍出现在清单里就失效所有下游 stage。
new_effective_delivery = (
next_status == "done"
and (is_intent_node or current.status != "done" or markdown_changed)
)
old_revision = current.revision
# ④ 落本节点交付状态。
current.status = next_status
current.latest_answer = latest_answer
current.artifact_manifest = _dumps(effective_artifacts)
if upstream_revision is not None:
current.upstream_revision = _dumps(upstream_revision)
current.invalidated_by = None
if new_effective_delivery:
current.revision += 1
current.version += 1
current.updated_at = now
self._invalidate_downstream(by_key.values(), current, new_effective_delivery, old_revision, now)
self._recompute_unlocks(by_key.values(), now)
return {"ok": True, "node": self._node_dict(current)}
def _apply_bind(
self,
by_key: dict[str, PositionRoundtableNodeRow],
node_key: str,
payload: dict[str, Any],
now: datetime,
) -> dict[str, Any]:
"""bind-thread:锁内校验解锁/失效/线程占用后绑定。"""
current = by_key.get(node_key)
if current is None:
return {"ok": False, "http_status": 404, "detail": "Position roundtable node not found"}
thread_id = str(payload.get("thread_id") or "").strip()
if not thread_id:
return {"ok": False, "http_status": 400, "detail": "thread_id is required"}
if current.status == "locked":
return {"ok": False, "http_status": 409, "detail": "上游节点尚未完成,当前节点未解锁"}
if current.invalidated_by:
return {
"ok": False,
"http_status": 409,
"detail": "上游产物已被驳回或重做,当前节点需等待前序节点重新完成",
}
if current.thread_id and current.thread_id != thread_id:
return {"ok": False, "http_status": 409, "detail": "该节点已绑定另一条对话线程"}
current.thread_id = thread_id
if current.status == "ready":
current.status = "running"
current.version += 1
current.updated_at = now
return {"ok": True, "node": self._node_dict(current)}
def _apply_conversation(
self,
by_key: dict[str, PositionRoundtableNodeRow],
node_key: str,
payload: dict[str, Any],
now: datetime,
) -> dict[str, Any]:
"""conversation:对话快照持久化(UI 恢复用),锁内校验线程归属。"""
current = by_key.get(node_key)
if current is None:
return {"ok": False, "http_status": 404, "detail": "Position roundtable node not found"}
thread_id = payload.get("thread_id")
if thread_id and current.thread_id and current.thread_id != thread_id:
return {"ok": False, "http_status": 409, "detail": "Position node is bound to another thread"}
if thread_id and not current.thread_id:
current.thread_id = thread_id
current.conversation_snapshot = _dumps(payload.get("conversation_snapshot"))
current.version += 1
current.updated_at = now
return {"ok": True, "node": self._node_dict(current)}
def _apply_reject(
self,
by_key: dict[str, PositionRoundtableNodeRow],
node_key: str,
parent: PositionRoundtableSessionRow,
payload: dict[str, Any],
user_id: str,
now: datetime,
) -> dict[str, Any]:
"""reject:驳回一个已交付节点并失效全部下游 stage(锁内判定可驳回性)。"""
rows = sorted(
by_key.values(), key=lambda r: (r.stage_index, r.seat_index)
)
target = by_key.get(node_key)
if target is None:
return {"ok": False, "http_status": 404, "detail": "Position roundtable node not found"}
if target.status == "locked":
return {"ok": False, "http_status": 409, "detail": "当前节点尚未解锁,暂无可驳回的交付产物"}
if (
target.revision <= 0
and not target.latest_answer
and not _loads(target.artifact_manifest)
):
return {"ok": False, "http_status": 409, "detail": "当前节点暂无可驳回的交付产物"}
rejection = {
"title": str(payload.get("title") or "").strip() or "产物驳回",
"reason": str(payload.get("reason") or "").strip(),
"rejected_by": user_id,
"rejected_at": now.isoformat(),
}
summary_snapshot = _loads(parent.summary_snapshot)
if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
parent.summary_snapshot = _dumps(
{
**summary_snapshot,
"status": "stale",
"invalidated_by": target.node_key,
"invalidated_at": now.isoformat(),
}
)
action_plan_snapshot = _loads(parent.action_plan_snapshot)
if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
parent.action_plan_snapshot = _dumps(
{
**action_plan_snapshot,
"status": "stale",
"invalidated_by": target.node_key,
"invalidated_at": now.isoformat(),
}
)
if parent.status == "completed":
parent.status = "active"
target.status = "rejected"
target.last_rejection = _dumps(rejection)
target.rejection_count += 1
target.invalidated_by = None
target.version += 1
target.updated_at = now
for row in rows:
if row.stage_index <= target.stage_index:
continue
if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
row.status = "stale"
else:
row.status = "locked"
row.invalidated_by = target.node_key
row.version += 1
row.updated_at = now
return {
"ok": True,
"session": self._session_dict(parent),
"nodes": [self._node_dict(row) for row in rows],
}
@staticmethod
def _invalidate_downstream(
rows: Any,
current: PositionRoundtableNodeRow,
new_effective_delivery: bool,
old_revision: int,
now: datetime,
) -> None:
"""新交付且本节点此前已交付过 → 后续 stage 全部失效(需重做)。"""
if not (new_effective_delivery and old_revision > 0):
return
for downstream in rows:
if (
downstream.stage_index > current.stage_index
and downstream.status in {"ready", "running", "done", "stale", "rejected", "error"}
):
downstream.status = "stale" if _has_material(downstream) else "locked"
downstream.invalidated_by = current.node_key
downstream.version += 1
downstream.updated_at = now
@staticmethod
def _recompute_unlocks(rows: Any, now: datetime) -> None:
"""幂等重算 stage 解锁:每个「全部 done」stage 解锁其紧邻下一 stage。
locked → ready;stale 保持 stale(产物过期需重做)但清掉 invalidated_by
(上游已就绪)。多次调用结果一致,不依赖调用时序。
"""
row_list = list(rows)
stage_indices = sorted({r.stage_index for r in row_list})
for stage in stage_indices:
stage_rows = [r for r in row_list if r.stage_index == stage]
if not stage_rows or not all(r.status == "done" for r in stage_rows):
continue
later = [t for t in stage_indices if t > stage]
if not later:
continue
next_stage = min(later)
for node in row_list:
if node.stage_index != next_stage:
continue
if node.status == "locked":
node.status = "ready"
node.invalidated_by = None
node.version += 1
node.updated_at = now
elif node.status == "stale":
node.invalidated_by = None
node.version += 1
node.updated_at = now
async def apply_session_command(
self,
session_id: str,
user_id: str,
*,
command_id: str,
command_type: str,
payload: dict[str, Any] | None = None,
expected_session_version: int | None = None,
) -> dict[str, Any]:
"""session 级命令(activate / archive / delete)的命令式入口。
与 ``apply_node_command`` 同一套原则:单事务锁 session 行(``FOR UPDATE``)
+ 命令唯一键幂等 + 锁内判定。返回结构同 ``apply_node_command``
(``ok`` / ``replayed`` / ``http_status`` / ``detail`` / ``session`` / ``nodes``)。
- ``activate`` — payload 需带 ``chain_id`` / ``chain_snapshot`` / ``nodes``。
锁内判定:已激活且链相同 → 幂等返回现有状态;链不同 → 409。
- ``archive`` — 已归档 → 幂等返回;否则置 archived。
- ``delete`` — 删除节点与会话(命令记录无外键,保留供重放)。
"""
payload = payload or {}
payload_hash = _payload_hash(command_type, payload)
now = datetime.now(UTC)
async def _replay_check(session: AsyncSession) -> dict[str, Any] | None:
existing = (
await session.execute(
select(PositionRoundtableCommandRow).where(
PositionRoundtableCommandRow.session_id == session_id,
PositionRoundtableCommandRow.node_key == "",
PositionRoundtableCommandRow.command_id == command_id,
)
)
).scalars().first()
if existing is None:
return None
if existing.payload_hash != payload_hash:
return {
"ok": False,
"http_status": 409,
"detail": "相同操作标识携带了不同参数,已拒绝执行",
}
return {
"ok": True,
"replayed": True,
"command_type": command_type,
**(_loads(existing.result_json) or {}),
}
async def _run(session: AsyncSession) -> dict[str, Any]:
# delete 之后 session 行已不存在,重试必须在锁 session 之前查命令记录。
replayed = await _replay_check(session)
if replayed is not None:
return replayed
parent = (
await session.execute(
select(PositionRoundtableSessionRow)
.where(PositionRoundtableSessionRow.id == session_id)
.with_for_update()
)
).scalar_one_or_none()
if parent is None or not self._can_access(parent, user_id):
return {"ok": False, "http_status": 404, "detail": "Position roundtable session not found"}
if expected_session_version is not None and parent.version != expected_session_version:
return {
"ok": False,
"http_status": 409,
"detail": "会话状态已更新,请刷新后重试",
"session": self._session_dict(parent),
}
if command_type == "activate":
result = await self._apply_activate(session, parent, payload, now)
elif command_type == "archive":
if parent.status == "archived":
result = {
"ok": True,
"session": self._session_dict(parent),
"nodes": [],
"already": True,
}
else:
parent.status = "archived"
parent.version += 1
parent.updated_at = now
result = {
"ok": True,
"session": self._session_dict(parent),
"nodes": [],
}
elif command_type == "delete":
await session.execute(
delete(PositionRoundtableNodeRow).where(
PositionRoundtableNodeRow.session_id == session_id
)
)
await session.execute(
delete(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.id == session_id
)
)
result = {"ok": True, "deleted": True}
else:
return {"ok": False, "http_status": 400, "detail": f"unknown command type: {command_type}"}
if not result.get("ok", True):
return result
stored = {k: v for k, v in result.items() if k != "ok"}
session.add(
PositionRoundtableCommandRow(
id=uuid4().hex,
session_id=session_id,
node_key="",
command_id=command_id,
command_type=command_type,
payload_hash=payload_hash,
result_json=_dumps(stored),
created_at=now,
)
)
result["replayed"] = False
result["command_type"] = command_type
return result
try:
async with self._sf() as session:
outcome = await _run(session)
if outcome.get("ok", False):
await session.commit()
else:
await session.rollback()
return outcome
except IntegrityError:
# 命令唯一键并发竞态:读回已提交的那条命令结果重放。
async with self._sf() as session:
replayed = await _replay_check(session)
if replayed is not None:
return replayed
raise
async def _apply_activate(
self,
session: AsyncSession,
parent: PositionRoundtableSessionRow,
payload: dict[str, Any],
now: datetime,
) -> dict[str, Any]:
"""激活业务链:锁内判定「已激活 → 链相同幂等 / 链不同 409」,否则建节点。"""
existing_rows = (
await session.execute(
select(PositionRoundtableNodeRow)
.where(PositionRoundtableNodeRow.session_id == parent.id)
.order_by(
PositionRoundtableNodeRow.stage_index,
PositionRoundtableNodeRow.seat_index,
)
.with_for_update()
)
).scalars().all()
if parent.status == "archived":
return {
"ok": False,
"http_status": 409,
"detail": "该岗位协同会话已归档,仅可查看历史记录",
}
chain_id = str(payload.get("chain_id") or "")
if existing_rows:
# 已激活:同一业务链 → 幂等返回现有状态;不同链 → 冲突。
if parent.chain_id == chain_id:
return {
"ok": True,
"session": self._session_dict(parent),
"nodes": [self._node_dict(row) for row in existing_rows],
"already": True,
}
return {
"ok": False,
"http_status": 409,
"detail": "该会话已激活其他业务链条,不能重复激活",
}
if not _has_confirmed_intent_snapshot(_loads(parent.intent_snapshot)):
return {
"ok": False,
"http_status": 400,
"detail": "请先完成并保存任务意图",
}
chain_snapshot = payload.get("chain_snapshot") or {}
nodes = payload.get("nodes") or []
if not nodes:
return {"ok": False, "http_status": 400, "detail": "activate requires nodes"}
parent.chain_id = chain_id
parent.chain_snapshot = _dumps(chain_snapshot)
parent.status = "active"
parent.version += 1
parent.updated_at = now
node_rows = [
PositionRoundtableNodeRow(
id=uuid4().hex,
session_id=parent.id,
node_key=str(node["node_key"]),
stage_index=int(node["stage_index"]),
seat_index=int(node["seat_index"]),
agent_id=str(node["agent_id"]),
position_id=node.get("position_id") or None,
status=str(node.get("status") or "locked"),
latest_answer=node.get("latest_answer") or None,
artifact_manifest=_dumps(node.get("artifact_manifest") or []),
revision=int(node.get("revision") or 0),
upstream_revision=(
_dumps(node.get("upstream_revision"))
if node.get("upstream_revision") is not None
else None
),
created_at=now,
updated_at=now,
)
for node in nodes
]
session.add_all(node_rows)
# 同事务内 flush 拿到完整行状态,供命令记录与返回值使用。
await session.flush()
return {
"ok": True,
"session": self._session_dict(parent),
"nodes": [self._node_dict(row) for row in node_rows],
}
async def reject_node(
self,
session_id: str,
@@ -27,8 +27,14 @@ class RoundtableArtifactSubmissionStore(ABC):
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any]:
"""Create or update the idempotent submission record for an artifact."""
"""Create or update the idempotent submission record for an artifact.
When *expected_version* is given it must match the current row version;
a mismatch indicates another writer committed first and the caller
should re-read and retry (HTTP 409).
"""
raise NotImplementedError
@abstractmethod
@@ -39,6 +45,7 @@ class RoundtableArtifactSubmissionStore(ABC):
*,
submission_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any] | None:
"""Mark a submission as cancelled without deleting its audit record."""
raise NotImplementedError
@@ -54,7 +54,10 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any]:
from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict
key = submission_key(task_id, artifact)
row = self._rows.get((task_id, key))
values = _artifact_values(artifact)
@@ -67,6 +70,7 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
**values,
"task_delivery_id": task_delivery_id,
"submitted": True,
"version": 0,
"submitted_at": now,
"cancelled_at": None,
"created_by": actor_id,
@@ -79,6 +83,9 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
self._rows[(task_id, key)] = row
return deepcopy(row)
if expected_version is not None and row.get("version", 0) != expected_version:
raise SubmissionVersionConflict(deepcopy(row))
changed = (
not row["submitted"]
or row.get("task_delivery_id") != task_delivery_id
@@ -91,6 +98,7 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
{
"task_delivery_id": task_delivery_id,
"submitted": True,
"version": row.get("version", 0) + 1,
"submitted_at": now,
"cancelled_at": None,
"updated_by": actor_id,
@@ -108,16 +116,24 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
*,
submission_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any] | None:
from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict
key = submission_key(task_id, artifact)
row = self._rows.get((task_id, key))
if row is None or (submission_id is not None and row["id"] != submission_id):
return None
if expected_version is not None and row.get("version", 0) != expected_version:
raise SubmissionVersionConflict(deepcopy(row))
if row["submitted"]:
now = _now()
row.update(
{
"submitted": False,
"version": row.get("version", 0) + 1,
"cancelled_at": now,
"updated_by": actor_id,
"cancelled_by": actor_id,
@@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import UTC, datetime
import sqlalchemy as sa
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
@@ -41,6 +42,9 @@ class RoundtableArtifactSubmissionRow(Base):
task_delivery_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Phase 5 CAS: 每次状态变更 +1。submit/cancel 可带 expected_version 做乐观锁,
# FOR UPDATE + version 检查确保「旧 cancel 不得撤销新提交」。
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=sa.text("0"))
submitted_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -15,6 +15,21 @@ from deerflow.persistence.roundtable_artifact_submissions.memory import submissi
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
class SubmissionVersionConflict(Exception):
"""Stale ``expected_version`` — another writer committed first.
Carries the freshest row dict so the router can return it in the 409 body,
letting the client merge and retry.
"""
def __init__(self, row: dict[str, Any]) -> None:
self.row = row
super().__init__(
f"submission version conflict: expected_version does not match "
f"current version={row.get('version')}"
)
def _timestamp(value: datetime | None) -> str | None:
return value.isoformat() if isinstance(value, datetime) else value
@@ -34,6 +49,7 @@ def _to_dict(row: RoundtableArtifactSubmissionRow) -> dict[str, Any]:
"revision": row.revision,
"task_delivery_id": row.task_delivery_id,
"submitted": row.submitted,
"version": row.version,
"submitted_at": _timestamp(row.submitted_at),
"cancelled_at": _timestamp(row.cancelled_at),
"created_by": row.created_by,
@@ -92,34 +108,63 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any]:
key = submission_key(task_id, artifact)
stmt = select(RoundtableArtifactSubmissionRow).where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
now = datetime.now(UTC)
created = row is None
if row is None:
row = RoundtableArtifactSubmissionRow(
id=uuid4().hex,
task_id=task_id,
idempotency_key=key,
task_delivery_id=task_delivery_id,
submitted=True,
submitted_at=now,
created_by=actor_id,
updated_by=actor_id,
submitted_by=actor_id,
created_at=now,
updated_at=now,
# 至多重试一次:第一轮 INSERT 遇唯一键冲突 → 第二轮 FOR UPDATE 读回做 CAS 更新。
for _attempt in range(2):
async with self._sf() as session:
result = await session.execute(
select(RoundtableArtifactSubmissionRow)
.where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
.with_for_update()
)
_apply_artifact(row, artifact)
session.add(row)
elif _requires_submission_update(row, artifact, task_delivery_id):
row = result.scalars().first()
now = datetime.now(UTC)
if row is None:
# 新提交:INSERT,version=0。
row = RoundtableArtifactSubmissionRow(
id=uuid4().hex,
task_id=task_id,
idempotency_key=key,
task_delivery_id=task_delivery_id,
submitted=True,
submitted_at=now,
created_by=actor_id,
updated_by=actor_id,
submitted_by=actor_id,
created_at=now,
updated_at=now,
version=0,
)
_apply_artifact(row, artifact)
session.add(row)
try:
await session.commit()
except IntegrityError:
# 并发 INSERT 赢家已写入,rollback 后重试 FOR UPDATE 读回。
await session.rollback()
continue
await session.refresh(row)
return _to_dict(row)
# 行已存在 —— CAS 检查:expected_version 不匹配说明在此期间另一个
# 写入已提交(如新 submit 把 version 从 0 bump 到 1),旧 cancel 的
# expected_version=0 必须失败,不得撤销新提交。
# 注意:不调 session.rollback()——rollback 会 expire 所有 ORM 对象,
# 导致 _to_dict 访问属性时触发 lazy-load → MissingGreenlet。
# async with 退出时会自动回滚未提交事务并释放 FOR UPDATE 锁。
if expected_version is not None and row.version != expected_version:
raise SubmissionVersionConflict(_to_dict(row))
if not _requires_submission_update(row, artifact, task_delivery_id):
# 幂等:内容无变化,不 bump version。
return _to_dict(row)
_apply_artifact(row, artifact)
row.task_delivery_id = task_delivery_id
row.submitted = True
@@ -129,34 +174,12 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
row.submitted_by = actor_id
row.cancelled_by = None
row.updated_at = now
try:
row.version = row.version + 1
await session.commit()
except IntegrityError:
# Two callers can both observe a missing row. The unique key
# turns that race into a normal idempotent retry instead of a
# 500. If their non-key metadata differs, apply this request
# after loading the winner so PUT still has upsert semantics.
await session.rollback()
if not created:
raise
result = await session.execute(stmt)
row = result.scalars().first()
if row is None:
raise
if _requires_submission_update(row, artifact, task_delivery_id):
now = datetime.now(UTC)
_apply_artifact(row, artifact)
row.task_delivery_id = task_delivery_id
row.submitted = True
row.submitted_at = now
row.cancelled_at = None
row.updated_by = actor_id
row.submitted_by = actor_id
row.cancelled_by = None
row.updated_at = now
await session.commit()
await session.refresh(row)
return _to_dict(row)
await session.refresh(row)
return _to_dict(row)
raise RuntimeError("submit: retry loop exhausted (unexpected)")
async def cancel(
self,
@@ -165,24 +188,36 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
*,
submission_id: str | None = None,
actor_id: str | None = None,
expected_version: int | None = None,
) -> dict[str, Any] | None:
key = submission_key(task_id, artifact)
stmt = select(RoundtableArtifactSubmissionRow).where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
async with self._sf() as session:
result = await session.execute(stmt)
result = await session.execute(
select(RoundtableArtifactSubmissionRow)
.where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
.with_for_update()
)
row = result.scalars().first()
if row is None or (submission_id is not None and row.id != submission_id):
return None
if row.submitted:
now = datetime.now(UTC)
row.submitted = False
row.cancelled_at = now
row.updated_by = actor_id
row.cancelled_by = actor_id
row.updated_at = now
await session.commit()
await session.refresh(row)
if expected_version is not None and row.version != expected_version:
raise SubmissionVersionConflict(_to_dict(row))
if not row.submitted:
# 幂等:已取消,不 bump version。
return _to_dict(row)
now = datetime.now(UTC)
row.submitted = False
row.cancelled_at = now
row.updated_by = actor_id
row.cancelled_by = actor_id
row.updated_at = now
row.version = row.version + 1
await session.commit()
await session.refresh(row)
return _to_dict(row)
@@ -47,6 +47,9 @@ class RoundtableDraftRow(Base):
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# 乐观并发控制版本号:每次 update_draft 自增;传入 expected_version 时校验,命中
# 0 行即并发冲突(防 step2.runs 读-改-写丢失更新——后台作业与前台 PUT 并发)。
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
class RoundtableRecommendHistoryRow(Base):
@@ -15,7 +15,7 @@ import re
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy import case, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_drafts.model import (
@@ -66,6 +66,23 @@ def _dumps(value: Any) -> str | None:
return json.dumps(value, ensure_ascii=False)
class DraftConcurrentWriteError(Exception):
"""乐观锁冲突:``update_draft(expected_version=...)`` 时版本号不匹配。
由后台作业 ``_write_run_to_draft`` 的读-改-写重试循环捕获:冲突时重新读取最新草稿、
重新合并 ``step2.runs[]``、再试。防多用户/多 worker 并发写同一草稿时后写覆盖先写。
"""
def __init__(self, draft_id: str, *, expected: int | None = None, actual: int | None = None) -> None:
self.draft_id = draft_id
self.expected = expected
self.actual = actual
super().__init__(
f"concurrent write detected on roundtable draft {draft_id} "
f"(expected version {expected}, got {actual})"
)
class RoundtableDraftRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@@ -80,6 +97,8 @@ class RoundtableDraftRepository:
"task_id": row.task_id,
"title": row.title,
"furthest_step": row.furthest_step,
# 乐观锁版本号:列表也携带,供前端本地副本对账(冲突时重拉合并)。
"version": row.version,
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
"chain_title": _extract_chain_title(row.step2),
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
@@ -145,26 +164,60 @@ class RoundtableDraftRepository:
step2=_UNSET,
step3=_UNSET,
task_id=_UNSET,
expected_version: int | None = None,
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RoundtableDraftRow, draft_id)
"""原子条件 UPDATE:版本判定与字段写入发生在**同一条 SQL** 里。
一致性只来自数据库:``UPDATE ... WHERE id AND user_id AND version=`` 配合
rowcount 判定,多 worker 并发下不存在「读-改-写」的检查后竞争窗口。
``rowcount == 0`` 时再回读区分「不存在/非本人」(返回 None)与
「版本冲突」(抛 :class:`DraftConcurrentWriteError`)。
"""
values: dict[str, Any] = {
"version": RoundtableDraftRow.version + 1,
"updated_at": datetime.now(UTC),
}
if task_id is not _UNSET and task_id is not None:
values["task_id"] = str(task_id)
if title is not _UNSET and title is not None:
values["title"] = str(title).strip()
if furthest_step is not _UNSET and furthest_step is not None:
# furthest_step 单调不回退:迟到的自动保存不得把已推进的步骤拉回去。
step_value = int(furthest_step)
values["furthest_step"] = case(
(RoundtableDraftRow.furthest_step >= step_value, RoundtableDraftRow.furthest_step),
else_=step_value,
)
if step1 is not _UNSET:
values["step1"] = _dumps(step1)
if step2 is not _UNSET:
values["step2"] = _dumps(step2)
if step3 is not _UNSET:
values["step3"] = _dumps(step3)
where = [
RoundtableDraftRow.id == draft_id,
# 个人草稿严格按 user 隔离(task 聊天记录已迁出到独立存储)。
if row is None or row.user_id != user_id:
RoundtableDraftRow.user_id == user_id,
]
if expected_version is not None:
where.append(RoundtableDraftRow.version == expected_version)
async with self._sf() as session:
result = await session.execute(
update(RoundtableDraftRow).where(*where).values(**values)
)
if result.rowcount == 0:
if expected_version is not None:
# 回读区分冲突与不存在(回读在冲突分支,正常路径零额外开销)。
existing = await session.get(RoundtableDraftRow, draft_id)
if existing is not None and existing.user_id == user_id:
raise DraftConcurrentWriteError(
draft_id, expected=expected_version, actual=existing.version
)
return None
if task_id is not _UNSET and task_id is not None:
row.task_id = str(task_id)
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if furthest_step is not _UNSET and furthest_step is not None:
row.furthest_step = int(furthest_step)
if step1 is not _UNSET:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
row.updated_at = datetime.now(UTC)
await session.commit()
row = await session.get(RoundtableDraftRow, draft_id)
await session.refresh(row)
return self._draft_to_dict(row)
@@ -19,7 +19,8 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Integer, String, Text
import sqlalchemy as sa
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -28,6 +29,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class RoundtableJobRow(Base):
__tablename__ = "roundtable_jobs"
__table_args__ = (
# 「同一 scope 至多一个活跃作业」由该唯一键强制(Phase 3):活跃时存
# sha256(draft_id + task_id|user_id),进入终态时清 NULL;MySQL/SQLite 唯一索引
# 都允许多个 NULL,故终态历史作业不占键。INSERT 冲突即幂等命中 —— 一致性来自
# DB 唯一键,不再依赖 GET_LOCK 命名锁。
UniqueConstraint("active_dedupe_key", name="uq_roundtable_jobs_active_dedupe_key"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# The draft/session this job runs for. Indexed for "active job of draft" lookups.
@@ -37,6 +45,24 @@ class RoundtableJobRow(Base):
# **不按 user 分权**(任何人打开同一 task 都能看在跑的研讨);其 draft_id 指向独立的
# ``roundtable_task_drafts`` 表。为空 = 普通个人作业(按 user 分权)。
task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
# ── Phase 3:唯一活跃键 + 租约 + dispatcher ──────────────────────────────
# 前端启动请求的幂等键(同键重试返回同一作业)与入参指纹(审计/排查)。
request_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
request_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 活跃去重键:活跃(非终态)时 = sha256(draft_id + task_id|user_id),终态清 NULL。
# 唯一索引(__table_args__)保证「同一 scope 至多一个活跃作业」——INSERT 冲突即幂等。
active_dedupe_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 启动入参完整 JSON 快照:dispatcher 在任意 worker 领取 queued/租约过期作业时
# 据此重建 StartParams(跨进程可接管的前提;resume 续跑也重写本列)。
input_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 租约:条件 UPDATE 抢占(queued 或租约过期)+ 心跳续租;worker 崩溃后租约自然过期,
# 其它 worker 接管。恢复能力来自 DB 而非进程内存。
lease_owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
lease_until: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
# 被 dispatcher 领取执行的次数(接管计数)。
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=sa.text("0"))
# 行级 CAS 版本:条件 UPDATE 乐观锁,防止并发写互相覆盖。
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=sa.text("0"))
# Job status machine (§2.1): queued / running / awaiting_input / done / error / cancelled.
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True)
# Fine-grained phase (§2.2): initializing / leader_thinking / dispatching /
@@ -71,6 +97,11 @@ class RoundtableJobRow(Base):
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Terminal error detail when status=error.
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# 草稿回写 pending 标记:``_write_run_to_draft`` 乐观锁重试耗尽后置 1(绝不退化
# last-write-wins 盲写);由 outbox 补偿器投影结果到草稿后清 0。一致性来自 DB 标记。
draft_persist_pending: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=sa.false()
)
# 时间列必须用 BeijingDateTime(项目规范,与 ai_writing_sessions / roundtable_drafts 对齐)。
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
@@ -12,11 +12,14 @@ threading a user id through, while gateway routes pass it explicitly.
from __future__ import annotations
import hashlib
import json
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy import delete, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
@@ -24,8 +27,36 @@ from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
_UNSET = object()
logger = logging.getLogger(__name__)
# 终态:列表/清理判定用。
TERMINAL_STATUSES = ("done", "error", "cancelled")
# 非终态(活跃):唯一活跃键去重 / 领取判定用。
ACTIVE_STATUSES = ("queued", "running", "awaiting_input")
# cancel 可命中的状态(queued/running/awaiting_input → cancel_requested)。
CANCELLABLE_STATUSES = ("queued", "running", "awaiting_input")
def compute_active_dedupe_key(
draft_id: str | None, user_id: str | None, task_id: str | None
) -> str | None:
"""活跃去重键:``sha256(scope)``。scope = draft_id + (task_id | user_id)。
- task 作业(task_id 非空):按 (draft_id, task_id) 去重 —— 跨用户共享;
- 普通作业:按 (draft_id, user_id) 去重;
- draft_id 为空 → 无法去重,返回 None(直接创建,保持原行为)。
该键写入 ``active_dedupe_key`` 唯一索引:作业活跃时占用,进入终态时清 NULL
(MySQL/SQLite 唯一索引都允许多个 NULL)——「同一 scope 至多一个活跃作业」
由 DB 唯一键强制,INSERT 冲突即幂等命中。
"""
if not draft_id:
return None
if task_id:
scope = f"task:{draft_id}:{task_id}"
else:
scope = f"user:{draft_id}:{user_id or ''}"
return hashlib.sha256(scope.encode("utf-8")).hexdigest()
def _loads(value: Any) -> Any:
@@ -52,15 +83,72 @@ class RoundtableJobRepository:
@staticmethod
def _row_to_dict(row: RoundtableJobRow) -> dict[str, Any]:
d = row.to_dict()
for k in ("created_at", "updated_at"):
for k in ("created_at", "updated_at", "lease_until"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
for k in ("dispatch_chain", "thread_ids", "chain", "orchestration_plan", "dialogues", "step3"):
for k in (
"dispatch_chain",
"thread_ids",
"chain",
"orchestration_plan",
"dialogues",
"step3",
"input_snapshot",
):
d[k] = _loads(d.get(k))
return d
# ── create ────────────────────────────────────────────────────────────
@staticmethod
def _make_row(
*,
id: str,
resolved_user_id: str | None,
draft_id: str | None,
task_id: str | None,
orchestration_mode: str,
coordinator_name: str | None,
thread_ids: dict[str, str] | None,
chain: dict[str, Any] | None,
orchestration_plan: dict[str, Any] | None,
dispatch_chain: list[dict[str, Any]] | None,
status: str,
phase: str,
request_id: str | None = None,
request_hash: str | None = None,
active_dedupe_key: str | None = None,
input_snapshot: dict[str, Any] | None = None,
) -> RoundtableJobRow:
now = datetime.now(UTC)
return RoundtableJobRow(
id=id,
draft_id=draft_id,
user_id=resolved_user_id,
task_id=(task_id or None),
request_id=(request_id or None),
request_hash=(request_hash or None),
active_dedupe_key=(active_dedupe_key or None),
input_snapshot=_dumps(input_snapshot),
status=status,
phase=phase,
cycle=0,
dispatch_chain=_dumps(dispatch_chain),
active_agent_id=None,
consensus_percentage=0,
pending_clarification=None,
thread_ids=_dumps(thread_ids),
coordinator_name=coordinator_name,
orchestration_mode=orchestration_mode,
chain=_dumps(chain),
orchestration_plan=_dumps(orchestration_plan),
error=None,
attempt=0,
version=0,
created_at=now,
updated_at=now,
)
async def create(
self,
*,
@@ -78,27 +166,19 @@ class RoundtableJobRepository:
phase: str = "initializing",
) -> dict[str, Any]:
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.create")
now = datetime.now(UTC)
row = RoundtableJobRow(
row = self._make_row(
id=id,
resolved_user_id=resolved_user_id,
draft_id=draft_id,
user_id=resolved_user_id,
task_id=(task_id or None),
task_id=task_id,
orchestration_mode=orchestration_mode,
coordinator_name=coordinator_name,
thread_ids=thread_ids,
chain=chain,
orchestration_plan=orchestration_plan,
dispatch_chain=dispatch_chain,
status=status,
phase=phase,
cycle=0,
dispatch_chain=_dumps(dispatch_chain),
active_agent_id=None,
consensus_percentage=0,
pending_clarification=None,
thread_ids=_dumps(thread_ids),
coordinator_name=coordinator_name,
orchestration_mode=orchestration_mode,
chain=_dumps(chain),
orchestration_plan=_dumps(orchestration_plan),
error=None,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
@@ -106,6 +186,102 @@ class RoundtableJobRepository:
await session.refresh(row)
return self._row_to_dict(row)
async def try_create_or_get_active(
self,
*,
draft_id: str | None,
user_id: str | None | _AutoSentinel,
task_id: str | None,
id: str,
orchestration_mode: str = "recommend",
coordinator_name: str | None = None,
thread_ids: dict[str, str] | None = None,
chain: dict[str, Any] | None = None,
orchestration_plan: dict[str, Any] | None = None,
dispatch_chain: list[dict[str, Any]] | None = None,
status: str = "queued",
phase: str = "initializing",
request_id: str | None = None,
request_hash: str | None = None,
input_snapshot: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], bool]:
"""原子地「创建活跃作业 → 冲突则幂等返回已有活跃作业」(Phase 3 唯一活跃键版)。
一致性来自 DB **唯一索引** ``uq_roundtable_jobs_active_dedupe_key``:活跃作业占用
``active_dedupe_key = sha256(draft_id + task_id|user_id)``,进入终态时清 NULL。于是
「同一 scope 至多一个活跃作业」由唯一键强制——直接 INSERT,冲突(IntegrityError)即
说明已有活跃作业,读回它幂等返回(``created=False``)。**不再依赖 GET_LOCK 命名锁**:
唯一键在 MySQL / PostgreSQL / SQLite 上语义一致,跨进程/跨 worker 天然互斥。
极小竞态兜底:INSERT 冲突后读回时若胜者恰好已进终态(键已释放),则重试一次 INSERT。
返回 ``(job_dict, created)``。``draft_id`` 为空时无法去重,直接创建(保持原行为)。
"""
resolved_user_id = resolve_user_id(
user_id, method_name="RoundtableJobRepository.try_create_or_get_active"
)
dedupe_key = compute_active_dedupe_key(draft_id, resolved_user_id, task_id)
def _build_row() -> RoundtableJobRow:
return self._make_row(
id=id,
resolved_user_id=resolved_user_id,
draft_id=draft_id,
task_id=task_id,
orchestration_mode=orchestration_mode,
coordinator_name=coordinator_name,
thread_ids=thread_ids,
chain=chain,
orchestration_plan=orchestration_plan,
dispatch_chain=dispatch_chain,
status=status,
phase=phase,
request_id=request_id,
request_hash=request_hash,
active_dedupe_key=dedupe_key,
input_snapshot=input_snapshot,
)
# 无 draft_id → 无法去重,直接创建(保持原有行为)。
if dedupe_key is None:
row = _build_row()
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row), True
for _attempt in range(2):
try:
async with self._sf() as session:
row = _build_row()
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row), True
except IntegrityError:
pass # 唯一键冲突 → 已有活跃作业(或极小竞态,见下)。
existing = await self._get_by_dedupe_key(dedupe_key)
if existing is not None:
return self._row_to_dict(existing), False
# 胜者在冲突与我们读回之间进了终态(键已释放)→ 重试 INSERT 可成功。
# 两次都冲突且读不回 —— 理论上不可达;保守读回/报错,绝不双建。
existing = await self._get_by_dedupe_key(dedupe_key)
if existing is not None:
return self._row_to_dict(existing), False
raise RuntimeError(
f"roundtable job dedupe conflict but no active row found for key {dedupe_key}"
)
async def _get_by_dedupe_key(self, dedupe_key: str) -> RoundtableJobRow | None:
"""按唯一活跃键读回占用该键的(活跃)作业行;无则 None。"""
stmt = select(RoundtableJobRow).where(
RoundtableJobRow.active_dedupe_key == dedupe_key
)
async with self._sf() as session:
return (await session.execute(stmt)).scalars().first()
# ── update progress ─────────────────────────────────────────────────────
async def update_progress(
@@ -125,8 +301,80 @@ class RoundtableJobRepository:
dialogues=_UNSET,
step3=_UNSET,
error=_UNSET,
lease_owner: str | None = None,
) -> dict[str, Any] | None:
"""更新进度字段。
**租约门(Phase 3)**:传入 ``lease_owner`` 时走**原子条件 UPDATE**——要求 DB 里
``lease_owner`` 匹配且状态不在 ``cancel_requested/cancelled``,否则返回 None(写被
拒)。这保证:① 只有持租者能推进度;② 一旦 cancel 落库(cancel_requested),迟到的
进度写不会覆盖取消意图;③ 多 worker 下非持租者的过期写直接失效。一致性来自 DB 条件
UPDATE,而非读后判断。
**终态处理**:写入 status ∈ 终态(done/error/cancelled)时,同时清空
``active_dedupe_key``(释放唯一活跃键,允许该 scope 再建作业)与租约字段。
不传 ``lease_owner`` 时保持原「按 user 分权 + 读改写」行为(向后兼容旧调用/测试)。
"""
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.update_progress")
# ── 租约门:原子条件 UPDATE ──
if lease_owner is not None:
values: dict[str, Any] = {"updated_at": datetime.now(UTC)}
if status is not _UNSET:
values["status"] = status
if phase is not _UNSET:
values["phase"] = phase
if cycle is not _UNSET:
values["cycle"] = int(cycle)
if dispatch_chain is not _UNSET:
values["dispatch_chain"] = _dumps(dispatch_chain)
if active_agent_id is not _UNSET:
values["active_agent_id"] = active_agent_id
if consensus_percentage is not _UNSET:
values["consensus_percentage"] = int(consensus_percentage)
if pending_clarification is not _UNSET:
values["pending_clarification"] = pending_clarification
if thread_ids is not _UNSET:
values["thread_ids"] = _dumps(thread_ids)
if coordinator_name is not _UNSET:
values["coordinator_name"] = coordinator_name
if dialogues is not _UNSET:
values["dialogues"] = _dumps(dialogues)
if step3 is not _UNSET:
values["step3"] = _dumps(step3)
if error is not _UNSET:
values["error"] = error
# 终态:释放唯一活跃键 + 清租约。
if status is not _UNSET and status in TERMINAL_STATUSES:
values["active_dedupe_key"] = None
values["lease_owner"] = None
values["lease_until"] = None
# awaiting_input:任务已暂停、执行协程退出 —— 必须释放租约,否则 resume 前
# 租约未过期没人能重新领取。活跃键保留(作业仍未终态,继续占着 scope)。
elif status is not _UNSET and status == "awaiting_input":
values["lease_owner"] = None
values["lease_until"] = None
values["version"] = RoundtableJobRow.version + 1
where = [
RoundtableJobRow.id == job_id,
RoundtableJobRow.lease_owner == lease_owner,
RoundtableJobRow.status.not_in(["cancel_requested", "cancelled"]),
]
if resolved_user_id is not None:
where.append(RoundtableJobRow.user_id == resolved_user_id)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
# ── 兼容路径:按 user 分权 + 读改写(不持租约的旧调用)──
async with self._sf() as session:
row = await session.get(RoundtableJobRow, job_id)
if row is None:
@@ -157,11 +405,319 @@ class RoundtableJobRepository:
row.step3 = _dumps(step3)
if error is not _UNSET:
row.error = error
# 终态同样要释放唯一活跃键(兼容路径没有租约也要清键)。
if status is not _UNSET and status in TERMINAL_STATUSES:
row.active_dedupe_key = None
row.lease_owner = None
row.lease_until = None
# awaiting_input 同样释放租约(与租约门路径一致)。
elif status is not _UNSET and status == "awaiting_input":
row.lease_owner = None
row.lease_until = None
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def mark_draft_persist_pending(self, job_id: str) -> bool:
"""把草稿回写 pending 标记置 1(``_write_run_to_draft`` 重试耗尽时调用)。
一致性来自 DB:绝不退化为 last-write-wins 盲写,而是留下可被 outbox 补偿器
(Phase 3)发现的标记。返回是否命中了作业行。
"""
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(RoundtableJobRow.id == job_id)
.values(draft_persist_pending=True, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
# ── Phase 3:租约领取 / 续租 / 释放(dispatcher) ────────────────────────
async def list_claimable(self, *, now: datetime, limit: int = 20) -> list[str]:
"""列出可领取的作业 id:``queued``(新作业/续跑入队)或 ``running`` 但租约已过期
(持租 worker 崩溃/卡死)。按 updated_at 升序(先来先领),最多 limit 个。
这只是「候选列表」;真正的领取靠 {@link claim_job} 的条件 UPDATE —— 多 worker 并发
扫到同一候选时,只有一个 UPDATE 能命中(一致性来自 DB)。
"""
stmt = (
select(RoundtableJobRow.id)
.where(
or_(
RoundtableJobRow.status == "queued",
(RoundtableJobRow.status == "running")
& (RoundtableJobRow.lease_until.is_not(None))
& (RoundtableJobRow.lease_until < now),
)
)
.order_by(RoundtableJobRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [r[0] for r in result.all()]
async def list_cancel_reapable(self, *, now: datetime, limit: int = 20) -> list[str]:
"""列出可以由 dispatcher 收口的取消请求。
``cancel_requested`` 是取消的权威意图,但持租 worker 可能在看门狗收口前崩溃。
仅当作业没有租约或租约已过期时才允许 dispatcher 无租约收口;仍持有有效租约的
作业继续由原 worker 的看门狗处理,避免抢占仍在正常退出的执行器。
"""
stmt = (
select(RoundtableJobRow.id)
.where(
RoundtableJobRow.status == "cancel_requested",
or_(
RoundtableJobRow.lease_owner.is_(None),
RoundtableJobRow.lease_until.is_(None),
RoundtableJobRow.lease_until < now,
),
)
.order_by(RoundtableJobRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [r[0] for r in result.all()]
async def claim_job(
self, job_id: str, *, lease_owner: str, lease_until: datetime
) -> dict[str, Any] | None:
"""原子条件 UPDATE 领取一个作业:仅当它仍是可领取状态时才成功。
成功条件(DB 保证,跨 worker 互斥):``status='queued'``,或 ``status='running'``
且租约已过期。成功则置 ``status='running'``、写 ``lease_owner/lease_until``、
``attempt+1``、``version+1``,返回行;否则返回 None(别的 worker 先领了 / 状态已变)。
"""
values = {
"status": "running",
"lease_owner": lease_owner,
"lease_until": lease_until,
"attempt": RoundtableJobRow.attempt + 1,
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
where = [
RoundtableJobRow.id == job_id,
or_(
RoundtableJobRow.status == "queued",
(RoundtableJobRow.status == "running")
& (RoundtableJobRow.lease_until.is_not(None))
& (RoundtableJobRow.lease_until < datetime.now(UTC)),
),
]
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
if row is None:
return None
# Phase 7 observability: attempt >= 2 means this job was previously
# claimed by another worker whose lease expired (crash / OOM / GC
# stall) and is now being reclaimed. This is the key signal for
# diagnosing worker instability under load.
if row.attempt >= 2:
logger.warning(
"roundtable_jobs lease takeover: job=%s reclaimed by %s "
"(attempt=%d — previous worker's lease expired)",
job_id, lease_owner, row.attempt,
)
return self._row_to_dict(row)
async def renew_lease(
self, job_id: str, *, lease_owner: str, lease_until: datetime
) -> bool:
"""心跳续租:仅持租者可延长 ``lease_until``。返回是否续租成功。"""
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(
RoundtableJobRow.id == job_id,
RoundtableJobRow.lease_owner == lease_owner,
RoundtableJobRow.status == "running",
)
.values(lease_until=lease_until, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
async def release_lease(self, job_id: str, *, lease_owner: str | None = None) -> bool:
"""释放租约(进入 awaiting_input 等「暂停但非终态」时调用)。
置 ``lease_owner/lease_until`` 为 NULL,使该作业可被任意 worker 重新领取(续跑)。
传入 ``lease_owner`` 时仅当匹配才释放(防止误放他人的租约)。
"""
where = [RoundtableJobRow.id == job_id]
if lease_owner is not None:
where.append(RoundtableJobRow.lease_owner == lease_owner)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(*where)
.values(lease_owner=None, lease_until=None, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
# ── Phase 3:resume / cancel 条件状态机 ──────────────────────────────────
async def queue_for_resume(
self,
job_id: str,
*,
input_snapshot: dict[str, Any] | None = None,
user_id: str | None | _AutoSentinel = AUTO,
task_id: str | None = None,
) -> dict[str, Any] | None:
"""原子条件 UPDATE:``awaiting_input → queued``(续跑入队),仅第一个成功方有效。
多 worker / 重复点击下可能有多个 resume 请求并发;条件 UPDATE(WHERE status=
'awaiting_input')保证只有一个能把它翻成 queued,其余返回 None(路由据此拒绝)。
成功后清租约(交给 dispatcher 重新领取),并把含续跑语义的新 snapshot 写回。
"""
resolved_user_id = resolve_user_id(
user_id, method_name="RoundtableJobRepository.queue_for_resume"
)
values: dict[str, Any] = {
"status": "queued",
"phase": "initializing",
"pending_clarification": None,
"lease_owner": None,
"lease_until": None,
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
if input_snapshot is not None:
values["input_snapshot"] = _dumps(input_snapshot)
where = [RoundtableJobRow.id == job_id, RoundtableJobRow.status == "awaiting_input"]
if task_id is not None:
where.append(RoundtableJobRow.task_id == task_id)
elif resolved_user_id is not None:
where.append(RoundtableJobRow.user_id == resolved_user_id)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
async def request_cancel(
self,
job_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
task_id: str | None = None,
) -> dict[str, Any] | None:
"""原子条件 UPDATE:可取消态(queued/running/awaiting_input)→ ``cancel_requested``。
cancel 的**第一步**(权威意图落库)。成功后路由再 best-effort 取消本进程任务;
实际终态 ``cancelled`` 由持租者看门狗(检测到 cancel_requested)或 ``finalize_cancel``
写入 —— 保证「已取消」终态由持租者收口,避免竞态。重复 cancel 幂等(已 cancel_requested/
cancelled 时返回 None,路由据此回 409 或返回现状)。
"""
resolved_user_id = resolve_user_id(
user_id, method_name="RoundtableJobRepository.request_cancel"
)
values: dict[str, Any] = {
"status": "cancel_requested",
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
where = [
RoundtableJobRow.id == job_id,
RoundtableJobRow.status.in_(list(CANCELLABLE_STATUSES)),
]
if task_id is not None:
where.append(RoundtableJobRow.task_id == task_id)
elif resolved_user_id is not None:
where.append(RoundtableJobRow.user_id == resolved_user_id)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
async def finalize_cancel(
self,
job_id: str,
*,
lease_owner: str | None = None,
) -> dict[str, Any] | None:
"""把 ``cancel_requested``(或孤儿 queued/running)收口为终态 ``cancelled``。
由持租者看门狗(检测 cancel_requested 后)或路由(作业尚在 queued、无持租者时)调用。
条件 UPDATE:仅当 ``status='cancel_requested'`` 时翻转;同时清唯一活跃键 + 租约。
传入 ``lease_owner`` 时要求租约匹配(持租者收口),否则不加租约条件(queued 无租约)。
"""
values: dict[str, Any] = {
"status": "cancelled",
"active_dedupe_key": None,
"lease_owner": None,
"lease_until": None,
"version": RoundtableJobRow.version + 1,
"updated_at": datetime.now(UTC),
}
where = [RoundtableJobRow.id == job_id, RoundtableJobRow.status == "cancel_requested"]
if lease_owner is not None:
where.append(RoundtableJobRow.lease_owner == lease_owner)
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow).where(*where).values(**values)
)
if result.rowcount == 0:
return None
await session.commit()
row = await session.get(RoundtableJobRow, job_id)
return self._row_to_dict(row) if row is not None else None
# ── Phase 3:outbox 补偿器(draft_persist_pending 投影) ─────────────────
async def list_draft_persist_pending(self, *, limit: int = 50) -> list[dict[str, Any]]:
"""列出 ``draft_persist_pending=1`` 的作业行(补偿器待投影清单)。"""
stmt = (
select(RoundtableJobRow)
.where(RoundtableJobRow.draft_persist_pending.is_(True))
.order_by(RoundtableJobRow.updated_at.asc())
.limit(limit)
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def clear_draft_persist_pending(self, job_id: str) -> bool:
"""投影成功后清 ``draft_persist_pending`` 标记。返回是否命中。"""
async with self._sf() as session:
result = await session.execute(
update(RoundtableJobRow)
.where(RoundtableJobRow.id == job_id)
.values(draft_persist_pending=False, updated_at=datetime.now(UTC))
)
if result.rowcount == 0:
return False
await session.commit()
return True
# ── read ────────────────────────────────────────────────────────────────
async def get(
@@ -50,3 +50,6 @@ class RoundtableTaskDraftRow(Base):
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
# 乐观并发控制版本号:每次 update_draft 自增;传入 expected_version 时校验,命中
# 0 行即并发冲突(防 step2.runs 读-改-写丢失更新——多用户/多 worker 并发)。
version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
@@ -19,9 +19,10 @@ import re
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy import case, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
_UNSET = object()
@@ -77,6 +78,8 @@ class RoundtableTaskDraftRepository:
"task_id": row.task_id,
"title": row.title,
"furthest_step": row.furthest_step,
# 乐观锁版本号:列表也携带,供前端本地副本对账(冲突时重拉合并)。
"version": row.version,
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
"chain_title": _extract_chain_title(row.step2),
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
@@ -159,24 +162,52 @@ class RoundtableTaskDraftRepository:
step1=_UNSET,
step2=_UNSET,
step3=_UNSET,
expected_version: int | None = None,
) -> dict[str, Any] | None:
"""Partial update by id. ``user_id`` is ignored (signature-compat only)."""
"""Partial update by id (``user_id`` ignored — signature-compat only).
与个人草稿一致:原子条件 UPDATE,版本判定与写入在同一条 SQL 里,
多 worker 并发下无检查后竞争窗口。``rowcount == 0`` 时回读区分
「不存在」与「版本冲突」。
"""
values: dict[str, Any] = {
"version": RoundtableTaskDraftRow.version + 1,
"updated_at": datetime.now(UTC),
}
if title is not _UNSET and title is not None:
values["title"] = str(title).strip()
if furthest_step is not _UNSET and furthest_step is not None:
# furthest_step 单调不回退:迟到的自动保存不得把已推进的步骤拉回去。
step_value = int(furthest_step)
values["furthest_step"] = case(
(RoundtableTaskDraftRow.furthest_step >= step_value, RoundtableTaskDraftRow.furthest_step),
else_=step_value,
)
if step1 is not _UNSET:
values["step1"] = _dumps(step1)
if step2 is not _UNSET:
values["step2"] = _dumps(step2)
if step3 is not _UNSET:
values["step3"] = _dumps(step3)
where = [RoundtableTaskDraftRow.id == draft_id]
if expected_version is not None:
where.append(RoundtableTaskDraftRow.version == expected_version)
async with self._sf() as session:
row = await session.get(RoundtableTaskDraftRow, draft_id)
if row is None:
result = await session.execute(
update(RoundtableTaskDraftRow).where(*where).values(**values)
)
if result.rowcount == 0:
if expected_version is not None:
existing = await session.get(RoundtableTaskDraftRow, draft_id)
if existing is not None:
raise DraftConcurrentWriteError(
draft_id, expected=expected_version, actual=existing.version
)
return None
if title is not _UNSET and title is not None:
row.title = str(title).strip()
if furthest_step is not _UNSET and furthest_step is not None:
row.furthest_step = int(furthest_step)
if step1 is not _UNSET:
row.step1 = _dumps(step1)
if step2 is not _UNSET:
row.step2 = _dumps(step2)
if step3 is not _UNSET:
row.step3 = _dumps(step3)
row.updated_at = datetime.now(UTC)
await session.commit()
row = await session.get(RoundtableTaskDraftRow, draft_id)
await session.refresh(row)
return self._to_dict(row)
@@ -15,10 +15,10 @@ internal checkpoint callbacks that are not exposed in the Python public API.
from __future__ import annotations
import asyncio
import copy
import inspect
import logging
import asyncio
import copy
import inspect
import logging
from dataclasses import dataclass, field
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -28,31 +28,31 @@ from langgraph.checkpoint.base import empty_checkpoint
if TYPE_CHECKING:
from langchain_core.messages import HumanMessage
from deerflow.agents.middlewares.title_middleware import (
finalize_thread_title,
log_title_debug,
)
from deerflow.config.app_config import AppConfig
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from deerflow.agents.middlewares.title_middleware import (
finalize_thread_title,
log_title_debug,
)
from deerflow.config.app_config import AppConfig
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from .manager import RunManager, RunRecord
from .schemas import RunStatus
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
# Valid stream_mode values for LangGraph's graph.astream()
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
def _log_background_title_finalize_failure(thread_id: str):
def _done(task: asyncio.Task[bool]) -> None:
try:
task.result()
except Exception:
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
return _done
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
def _log_background_title_finalize_failure(thread_id: str):
def _done(task: asyncio.Task[bool]) -> None:
try:
task.result()
except Exception:
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
return _done
def _build_runtime_context(
@@ -364,7 +364,40 @@ async def run_agent(
else:
await run_manager.set_status(run_id, RunStatus.interrupted)
else:
await run_manager.set_status(run_id, RunStatus.success)
# LLMErrorHandlingMiddleware intentionally converts an exhausted
# provider failure into a user-safe AIMessage instead of raising.
# That keeps a malformed upstream response out of the SSE transport,
# but previously made this worker record the run as ``success``.
# The UI consequently had no error event to show and looked as if
# generation had silently stopped halfway through. Inspect the
# terminal checkpoint before declaring success so the persisted run
# state and the user-visible SSE outcome stay consistent.
terminal_llm_error = await _terminal_llm_error_reason(
checkpointer=checkpointer,
thread_id=thread_id,
)
if terminal_llm_error is not None:
error_message = "本轮模型生成未能完成,已保留当前对话内容,请稍后重试。"
logger.warning(
"Run %s ended with LLM fallback message (reason=%s)",
run_id,
terminal_llm_error,
)
await run_manager.set_status(
run_id,
RunStatus.error,
error=error_message,
)
await bridge.publish(
run_id,
"error",
{
"message": error_message,
"name": "ModelGenerationError",
},
)
else:
await run_manager.set_status(run_id, RunStatus.success)
except asyncio.CancelledError:
action = record.abort_action
@@ -414,47 +447,47 @@ async def run_agent(
except Exception:
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
# Sync title from checkpoint to threads_meta.display_name
if checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
channel_values = ckpt.get("channel_values", {}) or {}
title = channel_values.get("title")
if title:
await thread_store.update_display_name(thread_id, title)
if (
record.status == RunStatus.success
and channel_values.get("title_provisional") is True
):
log_title_debug(
"Run %s: scheduling background title generation for thread %s",
run_id,
thread_id,
)
title_task = asyncio.create_task(
finalize_thread_title(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id=thread_id,
source_state=copy.deepcopy(channel_values),
app_config=ctx.app_config,
),
name=f"title-finalize:{thread_id}",
)
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
elif record.status == RunStatus.success:
log_title_debug(
"Run %s: background title generation not scheduled for thread %s because title_provisional=%r title=%r",
run_id,
thread_id,
channel_values.get("title_provisional"),
channel_values.get("title"),
)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Sync title from checkpoint to threads_meta.display_name
if checkpointer is not None and thread_store is not None:
try:
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
if ckpt_tuple is not None:
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
channel_values = ckpt.get("channel_values", {}) or {}
title = channel_values.get("title")
if title:
await thread_store.update_display_name(thread_id, title)
if (
record.status == RunStatus.success
and channel_values.get("title_provisional") is True
):
log_title_debug(
"Run %s: scheduling background title generation for thread %s",
run_id,
thread_id,
)
title_task = asyncio.create_task(
finalize_thread_title(
checkpointer=checkpointer,
thread_store=thread_store,
thread_id=thread_id,
source_state=copy.deepcopy(channel_values),
app_config=ctx.app_config,
),
name=f"title-finalize:{thread_id}",
)
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
elif record.status == RunStatus.success:
log_title_debug(
"Run %s: background title generation not scheduled for thread %s because title_provisional=%r title=%r",
run_id,
thread_id,
channel_values.get("title_provisional"),
channel_values.get("title"),
)
except Exception:
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
# Update threads_meta status based on run outcome
if thread_store is not None:
@@ -484,6 +517,65 @@ async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_nam
return result
async def _terminal_llm_error_reason(
*,
checkpointer: Any | None,
thread_id: str,
) -> str | None:
"""Return the terminal LLM fallback reason, if this run ended in one.
``LLMErrorHandlingMiddleware`` persists its fallback as an AI message with
an internal marker. A normal agent graph treats that message as a valid
result and completes without an exception, so the worker must check the
saved conversation rather than relying solely on ``astream`` raising.
"""
if checkpointer is None:
return None
try:
checkpoint_config = {
"configurable": {
"thread_id": thread_id,
"checkpoint_ns": "",
}
}
checkpoint_tuple = await _call_checkpointer_method(
checkpointer,
"aget_tuple",
"get_tuple",
checkpoint_config,
)
if checkpoint_tuple is None:
return None
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values", {})
messages = channel_values.get("messages", []) if isinstance(channel_values, dict) else []
if not isinstance(messages, list):
return None
# Walk backwards to the latest assistant message. Tool/human messages
# may be appended around it by middleware, but an older fallback should
# never turn a later successful response into an error.
for message in reversed(messages):
message_type = message.get("type") if isinstance(message, dict) else getattr(message, "type", None)
if message_type not in {"ai", "assistant"}:
continue
from deerflow.agents.middlewares.llm_error_handling_middleware import (
is_llm_error_message,
)
return is_llm_error_message(message)
except Exception:
# A status check must never turn a completed answer into a failed run
# merely because a custom checkpointer cannot expose its latest tuple.
logger.debug(
"Could not inspect terminal LLM state for thread %s",
thread_id,
exc_info=True,
)
return None
async def _rollback_to_pre_run_checkpoint(
*,
checkpointer: Any,
@@ -3,7 +3,13 @@ from typing import Literal
from langchain.tools import tool
@tool("ask_clarification", parse_docstring=True, return_direct=True)
# ClarificationMiddleware owns the stop behaviour: it returns a Command that
# writes the ToolMessage and explicitly routes to END. ``return_direct=True``
# makes the prebuilt tool node short-circuit before that Command update is
# checkpointed in some LangGraph versions, leaving an assistant tool call
# without its matching ToolMessage. The next user reply then fails provider
# validation with "tool_calls must be followed by tool messages".
@tool("ask_clarification", parse_docstring=True, return_direct=False)
def ask_clarification_tool(
question: str,
clarification_type: Literal[
@@ -38,7 +38,10 @@ async def store(tmp_path):
import deerflow.persistence.position_roundtable.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
return PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
try:
yield PositionRoundtableRepository(async_sessionmaker(engine, expire_on_commit=False))
finally:
await engine.dispose()
def test_reject_node_request_allows_an_optional_reason():
@@ -1206,6 +1209,8 @@ async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reje
def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
from deerflow.persistence.position_roundtable.delivery import markdown_delivery_changed
old = [
{
"name": "report.md",
@@ -1214,8 +1219,8 @@ def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
"size_bytes": 100,
}
]
assert not position_roundtable_router._markdown_delivery_changed(old, old)
assert position_roundtable_router._markdown_delivery_changed(
assert not markdown_delivery_changed(old, old)
assert markdown_delivery_changed(
old,
[{**old[0], "modified_at": "2026-07-21T00:05:00+00:00", "size_bytes": 120}],
)
@@ -1358,6 +1363,17 @@ async def test_locked_node_cannot_bind_a_thread_before_its_stage_is_ready():
"thread_id": None,
}
async def apply_node_command(
self, session_id, node_key, user_id, *, command_id, command_type, payload=None,
expected_session_version=None,
):
# Mirrors the in-lock rejection a locked node produces in the real store.
return {
"ok": False,
"http_status": 409,
"detail": "上游节点尚未完成,当前节点未解锁",
}
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),