Merge branch 'master-nj' into 'master'
feat: 岗位会商命令幂等与圆桌后台作业租约调度 See merge request knowledge-base-bj/deerflow-code!22
This commit is contained in:
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 { getBackendBaseURL } from "@/core/config";
|
||||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
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 { useSkills } from "@/core/skills/hooks";
|
||||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
import {
|
import {
|
||||||
@@ -93,13 +94,20 @@ const ChatBox: React.FC<{
|
|||||||
const [settings] = useLocalSettings();
|
const [settings] = useLocalSettings();
|
||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
// Frontend single-round RAG writes its own source tool message, so it should
|
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||||
// use the same reference panel without changing the user's global RAG mode.
|
// the existing reference panel, but must not request or write reference
|
||||||
const hasSingleRoundRag = useMemo(
|
// batches through DeerFlow's backend APIs.
|
||||||
() => (thread.values.messages ?? []).some(isSingleRoundRagMessage),
|
const hasFrontendRag = useMemo(
|
||||||
|
() =>
|
||||||
|
(thread.values.messages ?? []).some(
|
||||||
|
(message) =>
|
||||||
|
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||||
|
),
|
||||||
[thread.values.messages],
|
[thread.values.messages],
|
||||||
);
|
);
|
||||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
const referencesEnabled = ragModeEnabled || hasFrontendRag;
|
||||||
|
const shouldFetchBackendReferenceBatches =
|
||||||
|
referencesEnabled && !hasFrontendRag;
|
||||||
const { skills } = useSkills();
|
const { skills } = useSkills();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -128,7 +136,7 @@ const ChatBox: React.FC<{
|
|||||||
);
|
);
|
||||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!referencesEnabled) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
setBackendReferenceBatches([]);
|
setBackendReferenceBatches([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -164,7 +172,12 @@ const ChatBox: React.FC<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]);
|
}, [
|
||||||
|
shouldFetchBackendReferenceBatches,
|
||||||
|
thread.isLoading,
|
||||||
|
threadId,
|
||||||
|
thread.values.messages,
|
||||||
|
]);
|
||||||
const adaptedBackendReferenceBatches = useMemo(
|
const adaptedBackendReferenceBatches = useMemo(
|
||||||
() =>
|
() =>
|
||||||
adaptBackendReferenceBatches(
|
adaptBackendReferenceBatches(
|
||||||
@@ -175,15 +188,22 @@ const ChatBox: React.FC<{
|
|||||||
);
|
);
|
||||||
const referenceBatches = useMemo(
|
const referenceBatches = useMemo(
|
||||||
() => {
|
() => {
|
||||||
if (adaptedBackendReferenceBatches.length > 0) {
|
if (
|
||||||
|
shouldFetchBackendReferenceBatches &&
|
||||||
|
adaptedBackendReferenceBatches.length > 0
|
||||||
|
) {
|
||||||
return adaptedBackendReferenceBatches;
|
return adaptedBackendReferenceBatches;
|
||||||
}
|
}
|
||||||
return fallbackReferenceBatches;
|
return fallbackReferenceBatches;
|
||||||
},
|
},
|
||||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
[
|
||||||
|
adaptedBackendReferenceBatches,
|
||||||
|
fallbackReferenceBatches,
|
||||||
|
shouldFetchBackendReferenceBatches,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!referencesEnabled) {
|
if (!shouldFetchBackendReferenceBatches) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const signature = JSON.stringify({
|
const signature = JSON.stringify({
|
||||||
@@ -210,7 +230,14 @@ const ChatBox: React.FC<{
|
|||||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||||
finalBatches: debugReferenceBatches(referenceBatches),
|
finalBatches: debugReferenceBatches(referenceBatches),
|
||||||
});
|
});
|
||||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]);
|
}, [
|
||||||
|
adaptedBackendReferenceBatches,
|
||||||
|
backendReferenceBatches,
|
||||||
|
fallbackReferenceBatches,
|
||||||
|
referenceBatches,
|
||||||
|
shouldFetchBackendReferenceBatches,
|
||||||
|
threadId,
|
||||||
|
]);
|
||||||
const latestReferenceBatch =
|
const latestReferenceBatch =
|
||||||
referenceBatches.length > 0
|
referenceBatches.length > 0
|
||||||
? referenceBatches[referenceBatches.length - 1]!
|
? referenceBatches[referenceBatches.length - 1]!
|
||||||
@@ -221,7 +248,7 @@ const ChatBox: React.FC<{
|
|||||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||||
const hasCitationReferences = referenceSources.length > 0;
|
const hasCitationReferences = referenceSources.length > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!referencesEnabled || !activeReferenceBatch) {
|
if (!shouldFetchBackendReferenceBatches || !activeReferenceBatch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||||
@@ -230,7 +257,7 @@ const ChatBox: React.FC<{
|
|||||||
sourceCount: activeReferenceBatch.sources.length,
|
sourceCount: activeReferenceBatch.sources.length,
|
||||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||||
});
|
});
|
||||||
}, [activeReferenceBatch, referencesEnabled, threadId]);
|
}, [activeReferenceBatch, shouldFetchBackendReferenceBatches, threadId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasCitationReferences) {
|
if (hasCitationReferences) {
|
||||||
setReferencesOpen(true);
|
setReferencesOpen(true);
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ export function CitationLink({
|
|||||||
href,
|
href,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
onReferenceClick,
|
||||||
|
onClick,
|
||||||
...props
|
...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 childrenText = normalizeCitationText(children);
|
||||||
const displayText = childrenText ?? "";
|
const displayText = childrenText ?? "";
|
||||||
const referenceId = href?.startsWith("#reference-")
|
const referenceId = href?.startsWith("#reference-")
|
||||||
@@ -28,6 +33,11 @@ export function CitationLink({
|
|||||||
)}
|
)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (onReferenceClick) {
|
||||||
|
onReferenceClick(referenceId);
|
||||||
|
onClick?.(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const batchId =
|
const batchId =
|
||||||
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||||
?.dataset.referenceBatchId;
|
?.dataset.referenceBatchId;
|
||||||
@@ -41,6 +51,7 @@ export function CitationLink({
|
|||||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
};
|
};
|
||||||
window.setTimeout(scroll, 240);
|
window.setTimeout(scroll, 240);
|
||||||
|
onClick?.(event);
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -58,6 +69,7 @@ export function CitationLink({
|
|||||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onClick={onClick}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{displayText || children}
|
{displayText || children}
|
||||||
|
|||||||
@@ -210,6 +210,9 @@ export function InputBox({
|
|||||||
const { models } = useModels();
|
const { models } = useModels();
|
||||||
const singleRoundRagActive =
|
const singleRoundRagActive =
|
||||||
context.execution_profile === "single_round_rag";
|
context.execution_profile === "single_round_rag";
|
||||||
|
const multiRoundRagActive =
|
||||||
|
context.execution_profile === "multi_round_rag";
|
||||||
|
const frontendRagActive = singleRoundRagActive || multiRoundRagActive;
|
||||||
const modelOptions = useMemo(() => {
|
const modelOptions = useMemo(() => {
|
||||||
return singleRoundRagActive ? extraModels : models;
|
return singleRoundRagActive ? extraModels : models;
|
||||||
}, [extraModels, models, singleRoundRagActive]);
|
}, [extraModels, models, singleRoundRagActive]);
|
||||||
@@ -235,7 +238,12 @@ export function InputBox({
|
|||||||
!userPreferences.hide_admin_prompt_prefix &&
|
!userPreferences.hide_admin_prompt_prefix &&
|
||||||
recommendedQuestions.length > 0;
|
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) {
|
if (modelOptions.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -259,7 +267,7 @@ export function InputBox({
|
|||||||
mode: nextMode,
|
mode: nextMode,
|
||||||
execution_profile: fallbackModel.execution_profile,
|
execution_profile: fallbackModel.execution_profile,
|
||||||
});
|
});
|
||||||
}, [context, modelOptions, onContextChange]);
|
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||||
|
|
||||||
const selectedModel = useMemo(() => {
|
const selectedModel = useMemo(() => {
|
||||||
if (modelOptions.length === 0) {
|
if (modelOptions.length === 0) {
|
||||||
@@ -342,6 +350,13 @@ export function InputBox({
|
|||||||
execution_profile: "single_round_rag",
|
execution_profile: "single_round_rag",
|
||||||
});
|
});
|
||||||
}, [context, extraModels, onContextChange]);
|
}, [context, extraModels, onContextChange]);
|
||||||
|
|
||||||
|
const handleMultiRoundRagSelect = useCallback(() => {
|
||||||
|
onContextChange?.({
|
||||||
|
...context,
|
||||||
|
execution_profile: "multi_round_rag",
|
||||||
|
});
|
||||||
|
}, [context, onContextChange]);
|
||||||
|
|
||||||
const handleReasoningEffortSelect = useCallback(
|
const handleReasoningEffortSelect = useCallback(
|
||||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||||
@@ -712,14 +727,16 @@ export function InputBox({
|
|||||||
/>
|
/>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu> */}
|
</PromptInputActionMenu> */}
|
||||||
<AddAttachmentsButton
|
{!frontendRagActive && (
|
||||||
className={cn(
|
<AddAttachmentsButton
|
||||||
"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",
|
className={cn(
|
||||||
compact &&
|
"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",
|
||||||
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
compact &&
|
||||||
)}
|
"gap-1.5! rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||||
/>
|
)}
|
||||||
<Tooltip
|
/>
|
||||||
|
)}
|
||||||
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
context.writing_mode
|
context.writing_mode
|
||||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||||
@@ -744,8 +761,8 @@ export function InputBox({
|
|||||||
<FileTextIcon className="size-3" />
|
<FileTextIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">写作</span>
|
<span className="text-xs font-normal">写作</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
memoryInjectionEnabled
|
memoryInjectionEnabled
|
||||||
? "记忆注入已开启:对话前会注入历史记忆"
|
? "记忆注入已开启:对话前会注入历史记忆"
|
||||||
@@ -770,8 +787,8 @@ export function InputBox({
|
|||||||
<BrainCircuitIcon className="size-3" />
|
<BrainCircuitIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">记忆</span>
|
<span className="text-xs font-normal">记忆</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
ragModeEnabled
|
ragModeEnabled
|
||||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||||
@@ -800,8 +817,8 @@ export function InputBox({
|
|||||||
<BookMarkedIcon className="size-3" />
|
<BookMarkedIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">参考文献</span>
|
<span className="text-xs font-normal">参考文献</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<Tooltip
|
{!frontendRagActive && <Tooltip
|
||||||
content={
|
content={
|
||||||
contextCompressionEnabled
|
contextCompressionEnabled
|
||||||
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
? "上下文压缩已开启:超长对话会自动压缩历史以节省 token"
|
||||||
@@ -829,7 +846,7 @@ export function InputBox({
|
|||||||
<ShrinkIcon className="size-3" />
|
<ShrinkIcon className="size-3" />
|
||||||
<span className="text-xs font-normal">上下文压缩</span>
|
<span className="text-xs font-normal">上下文压缩</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>}
|
||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<ModeHoverGuide
|
<ModeHoverGuide
|
||||||
mode={
|
mode={
|
||||||
@@ -841,9 +858,11 @@ export function InputBox({
|
|||||||
: "flash"
|
: "flash"
|
||||||
}
|
}
|
||||||
tooltipContent={
|
tooltipContent={
|
||||||
selectedSingleRoundRag
|
singleRoundRagActive
|
||||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||||
: undefined
|
: multiRoundRagActive
|
||||||
|
? "多轮 RAG 问答:多次检索并展示检索过程与参考资料"
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<PromptInputActionMenuTrigger
|
<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",
|
"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 && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||||
compact &&
|
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"
|
? "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"
|
: 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"
|
? "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>
|
<div>
|
||||||
{selectedSingleRoundRag ? (
|
{frontendRagActive ? (
|
||||||
<BookMarkedIcon className="size-3" />
|
<BookMarkedIcon className="size-3" />
|
||||||
) : context.mode === "flash" ? (
|
) : context.mode === "flash" ? (
|
||||||
<ZapIcon className="size-3" />
|
<ZapIcon className="size-3" />
|
||||||
@@ -881,8 +900,10 @@ export function InputBox({
|
|||||||
context.mode === "ultra" ? "golden-text" : "",
|
context.mode === "ultra" ? "golden-text" : "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedSingleRoundRag
|
{frontendRagActive
|
||||||
? "单轮 RAG"
|
? multiRoundRagActive
|
||||||
|
? "多轮 RAG"
|
||||||
|
: "单轮 RAG"
|
||||||
: (context.mode === "flash" && t.inputBox.flashMode) ||
|
: (context.mode === "flash" && t.inputBox.flashMode) ||
|
||||||
(context.mode === "thinking" &&
|
(context.mode === "thinking" &&
|
||||||
t.inputBox.reasoningMode) ||
|
t.inputBox.reasoningMode) ||
|
||||||
@@ -899,7 +920,7 @@ export function InputBox({
|
|||||||
<PromptInputActionMenu>
|
<PromptInputActionMenu>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!selectedSingleRoundRag && context.mode === "flash"
|
!frontendRagActive && context.mode === "flash"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -910,7 +931,7 @@ export function InputBox({
|
|||||||
<ZapIcon
|
<ZapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!selectedSingleRoundRag && context.mode === "flash" &&
|
!frontendRagActive && context.mode === "flash" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -920,7 +941,7 @@ export function InputBox({
|
|||||||
{t.inputBox.flashModeDescription}
|
{t.inputBox.flashModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!selectedSingleRoundRag && context.mode === "flash" ? (
|
{!frontendRagActive && context.mode === "flash" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -929,7 +950,7 @@ export function InputBox({
|
|||||||
{supportThinking && (
|
{supportThinking && (
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!selectedSingleRoundRag && context.mode === "thinking"
|
!frontendRagActive && context.mode === "thinking"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -940,7 +961,7 @@ export function InputBox({
|
|||||||
<LightbulbIcon
|
<LightbulbIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!selectedSingleRoundRag && context.mode === "thinking" &&
|
!frontendRagActive && context.mode === "thinking" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -950,7 +971,7 @@ export function InputBox({
|
|||||||
{t.inputBox.reasoningModeDescription}
|
{t.inputBox.reasoningModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!selectedSingleRoundRag && context.mode === "thinking" ? (
|
{!frontendRagActive && context.mode === "thinking" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -959,7 +980,7 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!selectedSingleRoundRag && context.mode === "pro"
|
!frontendRagActive && context.mode === "pro"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -970,7 +991,7 @@ export function InputBox({
|
|||||||
<GraduationCapIcon
|
<GraduationCapIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!selectedSingleRoundRag && context.mode === "pro" &&
|
!frontendRagActive && context.mode === "pro" &&
|
||||||
"text-accent-foreground",
|
"text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -980,7 +1001,7 @@ export function InputBox({
|
|||||||
{t.inputBox.proModeDescription}
|
{t.inputBox.proModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!selectedSingleRoundRag && context.mode === "pro" ? (
|
{!frontendRagActive && context.mode === "pro" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -988,7 +1009,7 @@ export function InputBox({
|
|||||||
</PromptInputActionMenuItem>
|
</PromptInputActionMenuItem>
|
||||||
<PromptInputActionMenuItem
|
<PromptInputActionMenuItem
|
||||||
className={cn(
|
className={cn(
|
||||||
!selectedSingleRoundRag && context.mode === "ultra"
|
!frontendRagActive && context.mode === "ultra"
|
||||||
? "text-accent-foreground"
|
? "text-accent-foreground"
|
||||||
: "text-muted-foreground/65",
|
: "text-muted-foreground/65",
|
||||||
)}
|
)}
|
||||||
@@ -999,14 +1020,14 @@ export function InputBox({
|
|||||||
<RocketIcon
|
<RocketIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"mr-2 size-4",
|
"mr-2 size-4",
|
||||||
!selectedSingleRoundRag &&
|
!frontendRagActive &&
|
||||||
context.mode === "ultra" &&
|
context.mode === "ultra" &&
|
||||||
"text-[#dabb5e]",
|
"text-[#dabb5e]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
!selectedSingleRoundRag &&
|
!frontendRagActive &&
|
||||||
context.mode === "ultra" &&
|
context.mode === "ultra" &&
|
||||||
"golden-text",
|
"golden-text",
|
||||||
)}
|
)}
|
||||||
@@ -1018,7 +1039,7 @@ export function InputBox({
|
|||||||
{t.inputBox.ultraModeDescription}
|
{t.inputBox.ultraModeDescription}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!selectedSingleRoundRag && context.mode === "ultra" ? (
|
{!frontendRagActive && context.mode === "ultra" ? (
|
||||||
<CheckIcon className="ml-auto size-4" />
|
<CheckIcon className="ml-auto size-4" />
|
||||||
) : (
|
) : (
|
||||||
<div className="ml-auto size-4" />
|
<div className="ml-auto size-4" />
|
||||||
@@ -1057,11 +1078,39 @@ export function InputBox({
|
|||||||
</PromptInputActionMenuItem>
|
</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>
|
</PromptInputActionMenu>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</PromptInputActionMenuContent>
|
</PromptInputActionMenuContent>
|
||||||
</PromptInputActionMenu>
|
</PromptInputActionMenu>
|
||||||
{supportReasoningEffort && context.mode !== "flash" && (
|
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||||
<PromptInputActionMenu>
|
<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]")}>
|
<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">
|
<div className="text-xs font-normal">
|
||||||
@@ -1179,8 +1228,8 @@ export function InputBox({
|
|||||||
)}
|
)}
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||||
{knowledgeSelector}
|
{!frontendRagActive && agentSelector && (
|
||||||
{agentSelector && (
|
{!frontendRagActive && knowledgeSelector}
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
selectedAgentId={agentSelector.selectedAgentId}
|
selectedAgentId={agentSelector.selectedAgentId}
|
||||||
onAgentChange={agentSelector.onAgentChange}
|
onAgentChange={agentSelector.onAgentChange}
|
||||||
@@ -1188,7 +1237,7 @@ export function InputBox({
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{promptPrefix && (
|
{!frontendRagActive && promptPrefix && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
promptPrefix.on
|
promptPrefix.on
|
||||||
@@ -1216,7 +1265,7 @@ export function InputBox({
|
|||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<ModelSelector
|
{!multiRoundRagActive && <ModelSelector
|
||||||
open={modelDialogOpen}
|
open={modelDialogOpen}
|
||||||
onOpenChange={setModelDialogOpen}
|
onOpenChange={setModelDialogOpen}
|
||||||
>
|
>
|
||||||
@@ -1266,7 +1315,7 @@ export function InputBox({
|
|||||||
))}
|
))}
|
||||||
</ModelSelectorList>
|
</ModelSelectorList>
|
||||||
</ModelSelectorContent>
|
</ModelSelectorContent>
|
||||||
</ModelSelector>
|
</ModelSelector>}
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
className={cn(
|
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",
|
"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 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 {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -25,8 +33,15 @@ import {
|
|||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from '@/components/ai-elements/reasoning';
|
} from '@/components/ai-elements/reasoning';
|
||||||
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
UploadedFilePreviewDialog,
|
UploadedFilePreviewDialog,
|
||||||
@@ -34,7 +49,8 @@ import {
|
|||||||
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
|
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
|
||||||
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
||||||
import { useChatAskTimes } from '@/core/chat-timestamps';
|
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 { useI18n } from '@/core/i18n/hooks';
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@@ -49,7 +65,12 @@ import {
|
|||||||
applyInlineCitations,
|
applyInlineCitations,
|
||||||
type RuntimeDisplaySource,
|
type RuntimeDisplaySource,
|
||||||
} from '@/core/skill-display/runtime';
|
} 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 { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { CitationLink } from '../citations/citation-link';
|
import { CitationLink } from '../citations/citation-link';
|
||||||
@@ -561,18 +582,47 @@ function MessageContent_({
|
|||||||
: baseRehypePlugins,
|
: baseRehypePlugins,
|
||||||
[baseRehypePlugins, extraRehypePlugins],
|
[baseRehypePlugins, extraRehypePlugins],
|
||||||
);
|
);
|
||||||
const askTimes = useChatAskTimes();
|
const askTimes = useChatAskTimes();
|
||||||
const isHuman = message.type === 'human';
|
const isHuman = message.type === 'human';
|
||||||
const resolveArtifact = useArtifactResolver(threadId);
|
const isSingleRoundRag =
|
||||||
const components = useMemo(
|
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>) => (
|
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||||
),
|
),
|
||||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (href?.startsWith('#reference-')) {
|
if (href?.startsWith('#reference-')) {
|
||||||
return <CitationLink href={href} {...props} />;
|
return (
|
||||||
}
|
<CitationLink
|
||||||
|
href={href}
|
||||||
|
{...props}
|
||||||
|
onReferenceClick={
|
||||||
|
isSingleRoundRag || isMultiRoundRag
|
||||||
|
? handleCitationClick
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof props.children === 'string' &&
|
typeof props.children === 'string' &&
|
||||||
/^citation:/i.test(props.children)
|
/^citation:/i.test(props.children)
|
||||||
@@ -593,8 +643,14 @@ function MessageContent_({
|
|||||||
return <a {...props} href={href} />;
|
return <a {...props} href={href} />;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[threadId, resolveArtifact],
|
[
|
||||||
);
|
handleCitationClick,
|
||||||
|
isMultiRoundRag,
|
||||||
|
isSingleRoundRag,
|
||||||
|
resolveArtifact,
|
||||||
|
threadId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
// 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
|
// 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)
|
// Reasoning-only AI message (no main response content yet)
|
||||||
if (!isHuman && reasoningContent && !rawContent) {
|
if (!isHuman && reasoningContent && !rawContent && !multiRoundTrace) {
|
||||||
return (
|
return (
|
||||||
<AIElementMessageContent className={className}>
|
<AIElementMessageContent className={className}>
|
||||||
<Reasoning isStreaming={isLoading}>
|
<Reasoning isStreaming={isLoading}>
|
||||||
@@ -707,32 +763,284 @@ function MessageContent_({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AIElementMessageContent className={className}>
|
<>
|
||||||
{filesList}
|
<AIElementMessageContent className={className}>
|
||||||
<div data-reference-batch-id={message.id} className="contents">
|
{filesList}
|
||||||
<AssistantDisplayContent
|
<div data-reference-batch-id={message.id} className="contents">
|
||||||
content={contentToDisplay}
|
{multiRoundTrace ? (
|
||||||
displaySources={displaySources}
|
<MultiRoundRagContent
|
||||||
isLoading={isLoading}
|
trace={multiRoundTrace}
|
||||||
renderSkillDisplayBlocks={false}
|
content={contentToDisplay}
|
||||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
displaySources={displaySources}
|
||||||
components={components}
|
isLoading={isLoading}
|
||||||
/>
|
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||||
</div>
|
components={components}
|
||||||
<MessageTokenUsage
|
/>
|
||||||
enabled={tokenUsageEnabled}
|
) : (
|
||||||
isLoading={isLoading}
|
<AssistantDisplayContent
|
||||||
message={message}
|
content={contentToDisplay}
|
||||||
/>
|
displaySources={displaySources}
|
||||||
{!isLoading && contentToDisplay ? (
|
isLoading={isLoading}
|
||||||
<MessageTimeStamp time={messageTime} align="left" />
|
renderSkillDisplayBlocks={false}
|
||||||
) : null}
|
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||||
</AIElementMessageContent>
|
components={components}
|
||||||
);
|
/>
|
||||||
}
|
)}
|
||||||
|
</div>
|
||||||
function EmbeddedPageContextCard({
|
<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,
|
||||||
}: {
|
}: {
|
||||||
pageContext: EmbeddedPageContext;
|
pageContext: EmbeddedPageContext;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type { Subtask } from "@/core/tasks";
|
|||||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||||
import type { AgentThreadState } from "@/core/threads";
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||||
|
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||||
|
|
||||||
@@ -239,7 +240,11 @@ export function MessageList({
|
|||||||
const ragModeEnabled =
|
const ragModeEnabled =
|
||||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
const hasSingleRoundRag = useMemo(
|
const hasSingleRoundRag = useMemo(
|
||||||
() => messages.some(isSingleRoundRagMessage),
|
() =>
|
||||||
|
messages.some(
|
||||||
|
(message) =>
|
||||||
|
isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message),
|
||||||
|
),
|
||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||||
|
|||||||
@@ -19,3 +19,18 @@ export function isSingleRoundRagProfile(
|
|||||||
"single_round_rag"
|
"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);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export interface Model {
|
|||||||
supports_thinking?: boolean;
|
supports_thinking?: boolean;
|
||||||
supports_reasoning_effort?: boolean;
|
supports_reasoning_effort?: boolean;
|
||||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
/** 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 {
|
export interface TokenUsageSettings {
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ test("keeps half-width syntax working", () => {
|
|||||||
assert.equal(out.includes(citation(3)), true);
|
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", () => {
|
test("accepts spaced and full-width square brackets", () => {
|
||||||
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
|
const out = applyInlineCitations("参考[ 1 ]和[2]。", makeSources(2));
|
||||||
assert.equal(out.includes(citation(1)), true);
|
assert.equal(out.includes(citation(1)), true);
|
||||||
|
|||||||
@@ -244,6 +244,11 @@ const TERMINAL_RUN_STATUSES = new Set([
|
|||||||
// for a while, then reconcile it from the authoritative run/state endpoints.
|
// for a while, then reconcile it from the authoritative run/state endpoints.
|
||||||
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
|
const STREAM_RECONCILIATION_IDLE_MS = 12_000;
|
||||||
const STREAM_RECONCILIATION_POLL_MS = 5_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(
|
async function fetchThreadTitleRefreshState(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
@@ -466,9 +471,15 @@ export function useThreadStream({
|
|||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const lastStreamActivityAtRef = useRef(0);
|
const lastStreamActivityAtRef = useRef(0);
|
||||||
const streamReconciliationInFlightRef = useRef(false);
|
const streamReconciliationInFlightRef = useRef(false);
|
||||||
|
const streamReconnectAttemptsRef = useRef(0);
|
||||||
|
const streamReconnectToastShownRef = useRef(false);
|
||||||
|
const streamReconnectFailureToastShownRef = useRef(false);
|
||||||
|
|
||||||
const markStreamActivity = useCallback(() => {
|
const markStreamActivity = useCallback(() => {
|
||||||
lastStreamActivityAtRef.current = Date.now();
|
lastStreamActivityAtRef.current = Date.now();
|
||||||
|
streamReconnectAttemptsRef.current = 0;
|
||||||
|
streamReconnectToastShownRef.current = false;
|
||||||
|
streamReconnectFailureToastShownRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const listeners = useRef({
|
const listeners = useRef({
|
||||||
@@ -761,13 +772,6 @@ export function useThreadStream({
|
|||||||
threadStreamRef.current = thread;
|
threadStreamRef.current = thread;
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
const activeThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
|
||||||
if (!activeThreadId || typeof window === "undefined") {
|
if (!activeThreadId || typeof window === "undefined") {
|
||||||
return;
|
return;
|
||||||
@@ -776,7 +780,7 @@ export function useThreadStream({
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
let refreshTimer: number | null = null;
|
let refreshTimer: number | null = null;
|
||||||
|
|
||||||
const reconcileCompletedRun = async () => {
|
const reconcileRun = async () => {
|
||||||
if (
|
if (
|
||||||
disposed ||
|
disposed ||
|
||||||
streamReconciliationInFlightRef.current ||
|
streamReconciliationInFlightRef.current ||
|
||||||
@@ -793,7 +797,51 @@ export function useThreadStream({
|
|||||||
try {
|
try {
|
||||||
const client = getAPIClient(isMock);
|
const client = getAPIClient(isMock);
|
||||||
const run = await client.runs.get(activeThreadId, runId);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,13 +849,17 @@ export function useThreadStream({
|
|||||||
// receive any more useful events. Load the checkpoint directly before
|
// receive any more useful events. Load the checkpoint directly before
|
||||||
// resetting the SDK stream; this preserves the normal onFinish side
|
// resetting the SDK stream; this preserves the normal onFinish side
|
||||||
// effects (e.g. marking a position delivery complete) even when the
|
// 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);
|
const finalState = await client.threads.getState(activeThreadId);
|
||||||
if (disposed || !threadStreamRef.current.isLoading) {
|
if (disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
storage?.removeItem(`lg:stream:${activeThreadId}`);
|
||||||
|
streamReconnectAttemptsRef.current = 0;
|
||||||
|
streamReconnectToastShownRef.current = false;
|
||||||
|
streamReconnectFailureToastShownRef.current = false;
|
||||||
if (run.status === "success") {
|
if (run.status === "success") {
|
||||||
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
listeners.current.onFinish?.(finalState.values as AgentThreadState);
|
||||||
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
|
||||||
@@ -836,7 +888,7 @@ export function useThreadStream({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const interval = window.setInterval(
|
const interval = window.setInterval(
|
||||||
() => void reconcileCompletedRun(),
|
() => void reconcileRun(),
|
||||||
STREAM_RECONCILIATION_POLL_MS,
|
STREAM_RECONCILIATION_POLL_MS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
knowledge_rag_enabled?: boolean;
|
knowledge_rag_enabled?: boolean;
|
||||||
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
/** DeerFlow LLMWiki mapping ids selected for this conversation. */
|
||||||
llmwiki_knowledge_base_ids?: string[];
|
llmwiki_knowledge_base_ids?: string[];
|
||||||
/** Direct knowledge-base RAG mode; this request never enters Lead Agent. */
|
/** Direct RAG modes handled in the frontend; these requests never enter Lead Agent. */
|
||||||
execution_profile?: "single_round_rag";
|
execution_profile?: "single_round_rag" | "multi_round_rag";
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||||
agent_name?: string;
|
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>;
|
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. */
|
/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */
|
||||||
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
|
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
|
||||||
const objects: string[] = [];
|
const objects: string[] = [];
|
||||||
@@ -199,6 +206,44 @@ function isAbortError(error: unknown) {
|
|||||||
return error instanceof DOMException && error.name === "AbortError";
|
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 {
|
export function isSingleRoundRagMessage(message: Message): boolean {
|
||||||
return message.additional_kwargs?.single_round_rag === true;
|
return message.additional_kwargs?.single_round_rag === true;
|
||||||
}
|
}
|
||||||
@@ -209,21 +254,29 @@ export function isSingleRoundRagMessage(message: Message): boolean {
|
|||||||
*/
|
*/
|
||||||
export function useSingleRoundRagMessages(threadId: string) {
|
export function useSingleRoundRagMessages(threadId: string) {
|
||||||
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
|
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
|
||||||
|
const messagesRef = useRef(messages);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const controllerRef = useRef<AbortController | null>(null);
|
const controllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages(readStoredMessages(threadId));
|
const stored = readStoredMessages(threadId);
|
||||||
|
messagesRef.current = stored;
|
||||||
|
setMessages(stored);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [threadId]);
|
}, [threadId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesRef.current = messages;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||||
|
|
||||||
const updateMessages = useCallback(
|
const updateMessages = useCallback(
|
||||||
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
|
||||||
setMessages((current) => {
|
setMessages((current) => {
|
||||||
const next = updater(current);
|
const next = updater(current);
|
||||||
|
messagesRef.current = next;
|
||||||
writeStoredMessages(targetThreadId, next);
|
writeStoredMessages(targetThreadId, next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -251,6 +304,7 @@ export function useSingleRoundRagMessages(threadId: string) {
|
|||||||
const assistantId = createId("single-rag-ai");
|
const assistantId = createId("single-rag-ai");
|
||||||
const sourceId = createId("single-rag-sources");
|
const sourceId = createId("single-rag-sources");
|
||||||
const clientTs = new Date().toISOString();
|
const clientTs = new Date().toISOString();
|
||||||
|
const history = buildRagHistory(messagesRef.current);
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
id: humanId,
|
id: humanId,
|
||||||
type: "human",
|
type: "human",
|
||||||
@@ -319,7 +373,7 @@ export function useSingleRoundRagMessages(threadId: string) {
|
|||||||
knowledge_base_name: [],
|
knowledge_base_name: [],
|
||||||
top_k: 5,
|
top_k: 5,
|
||||||
score_threshold: 0.5,
|
score_threshold: 0.5,
|
||||||
history: [],
|
history,
|
||||||
stream: true,
|
stream: true,
|
||||||
model_name: modelName,
|
model_name: modelName,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
import {
|
import {
|
||||||
|
isFrontendRagProfile,
|
||||||
|
isMultiRoundRagProfile,
|
||||||
isSingleRoundRagProfile,
|
isSingleRoundRagProfile,
|
||||||
SINGLE_ROUND_RAG_MODELS,
|
SINGLE_ROUND_RAG_MODELS,
|
||||||
} from "@/core/models/single-round-rag";
|
} from "@/core/models/single-round-rag";
|
||||||
@@ -59,6 +61,7 @@ import {
|
|||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||||
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
|
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 { useLayoutMode } from "@/core/system-settings/hooks";
|
||||||
import { useRouteBase } from "@/core/route-base";
|
import { useRouteBase } from "@/core/route-base";
|
||||||
import { textOfMessage } from "@/core/threads/utils";
|
import { textOfMessage } from "@/core/threads/utils";
|
||||||
@@ -310,19 +313,38 @@ export default function ChatPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
const singleRoundRag = useSingleRoundRagMessages(threadId);
|
||||||
|
const multiRoundRag = useMultiRoundRagMessages(threadId);
|
||||||
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
|
||||||
|
const multiRoundRagSelected = isMultiRoundRagProfile(settings.context);
|
||||||
|
const frontendRagSelected = isFrontendRagProfile(settings.context);
|
||||||
const displayThread = useMemo(() => {
|
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;
|
return thread;
|
||||||
}
|
}
|
||||||
const messages = [...thread.messages, ...singleRoundRag.messages];
|
const messages = [
|
||||||
|
...thread.messages,
|
||||||
|
...singleRoundRag.messages,
|
||||||
|
...multiRoundRag.messages,
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
...thread,
|
...thread,
|
||||||
values: { ...thread.values, messages },
|
values: { ...thread.values, messages },
|
||||||
messages,
|
messages,
|
||||||
isLoading: thread.isLoading || singleRoundRag.isLoading,
|
isLoading:
|
||||||
|
thread.isLoading || singleRoundRag.isLoading || multiRoundRag.isLoading,
|
||||||
} as typeof thread;
|
} as typeof thread;
|
||||||
}, [singleRoundRag.isLoading, singleRoundRag.messages, thread]);
|
}, [
|
||||||
|
multiRoundRag.isLoading,
|
||||||
|
multiRoundRag.messages,
|
||||||
|
singleRoundRag.isLoading,
|
||||||
|
singleRoundRag.messages,
|
||||||
|
thread,
|
||||||
|
]);
|
||||||
|
|
||||||
const writingArtifactPath = useMemo(() => {
|
const writingArtifactPath = useMemo(() => {
|
||||||
const isMarkdown = (path: string | null | undefined) =>
|
const isMarkdown = (path: string | null | undefined) =>
|
||||||
@@ -337,9 +359,9 @@ export default function ChatPage() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: PromptInputMessage) => {
|
(message: PromptInputMessage) => {
|
||||||
if (singleRoundRagSelected) {
|
if (frontendRagSelected) {
|
||||||
if (message.files && message.files.length > 0) {
|
if (message.files && message.files.length > 0) {
|
||||||
toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。");
|
toast.error("RAG 问答暂不支持附件,请移除附件后重试。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setHasStartedConversation(true);
|
setHasStartedConversation(true);
|
||||||
@@ -366,13 +388,23 @@ export default function ChatPage() {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await singleRoundRag.submit({
|
if (multiRoundRagSelected) {
|
||||||
targetThreadId,
|
await multiRoundRag.submit({ targetThreadId, query: message.text });
|
||||||
query: message.text,
|
} else {
|
||||||
modelName: settings.context.model_name ?? "OpenAI3",
|
await singleRoundRag.submit({
|
||||||
});
|
targetThreadId,
|
||||||
|
query: message.text,
|
||||||
|
modelName: settings.context.model_name ?? "OpenAI3",
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。");
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: multiRoundRagSelected
|
||||||
|
? "多轮 RAG 问答失败。"
|
||||||
|
: "单轮 RAG 问答失败。",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return;
|
return;
|
||||||
@@ -483,6 +515,9 @@ export default function ChatPage() {
|
|||||||
writingArtifactPath,
|
writingArtifactPath,
|
||||||
singleRoundRag,
|
singleRoundRag,
|
||||||
singleRoundRagSelected,
|
singleRoundRagSelected,
|
||||||
|
multiRoundRag,
|
||||||
|
multiRoundRagSelected,
|
||||||
|
frontendRagSelected,
|
||||||
routeBase,
|
routeBase,
|
||||||
setIsNewThread,
|
setIsNewThread,
|
||||||
setThreadId,
|
setThreadId,
|
||||||
@@ -580,8 +615,12 @@ export default function ChatPage() {
|
|||||||
singleRoundRag.stop();
|
singleRoundRag.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (multiRoundRag.isLoading) {
|
||||||
|
multiRoundRag.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await thread.stop();
|
await thread.stop();
|
||||||
}, [singleRoundRag, thread]);
|
}, [multiRoundRag, singleRoundRag, thread]);
|
||||||
|
|
||||||
const promptPrefixProp = useMemo(() => {
|
const promptPrefixProp = useMemo(() => {
|
||||||
if (!promptPrefixSettings?.enabled) return undefined;
|
if (!promptPrefixSettings?.enabled) return undefined;
|
||||||
@@ -776,13 +815,18 @@ export default function ChatPage() {
|
|||||||
: "ready"
|
: "ready"
|
||||||
}
|
}
|
||||||
context={settings.context}
|
context={settings.context}
|
||||||
extraModels={SINGLE_ROUND_RAG_MODELS}
|
extraModels={
|
||||||
|
singleRoundRagSelected
|
||||||
|
? SINGLE_ROUND_RAG_MODELS
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
disabled={
|
disabled={
|
||||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||||
isUploading ||
|
isUploading ||
|
||||||
agentHandoffPending ||
|
agentHandoffPending ||
|
||||||
knowledgeSelectionLoading ||
|
knowledgeSelectionLoading ||
|
||||||
singleRoundRag.isLoading
|
singleRoundRag.isLoading ||
|
||||||
|
multiRoundRag.isLoading
|
||||||
}
|
}
|
||||||
onContextChange={(context) =>
|
onContextChange={(context) =>
|
||||||
setSettings("context", 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 COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
|
||||||
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
|
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`
|
* Some intranet gateways only forward the long-established `/api/multi-agent`
|
||||||
* namespace. The position workspace was added later under its own top-level
|
* 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`, {
|
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ chain_id: chainId }),
|
body: JSON.stringify({ chain_id: chainId, command_id: newCommandId() }),
|
||||||
}),
|
}),
|
||||||
"activate position session",
|
"activate position session",
|
||||||
);
|
);
|
||||||
@@ -417,6 +463,7 @@ export async function bindPositionNodeThread(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
nodeKey: string,
|
nodeKey: string,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
|
commandId?: string,
|
||||||
): Promise<PositionRoundtableNode> {
|
): Promise<PositionRoundtableNode> {
|
||||||
const result = await readJson<ServerNode>(
|
const result = await readJson<ServerNode>(
|
||||||
await positionApiFetch(
|
await positionApiFetch(
|
||||||
@@ -424,7 +471,7 @@ export async function bindPositionNodeThread(
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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",
|
"bind position node thread",
|
||||||
@@ -438,6 +485,7 @@ export async function savePositionNodeConversation(
|
|||||||
input: {
|
input: {
|
||||||
threadId?: string | null;
|
threadId?: string | null;
|
||||||
conversationSnapshot: PositionConversationSnapshot;
|
conversationSnapshot: PositionConversationSnapshot;
|
||||||
|
commandId?: string;
|
||||||
},
|
},
|
||||||
): Promise<PositionRoundtableNode> {
|
): Promise<PositionRoundtableNode> {
|
||||||
const result = await readJson<ServerNode>(
|
const result = await readJson<ServerNode>(
|
||||||
@@ -449,6 +497,7 @@ export async function savePositionNodeConversation(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
thread_id: input.threadId || null,
|
thread_id: input.threadId || null,
|
||||||
conversation_snapshot: input.conversationSnapshot,
|
conversation_snapshot: input.conversationSnapshot,
|
||||||
|
command_id: input.commandId ?? newCommandId(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -533,6 +582,8 @@ export async function completePositionNodeTurn(
|
|||||||
latestAnswer: string;
|
latestAnswer: string;
|
||||||
artifactManifest?: Array<Record<string, unknown>>;
|
artifactManifest?: Array<Record<string, unknown>>;
|
||||||
upstreamRevision?: Record<string, unknown> | null;
|
upstreamRevision?: Record<string, unknown> | null;
|
||||||
|
/** Retry-stable idempotency key; omit to auto-generate per call. */
|
||||||
|
commandId?: string;
|
||||||
},
|
},
|
||||||
): Promise<PositionRoundtableNode> {
|
): Promise<PositionRoundtableNode> {
|
||||||
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
|
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
|
||||||
@@ -546,6 +597,7 @@ export async function completePositionNodeTurn(
|
|||||||
latest_answer: input.latestAnswer,
|
latest_answer: input.latestAnswer,
|
||||||
artifact_manifest: input.artifactManifest ?? [],
|
artifact_manifest: input.artifactManifest ?? [],
|
||||||
upstream_revision: input.upstreamRevision ?? null,
|
upstream_revision: input.upstreamRevision ?? null,
|
||||||
|
command_id: input.commandId ?? newCommandId(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -557,7 +609,7 @@ export async function completePositionNodeTurn(
|
|||||||
export async function rejectPositionNode(
|
export async function rejectPositionNode(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
nodeKey: string,
|
nodeKey: string,
|
||||||
input: { reason?: string; title?: string },
|
input: { reason?: string; title?: string; commandId?: string },
|
||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await positionApiFetch(
|
await positionApiFetch(
|
||||||
@@ -570,6 +622,7 @@ export async function rejectPositionNode(
|
|||||||
// workspace deliberately sends only an optional reason.
|
// workspace deliberately sends only an optional reason.
|
||||||
...(input.title ? { title: input.title } : {}),
|
...(input.title ? { title: input.title } : {}),
|
||||||
reason: input.reason ?? "",
|
reason: input.reason ?? "",
|
||||||
|
command_id: input.commandId ?? newCommandId(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
bindPositionNodeThread,
|
bindPositionNodeThread,
|
||||||
completePositionNodeTurn,
|
completePositionNodeTurn,
|
||||||
getPositionNodeContext,
|
getPositionNodeContext,
|
||||||
|
retryWithSameCommandId,
|
||||||
savePositionNodeConversation,
|
savePositionNodeConversation,
|
||||||
type PositionRoundtableNode,
|
type PositionRoundtableNode,
|
||||||
} from "@/position-roundtable/api/sessions";
|
} from "@/position-roundtable/api/sessions";
|
||||||
@@ -530,17 +531,26 @@ export function PositionNodeChat({
|
|||||||
// Artifact listing is best-effort: a valid text answer must still be
|
// Artifact listing is best-effort: a valid text answer must still be
|
||||||
// persisted and unlock downstream work even if files cannot be read.
|
// 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,
|
latestAnswer,
|
||||||
artifactManifest,
|
artifactManifest,
|
||||||
upstreamRevision: upstreamRevisionRef.current,
|
upstreamRevision: upstreamRevisionRef.current,
|
||||||
});
|
commandId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (delivery.ok) {
|
||||||
|
const updated = delivery.result;
|
||||||
onNodeUpdate(updated);
|
onNodeUpdate(updated);
|
||||||
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
|
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
|
||||||
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
|
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} else {
|
||||||
|
const error = delivery.error;
|
||||||
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
|
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -665,14 +675,19 @@ export function PositionNodeChat({
|
|||||||
[messageId]: textOfMessage(updatedMessage) ?? content,
|
[messageId]: textOfMessage(updatedMessage) ?? content,
|
||||||
}));
|
}));
|
||||||
if (updatedMessage.type === "ai" && messageId === latestAiMessageId) {
|
if (updatedMessage.type === "ai" && messageId === latestAiMessageId) {
|
||||||
try {
|
// 与自动交付路径同样:同一 command_id 重试,幂等保证不重复推进状态机。
|
||||||
const updatedNode = await completePositionNodeTurn(sessionId, node.nodeKey, {
|
const sync = await retryWithSameCommandId((commandId) =>
|
||||||
|
completePositionNodeTurn(sessionId, node.nodeKey, {
|
||||||
latestAnswer: content,
|
latestAnswer: content,
|
||||||
artifactManifest: node.artifactManifest,
|
artifactManifest: node.artifactManifest,
|
||||||
upstreamRevision: node.upstreamRevision,
|
upstreamRevision: node.upstreamRevision,
|
||||||
});
|
commandId,
|
||||||
onNodeUpdate(updatedNode);
|
}),
|
||||||
} catch (syncError) {
|
);
|
||||||
|
if (sync.ok) {
|
||||||
|
onNodeUpdate(sync.result);
|
||||||
|
} else {
|
||||||
|
const syncError = sync.error;
|
||||||
toast.warning(
|
toast.warning(
|
||||||
`消息已保存,但节点交接摘要同步失败:${
|
`消息已保存,但节点交接摘要同步失败:${
|
||||||
syncError instanceof Error ? syncError.message : String(syncError)
|
syncError instanceof Error ? syncError.message : String(syncError)
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ export interface DraftMeta {
|
|||||||
/** Auto-derived task summary (objective → first user message → timestamp). */
|
/** Auto-derived task summary (objective → first user message → timestamp). */
|
||||||
title: string;
|
title: string;
|
||||||
furthestStep: 1 | 2 | 3;
|
furthestStep: 1 | 2 | 3;
|
||||||
|
/**
|
||||||
|
* 乐观锁版本号:写回时经 `expectedVersion` 回传;并发冲突(409)时用它识别
|
||||||
|
* 本地副本已过期,重拉最新草稿合并后重试。
|
||||||
|
*/
|
||||||
|
version: number;
|
||||||
/**
|
/**
|
||||||
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
|
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
|
||||||
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
|
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
|
||||||
@@ -234,6 +239,33 @@ export interface DraftUpdatePayload {
|
|||||||
step1?: DraftStep1Snapshot;
|
step1?: DraftStep1Snapshot;
|
||||||
step2?: DraftStep2Snapshot;
|
step2?: DraftStep2Snapshot;
|
||||||
step3?: DraftStep3Snapshot | null;
|
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`;
|
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
|
||||||
@@ -256,6 +288,7 @@ export interface ServerDraftMeta {
|
|||||||
task_id?: string | null;
|
task_id?: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
furthest_step: number;
|
furthest_step: number;
|
||||||
|
version?: number;
|
||||||
chain_title?: string | null;
|
chain_title?: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -276,6 +309,7 @@ export function toMeta(s: ServerDraftMeta): DraftMeta {
|
|||||||
taskId: s.task_id ?? null,
|
taskId: s.task_id ?? null,
|
||||||
title: s.title ?? "",
|
title: s.title ?? "",
|
||||||
furthestStep: step,
|
furthestStep: step,
|
||||||
|
version: s.version ?? 0,
|
||||||
chainTitle: s.chain_title ?? null,
|
chainTitle: s.chain_title ?? null,
|
||||||
createdAt: s.created_at ?? "",
|
createdAt: s.created_at ?? "",
|
||||||
updatedAt: s.updated_at ?? "",
|
updatedAt: s.updated_at ?? "",
|
||||||
@@ -313,6 +347,7 @@ export function fromUpdate(p: DraftUpdatePayload) {
|
|||||||
if (p.step1 !== undefined) out.step1 = p.step1;
|
if (p.step1 !== undefined) out.step1 = p.step1;
|
||||||
if (p.step2 !== undefined) out.step2 = p.step2;
|
if (p.step2 !== undefined) out.step2 = p.step2;
|
||||||
if (p.step3 !== undefined) out.step3 = p.step3;
|
if (p.step3 !== undefined) out.step3 = p.step3;
|
||||||
|
if (p.expectedVersion !== undefined) out.expected_version = p.expectedVersion;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +401,23 @@ export async function createDraft(payload: DraftCreatePayload): Promise<DraftRec
|
|||||||
return record;
|
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> {
|
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
|
||||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -373,6 +425,7 @@ export async function updateDraft(id: string, patch: DraftUpdatePayload): Promis
|
|||||||
body: JSON.stringify(fromUpdate(patch)),
|
body: JSON.stringify(fromUpdate(patch)),
|
||||||
});
|
});
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
|
await throwIfDraftConflict(res, id);
|
||||||
const data = await readJson<ServerDraftFull>(res, "update draft");
|
const data = await readJson<ServerDraftFull>(res, "update draft");
|
||||||
const record = toRecord(data);
|
const record = toRecord(data);
|
||||||
writeYLog({
|
writeYLog({
|
||||||
|
|||||||
@@ -190,6 +190,12 @@ export interface StreamIntentRequest {
|
|||||||
model?: string;
|
model?: string;
|
||||||
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
|
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
|
||||||
intentAgentId?: string;
|
intentAgentId?: string;
|
||||||
|
/**
|
||||||
|
* 本回合幂等键(Phase 4 单飞):同一 (threadId, clientTurnId) 在后端至多一个 run。
|
||||||
|
* 缺省由调用方(useStep1Intent)每条消息生成;同一回合的重试 / 双击复用它,后端据此
|
||||||
|
* 去重(在跑→丢弃重复请求 / 已完成→幂等回放),杜绝并发下双发意图 / 重复澄清卡。
|
||||||
|
*/
|
||||||
|
clientTurnId?: string;
|
||||||
/** Optional hidden business/task context injected into the model run. */
|
/** Optional hidden business/task context injected into the model run. */
|
||||||
taskContext?: Record<string, unknown> | null;
|
taskContext?: Record<string, unknown> | null;
|
||||||
/** Step 1 用户上传的参考文件(可选)。 */
|
/** Step 1 用户上传的参考文件(可选)。 */
|
||||||
@@ -403,6 +409,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
|||||||
model: req.model,
|
model: req.model,
|
||||||
intent_agent_id: req.intentAgentId || undefined,
|
intent_agent_id: req.intentAgentId || undefined,
|
||||||
task_context: req.taskContext ?? 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,
|
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 { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||||
@@ -87,6 +89,9 @@ function toStartBody(p: StartJobPayload) {
|
|||||||
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
|
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
|
||||||
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
|
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
|
||||||
return {
|
return {
|
||||||
|
// 幂等请求 id:缺省现生成;「同一草稿至多一个活跃作业」由后端作用域唯一键保证,
|
||||||
|
// requestId 仅作审计/排查线索(重试时复用可让后端识别「同 id 但入参变了」的异常重试)。
|
||||||
|
requestId: p.requestId ?? nanoid(),
|
||||||
draftId: p.draftId ?? null,
|
draftId: p.draftId ?? null,
|
||||||
// taskId 非空 → task 作业(按 task 共享、不分权,报告写回独立 task-draft 存储)。
|
// taskId 非空 → task 作业(按 task 共享、不分权,报告写回独立 task-draft 存储)。
|
||||||
...(p.taskId ? { taskId: p.taskId } : {}),
|
...(p.taskId ? { taskId: p.taskId } : {}),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
fromCreate,
|
fromCreate,
|
||||||
fromUpdate,
|
fromUpdate,
|
||||||
readJson,
|
readJson,
|
||||||
|
throwIfDraftConflict,
|
||||||
toMeta,
|
toMeta,
|
||||||
toRecord,
|
toRecord,
|
||||||
type DraftCreatePayload,
|
type DraftCreatePayload,
|
||||||
@@ -103,6 +104,7 @@ export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Pr
|
|||||||
body: JSON.stringify(fromUpdate(patch)),
|
body: JSON.stringify(fromUpdate(patch)),
|
||||||
});
|
});
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
|
await throwIfDraftConflict(res, id);
|
||||||
const data = await readJson<ServerDraftFull>(res, "update task draft");
|
const data = await readJson<ServerDraftFull>(res, "update task draft");
|
||||||
const record = toRecord(data);
|
const record = toRecord(data);
|
||||||
writeYLog({
|
writeYLog({
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import {
|
|||||||
deleteDraft,
|
deleteDraft,
|
||||||
deriveDraftName,
|
deriveDraftName,
|
||||||
getDraft,
|
getDraft,
|
||||||
|
isDraftVersionConflictError,
|
||||||
listDrafts,
|
listDrafts,
|
||||||
updateDraft,
|
updateDraft,
|
||||||
type DraftCreatePayload,
|
type DraftCreatePayload,
|
||||||
type DraftRecord,
|
type DraftRecord,
|
||||||
|
type DraftStep2RunSnapshot,
|
||||||
type DraftStep2Snapshot,
|
type DraftStep2Snapshot,
|
||||||
type DraftStep3Snapshot,
|
type DraftStep3Snapshot,
|
||||||
type DraftUpdatePayload,
|
type DraftUpdatePayload,
|
||||||
@@ -42,6 +44,34 @@ import {
|
|||||||
} from "@/roundtable-planning/api/task-drafts";
|
} from "@/roundtable-planning/api/task-drafts";
|
||||||
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
|
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`)。 */
|
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
|
||||||
export interface DraftListItem {
|
export interface DraftListItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -198,6 +228,10 @@ export function useDraftPersistence(
|
|||||||
|
|
||||||
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
|
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
|
||||||
const currentDraftIdRef = useRef<string | null>(null);
|
const currentDraftIdRef = useRef<string | null>(null);
|
||||||
|
// 当前草稿的乐观锁版本号(本地副本基于的 server version)。update 时经 expectedVersion
|
||||||
|
// 回传;冲突(409)后重拉最新草稿、合并本地改动、用新版本号重试。load/create/update 成功
|
||||||
|
// 后同步刷新,保证下一次写基于最新版本。
|
||||||
|
const draftVersionRef = useRef<number | null>(null);
|
||||||
// 串行化所有写操作,避免并发 create。
|
// 串行化所有写操作,避免并发 create。
|
||||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||||
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
|
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
|
||||||
@@ -207,10 +241,12 @@ export function useDraftPersistence(
|
|||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
|
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
|
||||||
|
// 切换到不同草稿(或清空)时把乐观锁版本一并复位,防止拿旧草稿的版本号去写新草稿。
|
||||||
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
|
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
|
||||||
(action) => {
|
(action) => {
|
||||||
setCurrentDraftIdState((prev) => {
|
setCurrentDraftIdState((prev) => {
|
||||||
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
|
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
|
||||||
|
if (next !== currentDraftIdRef.current) draftVersionRef.current = null;
|
||||||
currentDraftIdRef.current = next;
|
currentDraftIdRef.current = next;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -273,14 +309,40 @@ export function useDraftPersistence(
|
|||||||
|
|
||||||
const id = currentDraftIdRef.current;
|
const id = currentDraftIdRef.current;
|
||||||
if (id) {
|
if (id) {
|
||||||
const updated = await apiUpdate(id, {
|
try {
|
||||||
...(furthestStep !== undefined ? { furthestStep } : {}),
|
const updated = await apiUpdate(id, {
|
||||||
step1,
|
...(furthestStep !== undefined ? { furthestStep } : {}),
|
||||||
step2,
|
step1,
|
||||||
...(step3 ? { step3 } : {}),
|
step2,
|
||||||
});
|
...(step3 ? { step3 } : {}),
|
||||||
if (updated) return updated.id;
|
...(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({
|
const created = await apiCreate({
|
||||||
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
||||||
@@ -294,6 +356,7 @@ export function useDraftPersistence(
|
|||||||
...(step3 ? { step3 } : {}),
|
...(step3 ? { step3 } : {}),
|
||||||
});
|
});
|
||||||
currentDraftIdRef.current = created.id;
|
currentDraftIdRef.current = created.id;
|
||||||
|
draftVersionRef.current = created.version;
|
||||||
setCurrentDraftId(created.id);
|
setCurrentDraftId(created.id);
|
||||||
return created.id;
|
return created.id;
|
||||||
};
|
};
|
||||||
@@ -301,7 +364,7 @@ export function useDraftPersistence(
|
|||||||
saveQueueRef.current = next.catch(() => undefined);
|
saveQueueRef.current = next.catch(() => undefined);
|
||||||
return next as Promise<string | null>;
|
return next as Promise<string | null>;
|
||||||
},
|
},
|
||||||
[apiCreate, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
|
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ensureDraft = useCallback(
|
const ensureDraft = useCallback(
|
||||||
@@ -340,12 +403,35 @@ export function useDraftPersistence(
|
|||||||
const run = async (): Promise<string | null> => {
|
const run = async (): Promise<string | null> => {
|
||||||
const id = currentDraftIdRef.current;
|
const id = currentDraftIdRef.current;
|
||||||
if (id) {
|
if (id) {
|
||||||
const updated = await apiUpdate(id, { furthestStep, step2 });
|
try {
|
||||||
if (updated) {
|
const updated = await apiUpdate(id, {
|
||||||
currentDraftIdRef.current = updated.id;
|
furthestStep,
|
||||||
return updated.id;
|
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();
|
const step1 = getStep1Snapshot();
|
||||||
if (!hasSavableContent(step1, step2)) return null;
|
if (!hasSavableContent(step1, step2)) return null;
|
||||||
@@ -360,6 +446,7 @@ export function useDraftPersistence(
|
|||||||
step2,
|
step2,
|
||||||
});
|
});
|
||||||
currentDraftIdRef.current = created.id;
|
currentDraftIdRef.current = created.id;
|
||||||
|
draftVersionRef.current = created.version;
|
||||||
setCurrentDraftId(created.id);
|
setCurrentDraftId(created.id);
|
||||||
return created.id;
|
return created.id;
|
||||||
};
|
};
|
||||||
@@ -367,7 +454,7 @@ export function useDraftPersistence(
|
|||||||
saveQueueRef.current = next.catch(() => undefined);
|
saveQueueRef.current = next.catch(() => undefined);
|
||||||
return next as Promise<string | null>;
|
return next as Promise<string | null>;
|
||||||
},
|
},
|
||||||
[apiCreate, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
|
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleManualSave = useCallback(() => {
|
const handleManualSave = useCallback(() => {
|
||||||
@@ -463,6 +550,8 @@ export function useDraftPersistence(
|
|||||||
setStep2InitGate(draft.furthestStep >= 2);
|
setStep2InitGate(draft.furthestStep >= 2);
|
||||||
|
|
||||||
setCurrentDraftId(draft.id);
|
setCurrentDraftId(draft.id);
|
||||||
|
// 必须在 setCurrentDraftId 之后:切换草稿会先把版本复位,这里再打上加载到的版本。
|
||||||
|
draftVersionRef.current = draft.version;
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
prevStepRef.current = draft.furthestStep;
|
prevStepRef.current = draft.furthestStep;
|
||||||
setCurrentStep(draft.furthestStep);
|
setCurrentStep(draft.furthestStep);
|
||||||
@@ -563,8 +652,12 @@ export function useDraftPersistence(
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
|
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
|
||||||
|
// 不带 expectedVersion:部分更新不触碰内容字段,与自动保存并发时双方各写各的
|
||||||
|
// 字段;自动保存侧撞 409 会重拉合并重试(标题被保留),一致性由 DB 版本保证。
|
||||||
const updated = await apiUpdate(id, { title: trimmed });
|
const updated = await apiUpdate(id, { title: trimmed });
|
||||||
if (updated) {
|
if (updated) {
|
||||||
|
// 重命名的正是当前草稿 → 同步版本号,省掉下一次自动保存的一次 409 往返。
|
||||||
|
if (id === currentDraftIdRef.current) draftVersionRef.current = updated.version;
|
||||||
await refreshList();
|
await refreshList();
|
||||||
triggerToast("已重命名");
|
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({
|
const res = await streamIntent({
|
||||||
threadId: intentThreadId,
|
threadId: intentThreadId,
|
||||||
message: trimmed,
|
message: trimmed,
|
||||||
model: selectedModelRef.current || undefined,
|
model: selectedModelRef.current || undefined,
|
||||||
intentAgentId: intentAgentIdRef.current,
|
intentAgentId: intentAgentIdRef.current,
|
||||||
taskContext: taskContextRef.current,
|
taskContext: taskContextRef.current,
|
||||||
|
clientTurnId,
|
||||||
files: fileRefs,
|
files: fileRefs,
|
||||||
signal: aborter.signal,
|
signal: aborter.signal,
|
||||||
onTextDelta: (delta) => {
|
onTextDelta: (delta) => {
|
||||||
|
|||||||
@@ -153,6 +153,13 @@ export interface StartJobPayload {
|
|||||||
* 普通会商省略。`toStartBody` 里下发为后端 `business_code`。
|
* 普通会商省略。`toStartBody` 里下发为后端 `business_code`。
|
||||||
*/
|
*/
|
||||||
businessCode?: string | null;
|
businessCode?: string | null;
|
||||||
|
/**
|
||||||
|
* 幂等请求 id(Phase 3 并发根治)。缺省由 `toStartBody` 用 nanoid 现生成;调用方若想在
|
||||||
|
* 逻辑重试间复用同一 id(便于后端审计「同 requestId 但入参变了」的异常重试),可显式传入。
|
||||||
|
* 「同一草稿至多一个活跃作业」的真正互斥由后端按 `(draftId/userId/taskId)` 作用域的唯一活跃键
|
||||||
|
* 保证,requestId 仅作审计/排查线索,不承担正确性。
|
||||||
|
*/
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 终态集合——SSE/轮询判定作业是否结束。 */
|
/** 终态集合——SSE/轮询判定作业是否结束。 */
|
||||||
|
|||||||
@@ -64,3 +64,6 @@ ROUNDTABLE_JOB_GATEWAY=inprocess
|
|||||||
DEEPSEEK_CHAT_API_KEY=sk-686b7bc000be4b7cbed6dccb3897c411
|
DEEPSEEK_CHAT_API_KEY=sk-686b7bc000be4b7cbed6dccb3897c411
|
||||||
GLM5_FP8_API_KEY=modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
GLM5_FP8_API_KEY=modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||||
SILICONFLOW_API_KEY=sk-ighylafootabtuthwueirmoycccyqtdgbifyhozduvolkdyt
|
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
|
Clients may retry the compatibility route only for a framework/proxy-level
|
||||||
`404 Not Found`; domain-level missing-session responses must remain real 404s.
|
`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 selects the separately seeded built-in agent
|
||||||
`position-roundtable-intent` by passing `intent_agent_id` to the shared
|
`position-roundtable-intent` by passing `intent_agent_id` to the shared
|
||||||
`/api/intent/init` and `/api/intent/stream` endpoints. Its request lifecycle,
|
`/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.
|
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.
|
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
|
### 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)
|
app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
from deerflow.persistence.roundtable_chains import make_roundtable_chain_store
|
||||||
|
|
||||||
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
app.state.roundtable_chain_store = make_roundtable_chain_store(sf)
|
||||||
|
|
||||||
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
# 独立的岗位协同 Session / Node 存储。它只索引业务流程状态,真实消息和
|
||||||
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
# 文件仍复用 LangGraph thread/checkpoint 与现有 artifact API。
|
||||||
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
from deerflow.persistence.position_roundtable import make_position_roundtable_store
|
||||||
|
|
||||||
app.state.position_roundtable_store = make_position_roundtable_store(sf)
|
app.state.position_roundtable_store = make_position_roundtable_store(sf)
|
||||||
|
|
||||||
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
|
# 岗位会商工作视角(名称 / 顺序 / 特殊意图岗)是全局配置,和组织/RBAC
|
||||||
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
|
# 岗位分开保存。启动只补齐缺失内置项,不覆盖管理员的既有修改。
|
||||||
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
|
from app.gateway.position_role_defaults import POSITION_ROLE_SEED
|
||||||
from deerflow.persistence.position_roles import make_position_role_store
|
from deerflow.persistence.position_roles import make_position_role_store
|
||||||
|
|
||||||
app.state.position_role_store = make_position_role_store(sf)
|
app.state.position_role_store = make_position_role_store(sf)
|
||||||
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
|
await app.state.position_role_store.ensure_seed(POSITION_ROLE_SEED)
|
||||||
|
|
||||||
from deerflow.persistence.business_mapping import make_business_mapping_store
|
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)
|
app.state.dashboard_session_store = make_dashboard_session_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.task_buttons import make_task_button_store
|
from deerflow.persistence.task_buttons import make_task_button_store
|
||||||
|
|
||||||
app.state.task_button_store = make_task_button_store(sf)
|
app.state.task_button_store = make_task_button_store(sf)
|
||||||
|
|
||||||
from deerflow.integrations.weknora.runtime import register_llmwiki_store
|
from deerflow.integrations.weknora.runtime import register_llmwiki_store
|
||||||
from deerflow.persistence.llmwiki import make_llmwiki_store
|
from deerflow.persistence.llmwiki import make_llmwiki_store
|
||||||
|
|
||||||
app.state.llmwiki_store = make_llmwiki_store(sf)
|
app.state.llmwiki_store = make_llmwiki_store(sf)
|
||||||
register_llmwiki_store(app.state.llmwiki_store)
|
register_llmwiki_store(app.state.llmwiki_store)
|
||||||
|
|
||||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||||
|
|
||||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.task_reports import make_task_report_store
|
from deerflow.persistence.task_reports import make_task_report_store
|
||||||
|
|
||||||
app.state.task_report_store = make_task_report_store(sf)
|
app.state.task_report_store = make_task_report_store(sf)
|
||||||
|
|
||||||
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
|
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
|
||||||
|
|
||||||
# Task-scoped artifact submission status: shared by task; actor is audit-only.
|
# Task-scoped artifact submission status: shared by task; actor is audit-only.
|
||||||
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
|
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.parallel_agent_tasks import make_parallel_agent_task_store
|
||||||
|
|
||||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
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 后台编排图写进度用)。
|
# (Phase 2 后台编排图写进度用)。
|
||||||
_register_roundtable_job_default_store(app.state.roundtable_job_store)
|
_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:会商全流程各处报错 / 关键事件落库,供管理员诊断页筛选。
|
# 圆桌会商「诊断日志」store:会商全流程各处报错 / 关键事件落库,供管理员诊断页筛选。
|
||||||
# 同样登记进程级默认仓储——harness 侧编排引擎 / 进程内网关跨边界写诊断用。
|
# 同样登记进程级默认仓储——harness 侧编排引擎 / 进程内网关跨边界写诊断用。
|
||||||
from deerflow.persistence.roundtable_diagnostics import (
|
from deerflow.persistence.roundtable_diagnostics import (
|
||||||
@@ -393,18 +400,32 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# task 作业把最终报告写回**独立的** task-draft 存储(不分权)。
|
# task 作业把最终报告写回**独立的** task-draft 存储(不分权)。
|
||||||
task_draft_store=getattr(app.state, "roundtable_task_draft_store", None),
|
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:
|
else:
|
||||||
app.state.roundtable_job_executor = None
|
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 event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
|
|
||||||
# RunManager with store backing for persistence
|
# RunManager with store backing for persistence
|
||||||
app.state.run_manager = RunManager(store=app.state.run_store)
|
app.state.run_manager = RunManager(store=app.state.run_store)
|
||||||
from deerflow.runtime import make_redis_concurrency_gate
|
from deerflow.runtime import make_redis_concurrency_gate
|
||||||
|
|
||||||
app.state.concurrency_gate = make_redis_concurrency_gate(config)
|
app.state.concurrency_gate = make_redis_concurrency_gate(config)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -412,16 +433,19 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
from deerflow.persistence.notifications import set_notification_store
|
from deerflow.persistence.notifications import set_notification_store
|
||||||
|
|
||||||
set_notification_store(None)
|
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)
|
queue = getattr(app.state, "knowledge_auto_ingest_queue", None)
|
||||||
if queue is not None:
|
if queue is not None:
|
||||||
from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
|
from deerflow.knowledge.auto_ingest import set_auto_ingest_queue
|
||||||
|
|
||||||
await queue.stop()
|
await queue.stop()
|
||||||
set_auto_ingest_queue(None)
|
set_auto_ingest_queue(None)
|
||||||
await close_engine()
|
await close_engine()
|
||||||
gate = getattr(app.state, "concurrency_gate", None)
|
gate = getattr(app.state, "concurrency_gate", None)
|
||||||
if gate is not None:
|
if gate is not None:
|
||||||
await gate.aclose()
|
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_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer")
|
||||||
get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store")
|
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_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback")
|
||||||
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store")
|
||||||
|
|
||||||
|
|
||||||
def get_concurrency_gate(request: Request):
|
def get_concurrency_gate(request: Request):
|
||||||
"""Return the optional cross-worker concurrency gate."""
|
"""Return the optional cross-worker concurrency gate."""
|
||||||
return getattr(request.app.state, "concurrency_gate", None)
|
return getattr(request.app.state, "concurrency_gate", None)
|
||||||
|
|
||||||
|
|
||||||
def get_store(request: Request):
|
def get_store(request: Request):
|
||||||
@@ -624,34 +648,34 @@ _cached_local_provider: LocalAuthProvider | None = None
|
|||||||
_cached_repo: SQLiteUserRepository | None = None
|
_cached_repo: SQLiteUserRepository | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_request_access_token(request: Request) -> str | None:
|
def get_request_access_token(request: Request) -> str | None:
|
||||||
"""Extract access token from cookie or Authorization header.
|
"""Extract access token from cookie or Authorization header.
|
||||||
|
|
||||||
Priority:
|
Priority:
|
||||||
1. ``access_token`` cookie (browser session mode)
|
1. ``access_token`` cookie (browser session mode)
|
||||||
2. ``Authorization: Bearer <token>`` header (API client mode)
|
2. ``Authorization: Bearer <token>`` header (API client mode)
|
||||||
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
|
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
|
||||||
"""
|
"""
|
||||||
cookie_token = request.cookies.get("access_token")
|
cookie_token = request.cookies.get("access_token")
|
||||||
if cookie_token:
|
if cookie_token:
|
||||||
return cookie_token
|
return cookie_token
|
||||||
|
|
||||||
authorization = request.headers.get("authorization", "").strip()
|
authorization = request.headers.get("authorization", "").strip()
|
||||||
if authorization:
|
if authorization:
|
||||||
scheme, _, token = authorization.partition(" ")
|
scheme, _, token = authorization.partition(" ")
|
||||||
if scheme.lower() == "bearer":
|
if scheme.lower() == "bearer":
|
||||||
token = token.strip()
|
token = token.strip()
|
||||||
if token:
|
if token:
|
||||||
return token
|
return token
|
||||||
|
|
||||||
from app.gateway.proxy_path import app_relative_path
|
from app.gateway.proxy_path import app_relative_path
|
||||||
|
|
||||||
path = app_relative_path(request).rstrip("/")
|
path = app_relative_path(request).rstrip("/")
|
||||||
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
|
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
|
||||||
query_token = (request.query_params.get("access_token") or "").strip()
|
query_token = (request.query_params.get("access_token") or "").strip()
|
||||||
if query_token:
|
if query_token:
|
||||||
return query_token
|
return query_token
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_local_provider() -> LocalAuthProvider:
|
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid as _uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.agents.roundtable_orchestrator import (
|
from deerflow.agents.roundtable_orchestrator import (
|
||||||
@@ -27,10 +30,34 @@ from deerflow.agents.roundtable_orchestrator import (
|
|||||||
run_orchestration,
|
run_orchestration,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.roundtable_diagnostics import DiagnosticsRecorder, get_default_store
|
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
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)总开关。
|
# 「后台挂起」的 Step3 自动产出(方案总结报告 md + 结果绘制 HTML)总开关。
|
||||||
# 历史:2026-06-05 因后台 start_run 路径报告不稳定临时禁用;2026-06-12 定位到根因是
|
# 历史:2026-06-05 因后台 start_run 路径报告不稳定临时禁用;2026-06-12 定位到根因是
|
||||||
# ``_enforce_agent_access`` 对进程内虚拟 request 的 ``request.state`` 裸访问(AttributeError
|
# ``_enforce_agent_access`` 对进程内虚拟 request 的 ``request.state`` 裸访问(AttributeError
|
||||||
@@ -203,6 +230,115 @@ class StartParams:
|
|||||||
# 深链接业务码(rwfx→6BF 等)。透传给 run_orchestration → build_summary_prompt:写实业务额外注入
|
# 深链接业务码(rwfx→6BF 等)。透传给 run_orchestration → build_summary_prompt:写实业务额外注入
|
||||||
# 「业务链完整性核对」,让后台总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。
|
# 「业务链完整性核对」,让后台总结报告核对产出相对业务链完不完整。普通会商为空 → 不注入。
|
||||||
business_code: str | None = None
|
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 实例。
|
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
|
||||||
@@ -259,7 +395,10 @@ class RoundtableJobExecutor:
|
|||||||
thread_ids, coordinator_name = await init()
|
thread_ids, coordinator_name = await init()
|
||||||
try:
|
try:
|
||||||
await self._store.update_progress(
|
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
|
except Exception: # noqa: BLE001
|
||||||
logger.warning("persist thread_ids for job %s failed", params.job_id, exc_info=True)
|
logger.warning("persist thread_ids for job %s failed", params.job_id, exc_info=True)
|
||||||
@@ -277,22 +416,52 @@ class RoundtableJobExecutor:
|
|||||||
agents=params.agents,
|
agents=params.agents,
|
||||||
on_dialogue=_on_dialogue if (self._draft_store_for(params) and params.draft_id) else None,
|
on_dialogue=_on_dialogue if (self._draft_store_for(params) and params.draft_id) else None,
|
||||||
recorder=diag,
|
recorder=diag,
|
||||||
|
# Phase 3:进度落库带租约持有者 —— update_progress 租约门只放行持租者的写,
|
||||||
|
# 非持租者(被接管/已取消)的过期进度写被 DB 条件 UPDATE 拒绝。
|
||||||
|
lease_owner=params.lease_owner,
|
||||||
)
|
)
|
||||||
result = await run_orchestration(
|
# Phase 3 心跳续租:活着就持续把 lease_until 推后,保证本 worker 的租约不过期、
|
||||||
gateway=gateway,
|
# 别的 worker 抢不走;本协程随编排 task 生死(finally 里 cancel)。崩溃则心跳停,
|
||||||
progress=progress,
|
# 租约最长 _LEASE_TTL_SECONDS 后过期 → dispatcher 在其它 worker 接管。
|
||||||
agents=params.agents,
|
main_task = asyncio.current_task()
|
||||||
thread_ids=thread_ids,
|
heartbeat = (
|
||||||
coordinator_name=coordinator_name,
|
asyncio.create_task(self._heartbeat(params.job_id, params.lease_owner, main_task))
|
||||||
model=params.model,
|
if params.lease_owner
|
||||||
seed_message=params.seed_message,
|
else None
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
# 跨进程取消看门狗: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)。
|
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
|
||||||
await self._write_run_to_draft(
|
await self._write_run_to_draft(
|
||||||
params,
|
params,
|
||||||
@@ -302,16 +471,18 @@ class RoundtableJobExecutor:
|
|||||||
step3=result.step3,
|
step3=result.step3,
|
||||||
thread_ids=thread_ids,
|
thread_ids=thread_ids,
|
||||||
)
|
)
|
||||||
# step3 也挂 job(API / 弹窗用)。
|
# step3 也挂 job(API / 弹窗用)。带租约门:仅持租者可写。
|
||||||
if result.status == "done" and result.step3:
|
if result.status == "done" and result.step3:
|
||||||
try:
|
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
|
except Exception: # noqa: BLE001
|
||||||
logger.warning("persist step3 to job %s failed", params.job_id, exc_info=True)
|
logger.warning("persist step3 to job %s failed", params.job_id, exc_info=True)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛
|
# 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛
|
||||||
# CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由
|
# CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由
|
||||||
# 路由的 cancel 端点(executor.cancel + store.update_progress)权威写入。
|
# 取消看门狗(或租约过期后的 dispatcher 回收)权威写入。
|
||||||
logger.info("roundtable job %s cancelled", params.job_id)
|
logger.info("roundtable job %s cancelled", params.job_id)
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
|
except Exception as exc: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
|
||||||
@@ -324,6 +495,97 @@ class RoundtableJobExecutor:
|
|||||||
finally:
|
finally:
|
||||||
reset_current_user(token)
|
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(
|
async def _write_run_to_draft(
|
||||||
self,
|
self,
|
||||||
params: StartParams,
|
params: StartParams,
|
||||||
@@ -338,77 +600,106 @@ class RoundtableJobExecutor:
|
|||||||
|
|
||||||
读现有 step2 → 按 run_id=``job-<jobId>`` upsert(保留其它手动 run)→ 设为 activeRun
|
读现有 step2 → 按 run_id=``job-<jobId>`` upsert(保留其它手动 run)→ 设为 activeRun
|
||||||
→ update_draft。前端加载草稿即可像手动跑一样 hydrate 出对话。
|
→ 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)
|
draft_store = self._draft_store_for(params)
|
||||||
if draft_store is None or not params.draft_id:
|
if draft_store is None or not params.draft_id:
|
||||||
return
|
return
|
||||||
|
# 这些只依赖 params/dialogues,重试间不变——提前算好。
|
||||||
idx_by_agent = {a.agent_id: i for i, a in enumerate(params.agents)}
|
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]
|
s2_dialogues = [_to_step2_dialogue(d, idx_by_agent) for d in dialogues]
|
||||||
last_leader = next(
|
last_leader = next(
|
||||||
(d.get("content") for d in reversed(dialogues) if d.get("role") == "leader"), ""
|
(d.get("content") for d in reversed(dialogues) if d.get("role") == "leader"), ""
|
||||||
)
|
)
|
||||||
run_id = f"job-{params.job_id}"
|
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(
|
for attempt in range(_DRAFT_WRITE_RETRIES):
|
||||||
scope="background", stage="job", level="error", event="draft_write_failed",
|
# 读最新草稿(冲突重试时重新读,拿到别的小伙伴刚写的 runs)。
|
||||||
message=f"研讨结果写回草稿失败(status={status}):{exc}",
|
try:
|
||||||
detail={"type": type(exc).__name__, "error": str(exc)},
|
draft = await draft_store.get_draft(params.draft_id, params.user_id)
|
||||||
job_id=params.job_id, draft_id=params.draft_id, task_id=params.task_id, user_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:
|
def cancel(self, job_id: str) -> bool:
|
||||||
"""请求取消一个在跑的作业。返回是否命中在跑任务。"""
|
"""请求取消一个在跑的作业。返回是否命中在跑任务。"""
|
||||||
|
|||||||
@@ -52,10 +52,14 @@ SSE 协议(与前端 ``streamIntent`` 对齐)
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -89,6 +93,37 @@ from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/intent", tags=["intent"])
|
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/`` 一致。
|
# 内置 agent 目录名,须与 ``.deer-flow/agents/roundtable-intent/`` 一致。
|
||||||
INTENT_AGENT_ID = "roundtable-intent"
|
INTENT_AGENT_ID = "roundtable-intent"
|
||||||
_SUPPORTED_INTENT_AGENT_IDS = frozenset({INTENT_AGENT_ID, POSITION_INTENT_AGENT_ID})
|
_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 超时这种坑。
|
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
|
||||||
async with _make_loopback_client(request) as client:
|
async with _make_loopback_client(request) as client:
|
||||||
try:
|
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 工具调用上限;岗位会商允许一个
|
# 通用会商保持原来的 3 次 ask 工具调用上限;岗位会商允许一个
|
||||||
# AI turn 同时产生多张独立协助卡,所以必须按 AI 消息(一次
|
# AI turn 同时产生多张独立协助卡,所以必须按 AI 消息(一次
|
||||||
@@ -657,6 +746,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
"content": ready_text,
|
"content": ready_text,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
}
|
}
|
||||||
|
await _record_terminal(final_frame)
|
||||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -710,6 +800,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
"content": fallback_msg,
|
"content": fallback_msg,
|
||||||
"summary": None,
|
"summary": None,
|
||||||
}
|
}
|
||||||
|
await _record_terminal(final_frame)
|
||||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -724,6 +815,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
"clarifications": clarification_payloads,
|
"clarifications": clarification_payloads,
|
||||||
"summary": None,
|
"summary": None,
|
||||||
}
|
}
|
||||||
|
await _record_terminal(final_frame)
|
||||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -733,6 +825,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
"content": content,
|
"content": content,
|
||||||
"summary": None,
|
"summary": None,
|
||||||
}
|
}
|
||||||
|
await _record_terminal(final_frame)
|
||||||
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
@@ -766,6 +859,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
"summary": None,
|
"summary": None,
|
||||||
"error_detail": error_detail,
|
"error_detail": error_detail,
|
||||||
}
|
}
|
||||||
|
await _record_terminal(err)
|
||||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
diagnostic = _diagnose_exception(exc, context="intent_stream")
|
diagnostic = _diagnose_exception(exc, context="intent_stream")
|
||||||
@@ -781,6 +875,7 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
"summary": None,
|
"summary": None,
|
||||||
"error_detail": diagnostic,
|
"error_detail": diagnostic,
|
||||||
}
|
}
|
||||||
|
await _record_terminal(err)
|
||||||
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
|
|||||||
@@ -1116,9 +1116,16 @@ async def init_structure_thread(request: Request) -> dict[str, str]:
|
|||||||
|
|
||||||
# 回环 /runs/stream 在**响应头阶段**返回这些状态码时,视为「单 worker + 单 sqlite 锁被
|
# 回环 /runs/stream 在**响应头阶段**返回这些状态码时,视为「单 worker + 单 sqlite 锁被
|
||||||
# 并发打满」造成的瞬时失败,退避重试(详见 _stream_upstream 内注释):
|
# 并发打满」造成的瞬时失败,退避重试(详见 _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 等确定性错误——那些重试也没用,立即上报。
|
# 不含 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。
|
# 重试次数(不含首次)。总尝试 = 1 + _UPSTREAM_MAX_RETRIES。
|
||||||
_UPSTREAM_MAX_RETRIES = 2
|
_UPSTREAM_MAX_RETRIES = 2
|
||||||
# 指数退避基数(秒):0.4 → 0.8 → …,足够让在跑的席位 run 让出 sqlite 锁,又不至于明显拖慢。
|
# 指数退避基数(秒):0.4 → 0.8 → …,足够让在跑的席位 run 让出 sqlite 锁,又不至于明显拖慢。
|
||||||
|
|||||||
@@ -14,11 +14,28 @@ import asyncio
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from app.gateway.path_utils import aresolve_thread_virtual_path
|
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
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
# The application mounts this router below both `/api` (canonical) and the
|
# 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)
|
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
conversation_snapshot: dict[str, Any] | None = None
|
conversation_snapshot: dict[str, Any] | None = None
|
||||||
revision: int = 0
|
revision: int = 0
|
||||||
|
# 节点乐观锁版本号(任意字段变更自增;revision 仅计真实交付)。
|
||||||
|
version: int = 0
|
||||||
upstream_revision: dict[str, Any] | None = None
|
upstream_revision: dict[str, Any] | None = None
|
||||||
rejection_count: int = 0
|
rejection_count: int = 0
|
||||||
last_rejection: dict[str, Any] | None = None
|
last_rejection: dict[str, Any] | None = None
|
||||||
@@ -96,6 +115,8 @@ class SessionResponse(BaseModel):
|
|||||||
action_plan_snapshot: dict[str, Any] | None = None
|
action_plan_snapshot: dict[str, Any] | None = None
|
||||||
action_plan_conversation: dict[str, Any] | None = None
|
action_plan_conversation: dict[str, Any] | None = None
|
||||||
status: str
|
status: str
|
||||||
|
# Session 级乐观锁版本号:每次命令式写操作自增,前端可回传做 CAS。
|
||||||
|
version: int = 0
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_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)
|
thread_id: str | None = Field(default=None, max_length=128)
|
||||||
conversation_snapshot: dict[str, Any] = Field(default_factory=dict)
|
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):
|
class ActivateRequest(BaseModel):
|
||||||
chain_id: str = Field(min_length=1, max_length=64)
|
chain_id: str = Field(min_length=1, max_length=64)
|
||||||
|
command_id: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class NodeThreadRequest(BaseModel):
|
class NodeThreadRequest(BaseModel):
|
||||||
thread_id: str = Field(min_length=1, max_length=128)
|
thread_id: str = Field(min_length=1, max_length=128)
|
||||||
|
command_id: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class CompleteTurnRequest(BaseModel):
|
class CompleteTurnRequest(BaseModel):
|
||||||
latest_answer: str = Field(default="", max_length=500_000)
|
latest_answer: str = Field(default="", max_length=500_000)
|
||||||
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
upstream_revision: dict[str, Any] | None = None
|
upstream_revision: dict[str, Any] | None = None
|
||||||
|
command_id: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class RejectNodeRequest(BaseModel):
|
class RejectNodeRequest(BaseModel):
|
||||||
@@ -154,6 +181,7 @@ class RejectNodeRequest(BaseModel):
|
|||||||
# default so neither field turns a valid rejection into a 422/400.
|
# default so neither field turns a valid rejection into a 422/400.
|
||||||
title: str = Field(default="产物驳回", max_length=120)
|
title: str = Field(default="产物驳回", max_length=120)
|
||||||
reason: str = Field(default="", max_length=5_000)
|
reason: str = Field(default="", max_length=5_000)
|
||||||
|
command_id: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class ActionPlanDictionaryOption(BaseModel):
|
class ActionPlanDictionaryOption(BaseModel):
|
||||||
@@ -261,6 +289,38 @@ async def _require_writable_session(
|
|||||||
return store, user_id, 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:
|
def _project_session(row: dict[str, Any]) -> SessionResponse:
|
||||||
return SessionResponse(**row)
|
return SessionResponse(**row)
|
||||||
|
|
||||||
@@ -400,18 +460,15 @@ def _is_intent_position_node(
|
|||||||
node: dict[str, Any],
|
node: dict[str, Any],
|
||||||
intent_position_id: str | None = None,
|
intent_position_id: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
position_id = str(node.get("position_id") or "").strip()
|
"""Node-dict wrapper over the shared harness decision (see delivery.py)."""
|
||||||
if intent_position_id:
|
return _is_intent_position_by_id(
|
||||||
return position_id == intent_position_id
|
str(node.get("position_id") or "").strip(), 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"}
|
|
||||||
|
|
||||||
|
|
||||||
def _session_intent_position_id(session: dict[str, Any]) -> str | None:
|
def _session_intent_position_id(session: dict[str, Any]) -> str | None:
|
||||||
chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
|
"""Session-dict wrapper over the shared harness decision (see delivery.py)."""
|
||||||
value = chain.get("_intent_position_id")
|
return _chain_intent_position_id(session.get("chain_snapshot"))
|
||||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
|
||||||
|
|
||||||
|
|
||||||
async def _configured_intent_position_id(request: Request) -> str:
|
async def _configured_intent_position_id(request: Request) -> str:
|
||||||
@@ -437,62 +494,11 @@ async def _configured_intent_position_id(request: Request) -> str:
|
|||||||
return "intelligence"
|
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:
|
def _node_has_markdown_delivery(node: dict[str, Any]) -> bool:
|
||||||
artifacts = node.get("artifact_manifest")
|
artifacts = node.get("artifact_manifest")
|
||||||
return isinstance(artifacts, list) and any(_is_markdown_artifact(item) for item in artifacts)
|
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]:
|
def _intent_string_list(intent: dict[str, Any], key: str) -> list[str]:
|
||||||
"""Return a clean, display-safe string list from a persisted intent field."""
|
"""Return a clean, display-safe string list from a persisted intent field."""
|
||||||
value = intent.get(key)
|
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)}
|
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]]:
|
def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Persist display metadata plus any database recovery payload."""
|
"""Persist display metadata plus any database recovery payload."""
|
||||||
refs: list[dict[str, Any]] = []
|
refs: list[dict[str, Any]] = []
|
||||||
@@ -1276,7 +1262,8 @@ async def update_task_snapshot(
|
|||||||
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
|
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
|
||||||
) -> SessionResponse:
|
) -> SessionResponse:
|
||||||
store, user_id, current = await _require_writable_session(request, session_id)
|
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,
|
session_id,
|
||||||
user_id,
|
user_id,
|
||||||
task_snapshot=_task_snapshot_for_scope(
|
task_snapshot=_task_snapshot_for_scope(
|
||||||
@@ -1284,9 +1271,8 @@ async def update_task_snapshot(
|
|||||||
body.task_snapshot,
|
body.task_snapshot,
|
||||||
task_scoped=bool(current.get("task_scoped")),
|
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)
|
return _project_session(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -1295,15 +1281,15 @@ async def update_intent_snapshot(
|
|||||||
request: Request, session_id: str, body: IntentUpdateRequest
|
request: Request, session_id: str, body: IntentUpdateRequest
|
||||||
) -> SessionResponse:
|
) -> SessionResponse:
|
||||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||||
row = await store.update_session(
|
row = await _update_session_or_raise(
|
||||||
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
user_id,
|
user_id,
|
||||||
intent_thread_id=body.intent_thread_id,
|
intent_thread_id=body.intent_thread_id,
|
||||||
intent_snapshot=body.intent_snapshot,
|
intent_snapshot=body.intent_snapshot,
|
||||||
intent_conversation=body.conversation_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)
|
return _project_session(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -1312,14 +1298,13 @@ async def update_summary_conversation(
|
|||||||
request: Request, session_id: str, body: ConversationStateUpdateRequest
|
request: Request, session_id: str, body: ConversationStateUpdateRequest
|
||||||
) -> SessionResponse:
|
) -> SessionResponse:
|
||||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||||
row = await store.update_session(
|
row = await _update_session_or_raise(
|
||||||
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
user_id,
|
user_id,
|
||||||
summary_thread_id=body.thread_id,
|
summary_thread_id=body.thread_id,
|
||||||
summary_conversation=body.conversation_snapshot,
|
summary_conversation=body.conversation_snapshot,
|
||||||
)
|
)
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
|
||||||
return _project_session(row)
|
return _project_session(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -1328,14 +1313,13 @@ async def update_action_plan_conversation(
|
|||||||
request: Request, session_id: str, body: ConversationStateUpdateRequest
|
request: Request, session_id: str, body: ConversationStateUpdateRequest
|
||||||
) -> SessionResponse:
|
) -> SessionResponse:
|
||||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||||
row = await store.update_session(
|
row = await _update_session_or_raise(
|
||||||
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
user_id,
|
user_id,
|
||||||
action_plan_thread_id=body.thread_id,
|
action_plan_thread_id=body.thread_id,
|
||||||
action_plan_conversation=body.conversation_snapshot,
|
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)
|
return _project_session(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -1374,34 +1358,60 @@ async def activate_session(
|
|||||||
current.get("intent_snapshot"),
|
current.get("intent_snapshot"),
|
||||||
intent_position_id=intent_position_id,
|
intent_position_id=intent_position_id,
|
||||||
)
|
)
|
||||||
activated = await store.activate_session(
|
# 命令式状态机:锁 session + 锁内判定「已激活→链相同幂等/链不同 409」,
|
||||||
session_id,
|
# 消除原 activate_session 的 check-then-insert 竞态(并发双击 → 唯一键冲突 500)。
|
||||||
user_id,
|
result = _command_result_or_raise(
|
||||||
chain_id=body.chain_id,
|
await store.apply_session_command(
|
||||||
chain_snapshot=chain_snapshot,
|
session_id,
|
||||||
nodes=nodes,
|
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)
|
@router.post("/sessions/{session_id}/archive", response_model=SessionResponse)
|
||||||
async def archive_session(request: Request, session_id: str) -> SessionResponse:
|
async def archive_session(request: Request, session_id: str) -> SessionResponse:
|
||||||
row = await _get_store(request).archive_session(session_id, _current_user_id(request))
|
store = _get_store(request)
|
||||||
if row is None:
|
user_id = _current_user_id(request)
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
# 命令式状态机:已归档幂等返回,锁内串行化并发归档。每次操作一个稳定的
|
||||||
return _project_session(row)
|
# 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}")
|
@router.delete("/sessions/{session_id}")
|
||||||
async def delete_session(request: Request, session_id: str) -> dict[str, bool]:
|
async def delete_session(request: Request, session_id: str) -> dict[str, bool]:
|
||||||
deleted = await _get_store(request).delete_session(
|
store = _get_store(request)
|
||||||
session_id, _current_user_id(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}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -1418,23 +1428,19 @@ async def bind_node_thread(
|
|||||||
request: Request, session_id: str, node_key: str, body: NodeThreadRequest
|
request: Request, session_id: str, node_key: str, body: NodeThreadRequest
|
||||||
) -> NodeResponse:
|
) -> NodeResponse:
|
||||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
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:
|
# 原实现在事务外读取节点状态做判定(TOCTOU),并发下两个请求可同时通过校验。
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
result = _command_result_or_raise(
|
||||||
if current["status"] == "locked":
|
await store.apply_node_command(
|
||||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
session_id,
|
||||||
if current.get("invalidated_by"):
|
node_key,
|
||||||
raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
|
user_id,
|
||||||
if current.get("thread_id") and current["thread_id"] != body.thread_id:
|
command_id=_command_id_or_new(body.command_id),
|
||||||
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
|
command_type="bind",
|
||||||
row = await store.update_node(
|
payload={"thread_id": body.thread_id},
|
||||||
session_id,
|
)
|
||||||
node_key,
|
|
||||||
user_id,
|
|
||||||
thread_id=body.thread_id,
|
|
||||||
status="running" if current.get("status") == "ready" else current.get("status", "locked"),
|
|
||||||
)
|
)
|
||||||
return _project_node(row)
|
return _project_node(result["node"])
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
@@ -1448,25 +1454,21 @@ async def update_node_conversation(
|
|||||||
body: ConversationStateUpdateRequest,
|
body: ConversationStateUpdateRequest,
|
||||||
) -> NodeResponse:
|
) -> NodeResponse:
|
||||||
store, user_id, session = await _require_writable_session(request, session_id)
|
store, user_id, session = await _require_writable_session(request, session_id)
|
||||||
current = await store.get_node(session_id, node_key, user_id)
|
# 命令式状态机:线程归属校验移入 FOR UPDATE 锁内。
|
||||||
if current is None:
|
result = _command_result_or_raise(
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
await store.apply_node_command(
|
||||||
if (
|
session_id,
|
||||||
body.thread_id
|
node_key,
|
||||||
and current.get("thread_id")
|
user_id,
|
||||||
and current["thread_id"] != body.thread_id
|
command_id=_command_id_or_new(body.command_id),
|
||||||
):
|
command_type="conversation",
|
||||||
raise HTTPException(status_code=409, detail="Position node is bound to another thread")
|
payload={
|
||||||
row = await store.update_node(
|
"thread_id": body.thread_id,
|
||||||
session_id,
|
"conversation_snapshot": body.conversation_snapshot,
|
||||||
node_key,
|
},
|
||||||
user_id,
|
)
|
||||||
thread_id=body.thread_id if body.thread_id else current.get("thread_id"),
|
|
||||||
conversation_snapshot=body.conversation_snapshot,
|
|
||||||
)
|
)
|
||||||
if row is None:
|
return _project_node(result["node"])
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
|
||||||
return _project_node(row)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/nodes/{node_key}/context")
|
@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)
|
node = await store.get_node(session_id, node_key, user_id)
|
||||||
if node is None:
|
if node is None:
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||||
if node["status"] == "locked":
|
# 产物内容持久化是文件 IO,保持在数据库事务之外(避免长事务)。清单为空表示
|
||||||
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
|
# 「本轮无新清单」,由锁内回退到节点当前清单——绝不在事务外用过期快照兜底。
|
||||||
if node.get("invalidated_by"):
|
persisted: list[dict[str, Any]] | None = None
|
||||||
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 [])
|
|
||||||
if body.artifact_manifest:
|
if body.artifact_manifest:
|
||||||
effective_artifacts = await _persist_artifact_contents(
|
persisted = await _persist_artifact_contents(
|
||||||
node.get("thread_id"),
|
node.get("thread_id"),
|
||||||
effective_artifacts,
|
body.artifact_manifest,
|
||||||
)
|
)
|
||||||
has_markdown = any(_is_markdown_artifact(item) for item in effective_artifacts)
|
# 命令式状态机:能否完成 / 是否新交付 / revision+1 / 失效下游 / 解锁下一 stage,
|
||||||
markdown_changed = _markdown_delivery_changed(node.get("artifact_manifest"), effective_artifacts)
|
# 全部在单一 FOR UPDATE 事务内基于锁定行判定并原子落库(apply_node_command)。
|
||||||
requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
|
# 原实现先读快照算好 next_status 再进事务,两个并发请求会基于同一过期快照各自
|
||||||
next_status = (
|
# 判定,后写覆盖先写 → 下一 stage 卡 locked / invalidated_by 残留 → 断链。
|
||||||
"done"
|
result = _command_result_or_raise(
|
||||||
if _is_intent_position_node(node, _session_intent_position_id(session))
|
await store.apply_node_command(
|
||||||
else "done"
|
session_id,
|
||||||
if has_markdown and (not requires_fresh_delivery or markdown_changed)
|
node_key,
|
||||||
else "running"
|
user_id,
|
||||||
)
|
command_id=_command_id_or_new(body.command_id),
|
||||||
# Effective revisions are delivery revisions, not every conversational
|
command_type="complete",
|
||||||
# answer. This prevents a harmless follow-up on a completed seat from
|
payload={
|
||||||
# invalidating all later stages just because its old Markdown file still
|
"latest_answer": body.latest_answer,
|
||||||
# appears in the artifact list.
|
"artifact_manifest": persisted,
|
||||||
new_effective_delivery = (
|
"upstream_revision": body.upstream_revision,
|
||||||
next_status == "done"
|
},
|
||||||
and (
|
|
||||||
_is_intent_position_node(node, _session_intent_position_id(session))
|
|
||||||
or node.get("status") != "done"
|
|
||||||
or markdown_changed
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
row = await store.update_node(
|
return _project_node(result["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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/nodes/{node_key}/reject", response_model=SessionDetailResponse)
|
@router.post("/sessions/{session_id}/nodes/{node_key}/reject", response_model=SessionDetailResponse)
|
||||||
async def reject_node(
|
async def reject_node(
|
||||||
request: Request, session_id: str, node_key: str, body: RejectNodeRequest
|
request: Request, session_id: str, node_key: str, body: RejectNodeRequest
|
||||||
) -> SessionDetailResponse:
|
) -> SessionDetailResponse:
|
||||||
title = body.title.strip() or "产物驳回"
|
|
||||||
reason = body.reason.strip()
|
|
||||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||||
current = await store.get_node(session_id, node_key, user_id)
|
# 命令式状态机:可驳回性判定 + 驳回 + 下游失效在同一 FOR UPDATE 事务内完成,
|
||||||
if current is None:
|
# 并 bump session/node version。同 command_id 的重复驳回原样重放、不重复计数。
|
||||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
result = _command_result_or_raise(
|
||||||
if current["status"] == "locked":
|
await store.apply_node_command(
|
||||||
raise HTTPException(status_code=409, detail="当前节点尚未解锁,暂无可驳回的交付产物")
|
session_id,
|
||||||
if current.get("revision", 0) <= 0 and not current.get("latest_answer") and not current.get("artifact_manifest"):
|
node_key,
|
||||||
raise HTTPException(status_code=409, detail="当前节点暂无可驳回的交付产物")
|
user_id,
|
||||||
rejected = await store.reject_node(
|
command_id=_command_id_or_new(body.command_id),
|
||||||
session_id,
|
command_type="reject",
|
||||||
node_key,
|
payload={"title": body.title, "reason": body.reason},
|
||||||
user_id,
|
)
|
||||||
title=title,
|
)
|
||||||
reason=reason,
|
return SessionDetailResponse(
|
||||||
rejected_by=user_id,
|
**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(
|
async def _prepare_builtin_run(
|
||||||
@@ -1706,7 +1638,8 @@ async def _complete_builtin_run(
|
|||||||
_artifact_signature(previous_primary) != _artifact_signature(new_primary)
|
_artifact_signature(previous_primary) != _artifact_signature(new_primary)
|
||||||
)
|
)
|
||||||
if kind == "summary":
|
if kind == "summary":
|
||||||
row = await store.update_session(
|
row = await _update_session_or_raise(
|
||||||
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
user_id,
|
user_id,
|
||||||
summary_thread_id=body.thread_id,
|
summary_thread_id=body.thread_id,
|
||||||
@@ -1717,15 +1650,14 @@ async def _complete_builtin_run(
|
|||||||
status="active",
|
status="active",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
row = await store.update_session(
|
row = await _update_session_or_raise(
|
||||||
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
user_id,
|
user_id,
|
||||||
action_plan_thread_id=body.thread_id,
|
action_plan_thread_id=body.thread_id,
|
||||||
action_plan_snapshot=snapshot,
|
action_plan_snapshot=snapshot,
|
||||||
status="completed",
|
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 []
|
node_rows = await store.list_nodes(session_id, user_id) or []
|
||||||
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
|
||||||
|
|
||||||
|
|||||||
+40
-12
@@ -19,6 +19,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt, Stri
|
|||||||
|
|
||||||
from app.gateway.deps import get_current_user
|
from app.gateway.deps import get_current_user
|
||||||
from deerflow.persistence.roundtable_artifact_submissions import RoundtableArtifactSubmissionStore
|
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"])
|
router = APIRouter(prefix="/api/roundtable/tasks", tags=["roundtable-artifact-submissions"])
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ class ArtifactSubmissionRequest(BaseModel):
|
|||||||
submitted: bool
|
submitted: bool
|
||||||
submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id"))
|
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"))
|
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
|
artifact: ArtifactReferenceInput
|
||||||
|
|
||||||
@field_validator("task_id", "submission_id", "task_delivery_id", mode="before")
|
@field_validator("task_id", "submission_id", "task_delivery_id", mode="before")
|
||||||
@@ -82,6 +84,7 @@ class ArtifactSubmissionResponse(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
task_id: str = Field(serialization_alias="taskId")
|
task_id: str = Field(serialization_alias="taskId")
|
||||||
|
version: int = Field(default=0)
|
||||||
submitted: bool
|
submitted: bool
|
||||||
submitted_at: str | None = Field(default=None, serialization_alias="submittedAt")
|
submitted_at: str | None = Field(default=None, serialization_alias="submittedAt")
|
||||||
cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt")
|
cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt")
|
||||||
@@ -131,6 +134,7 @@ def _response(row: dict[str, Any]) -> ArtifactSubmissionResponse:
|
|||||||
return ArtifactSubmissionResponse(
|
return ArtifactSubmissionResponse(
|
||||||
id=str(row["id"]),
|
id=str(row["id"]),
|
||||||
task_id=str(row["task_id"]),
|
task_id=str(row["task_id"]),
|
||||||
|
version=int(row.get("version", 0)),
|
||||||
submitted=bool(row["submitted"]),
|
submitted=bool(row["submitted"]),
|
||||||
submitted_at=row.get("submitted_at"),
|
submitted_at=row.get("submitted_at"),
|
||||||
cancelled_at=row.get("cancelled_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.
|
# a thread reference a saved state could never be previewed correctly.
|
||||||
if not artifact["thread_id"]:
|
if not artifact["thread_id"]:
|
||||||
raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true")
|
raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true")
|
||||||
row = await store.submit(
|
try:
|
||||||
path_task_id,
|
row = await store.submit(
|
||||||
artifact,
|
path_task_id,
|
||||||
task_delivery_id=body.task_delivery_id,
|
artifact,
|
||||||
actor_id=actor_id,
|
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:
|
else:
|
||||||
row = await store.cancel(
|
try:
|
||||||
path_task_id,
|
row = await store.cancel(
|
||||||
artifact,
|
path_task_id,
|
||||||
submission_id=body.submission_id,
|
artifact,
|
||||||
actor_id=actor_id,
|
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:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Artifact submission not found")
|
raise HTTPException(status_code=404, detail="Artifact submission not found")
|
||||||
return _response(row)
|
return _response(row)
|
||||||
@@ -32,6 +32,7 @@ from uuid import uuid4
|
|||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -47,6 +48,8 @@ class DraftMetaResponse(BaseModel):
|
|||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
title: str = ""
|
title: str = ""
|
||||||
furthest_step: int = 1
|
furthest_step: int = 1
|
||||||
|
# 乐观锁版本号:PUT 时经 expected_version 回传,冲突 → 409 + current_version。
|
||||||
|
version: int = 0
|
||||||
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
|
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
|
||||||
chain_title: str | None = None
|
chain_title: str | None = None
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
@@ -88,6 +91,9 @@ class DraftUpdateRequest(BaseModel):
|
|||||||
step1: dict[str, Any] | None = None
|
step1: dict[str, Any] | None = None
|
||||||
step2: dict[str, Any] | None = None
|
step2: dict[str, Any] | None = None
|
||||||
step3: 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):
|
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
|
# Build kwargs that distinguish "omitted" from "explicitly None". The store
|
||||||
# uses a sentinel; here we pass only the fields the client actually sent.
|
# uses a sentinel; here we pass only the fields the client actually sent.
|
||||||
fields = body.model_dump(exclude_unset=True)
|
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:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Draft not found")
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
return DraftResponse(**row)
|
return DraftResponse(**row)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Routes(prefix ``/api/roundtable-jobs``)::
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -25,11 +26,11 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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.agents.roundtable_orchestrator import SeatRef
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
@@ -54,6 +55,10 @@ class JobAgent(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class StartJobRequest(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)
|
draftId: str | None = Field(default=None, max_length=64)
|
||||||
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
|
# 外部任务 id(taskId 深链会商)。非空 = task 作业:按 task_id 共享、不按 user 分权,
|
||||||
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
|
# 其 draft_id 指向独立的 roundtable_task_drafts 表(最终报告写回那里)。
|
||||||
@@ -151,6 +156,20 @@ def _get_executor(request: Request):
|
|||||||
return executor
|
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]]:
|
def _pending_nodes(agents: list[JobAgent]) -> list[dict[str, Any]]:
|
||||||
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
|
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
|
||||||
return [
|
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)
|
@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)
|
store = _get_store(request)
|
||||||
executor = _get_executor(request)
|
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
|
|
||||||
if not body.agents:
|
if not body.agents:
|
||||||
raise HTTPException(status_code=400, detail="agents is required")
|
raise HTTPException(status_code=400, detail="agents is required")
|
||||||
|
|
||||||
# 防重复挂起(服务端幂等闸门):同一草稿已有未终态作业(queued/running/awaiting_input)
|
|
||||||
# 时直接返回它、不再新建。前端的「手动挂起按钮 / 离开页面自动挂起 / beforeunload 刷新
|
|
||||||
# 兜底」可能对同一研讨多次触发本端点 —— 客户端去重只能挡住同页内的重复,跨触发源 /
|
|
||||||
# 跨标签页的并发只能靠这里兜住,否则同一研讨会被双跑。
|
|
||||||
task_id = (body.taskId or "").strip() or None
|
task_id = (body.taskId or "").strip() or None
|
||||||
if body.draftId:
|
draft_id = (body.draftId or "").strip() or None
|
||||||
# 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)
|
|
||||||
|
|
||||||
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
|
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
|
job_id = uuid4().hex
|
||||||
row = await store.create(
|
row, created = await store.try_create_or_get_active(
|
||||||
id=job_id,
|
draft_id=draft_id,
|
||||||
draft_id=body.draftId,
|
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
id=job_id,
|
||||||
orchestration_mode=body.orchestrationMode,
|
orchestration_mode=body.orchestrationMode,
|
||||||
coordinator_name=body.coordinatorName,
|
coordinator_name=body.coordinatorName,
|
||||||
thread_ids=body.threadIds,
|
thread_ids=body.threadIds,
|
||||||
@@ -227,32 +277,21 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
|
|||||||
orchestration_plan=body.orchestrationPlan,
|
orchestration_plan=body.orchestrationPlan,
|
||||||
dispatch_chain=_pending_nodes(body.agents),
|
dispatch_chain=_pending_nodes(body.agents),
|
||||||
status="queued",
|
status="queued",
|
||||||
|
request_id=request_id,
|
||||||
|
request_hash=_request_hash(body),
|
||||||
|
input_snapshot=snapshot,
|
||||||
)
|
)
|
||||||
|
if created:
|
||||||
executor.start_job(
|
# 新建(queued):交给 dispatcher 领取执行。
|
||||||
StartParams(
|
response.status_code = 201
|
||||||
job_id=job_id,
|
else:
|
||||||
user_id=user_id,
|
# 命中已有活跃作业:幂等返回(不重复建 / 不重复跑)。
|
||||||
agents=agents,
|
response.status_code = 200
|
||||||
seed_message=(body.seedMessage or body.intent or "").strip(),
|
# 任一分支都唤醒 dispatcher:新建需被领取;幂等命中时若既有作业还在 queued、未被任何
|
||||||
model=body.model,
|
# worker 领走(如 dispatcher 正在轮询间隙),nudge 确保它被立刻 claim,无感延迟。
|
||||||
mode=body.orchestrationMode,
|
dispatcher = _get_dispatcher(request)
|
||||||
intent_text=body.intent,
|
if dispatcher is not None:
|
||||||
thread_ids=body.threadIds,
|
dispatcher.nudge()
|
||||||
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),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return _to_response(row)
|
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(
|
async def resume_job(
|
||||||
request: Request, job_id: str, body: ResumeJobRequest, task_id: str | None = None
|
request: Request, job_id: str, body: ResumeJobRequest, task_id: str | None = None
|
||||||
) -> RoundtableJobResponse:
|
) -> 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)
|
store = _get_store(request)
|
||||||
executor = _get_executor(request)
|
|
||||||
tid = (task_id or "").strip() or None
|
tid = (task_id or "").strip() or None
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
|
|
||||||
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
row = await store.get(job_id, user_id=user_id, task_id=tid)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
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()
|
answer = body.answer.strip()
|
||||||
if not answer:
|
if not answer:
|
||||||
raise HTTPException(status_code=400, detail="answer is required")
|
raise HTTPException(status_code=400, detail="answer is required")
|
||||||
|
|
||||||
# task 作业不分权:放开写入归属校验(user_id=None);并以**原作业所有者**身份续跑,
|
# task 作业不分权:放开写入归属校验(user_id=None)。
|
||||||
# 保证编排上下文 / 写回 task-draft 用一致的 user 上下文。
|
|
||||||
write_user_id = None if tid else user_id
|
write_user_id = None if tid else user_id
|
||||||
run_user_id = row.get("user_id") or 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(
|
# 续跑 snapshot:优先复用原 input_snapshot(含席位/模式/链条等完整入参),覆盖续跑三件套
|
||||||
StartParams(
|
# (is_resume / seed_message / intent_text),并用作业行里**最新**的 thread_ids /
|
||||||
job_id=job_id,
|
# coordinator_name(首跑建线程后由执行器写回,比原 snapshot 里的更新)。旧作业无 snapshot
|
||||||
user_id=run_user_id,
|
# 时从作业行字段兜底重建。
|
||||||
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
|
base_snapshot = row.get("input_snapshot") or {}
|
||||||
seed_message=_RESUME_PREFIX + answer,
|
if not isinstance(base_snapshot, dict):
|
||||||
model=None,
|
base_snapshot = {}
|
||||||
mode=row.get("orchestration_mode") or "recommend",
|
resume_snapshot: dict[str, Any] = {
|
||||||
intent_text=answer,
|
"v": base_snapshot.get("v", 1),
|
||||||
thread_ids=row.get("thread_ids"),
|
"user_id": run_user_id,
|
||||||
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
|
"agents": base_snapshot.get("agents")
|
||||||
draft_id=row.get("draft_id"),
|
or [{"agent_id": n.get("agentId"), "name": n.get("name")} for n in (row.get("dispatch_chain") or [])],
|
||||||
task_id=tid or row.get("task_id"),
|
"seed_message": _RESUME_PREFIX + answer,
|
||||||
is_resume=True,
|
"model": base_snapshot.get("model"),
|
||||||
chain=row.get("chain"),
|
"mode": row.get("orchestration_mode") or base_snapshot.get("mode") or "recommend",
|
||||||
orchestration_plan=row.get("orchestration_plan"),
|
"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:
|
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)
|
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
|
||||||
async def cancel_job(request: Request, job_id: str, task_id: str | None = None) -> 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)
|
store = _get_store(request)
|
||||||
executor = _get_executor(request)
|
|
||||||
tid = (task_id or "").strip() or None
|
tid = (task_id or "").strip() or None
|
||||||
user_id = _current_user_id(request)
|
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:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
executor.cancel(job_id)
|
|
||||||
# task 作业不分权:放开写入归属校验。
|
# task 作业不分权:放开写入归属校验。
|
||||||
updated = await store.update_progress(job_id, user_id=(None if tid else user_id), status="cancelled")
|
write_user_id = None if tid else user_id
|
||||||
return _to_response(updated or row)
|
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 fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.persistence.roundtable_drafts.sql import DraftConcurrentWriteError
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -42,6 +43,8 @@ class TaskDraftMetaResponse(BaseModel):
|
|||||||
task_id: str
|
task_id: str
|
||||||
title: str = ""
|
title: str = ""
|
||||||
furthest_step: int = 1
|
furthest_step: int = 1
|
||||||
|
# 乐观锁版本号:PUT 时经 expected_version 回传,冲突 → 409 + current_version。
|
||||||
|
version: int = 0
|
||||||
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
|
# 本次会商所用业务链名(历史卡片展示);无链条(自由/recommend 模式)时为 None。
|
||||||
chain_title: str | None = None
|
chain_title: str | None = None
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
@@ -77,6 +80,9 @@ class TaskDraftUpdateRequest(BaseModel):
|
|||||||
step1: dict[str, Any] | None = None
|
step1: dict[str, Any] | None = None
|
||||||
step2: dict[str, Any] | None = None
|
step2: dict[str, Any] | None = None
|
||||||
step3: 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 ──────────────────────────────────────────────────────────────
|
# ── 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:
|
async def update_draft(request: Request, draft_id: str, body: TaskDraftUpdateRequest) -> TaskDraftResponse:
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
fields = body.model_dump(exclude_unset=True)
|
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:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Draft not found")
|
raise HTTPException(status_code=404, detail="Draft not found")
|
||||||
return TaskDraftResponse(**row)
|
return TaskDraftResponse(**row)
|
||||||
|
|||||||
@@ -364,16 +364,23 @@ auth_login:
|
|||||||
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
# 点击后在新标签页打开下面的 url。enabled 关掉则不显示该入口。
|
||||||
|
|
||||||
database:
|
database:
|
||||||
backend: sqlite
|
backend: postgres
|
||||||
|
postgres_url: $POSTGRES_DATABASE_URL
|
||||||
sqlite_dir: .deer-flow/data
|
sqlite_dir: .deer-flow/data
|
||||||
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
|
pool_size: 20 # 基础连接池(postgres/mysql 生效;sqlite 忽略)
|
||||||
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
|
max_overflow: 20 # 高峰期可临时超出 pool_size 的额外连接数,真实上限 = pool_size + max_overflow
|
||||||
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
|
pool_timeout: 30 # 池满时等待空闲连接的秒数,超时报错
|
||||||
|
pool_recycle: 1800 # 连接回收周期(秒);防火墙/pgbouncer/MySQL wait_timeout 静默断连兜底。多 worker 部署:每 worker 上限 = pool_size + max_overflow,总数 × worker 数 < DB max_connections
|
||||||
echo_sql: false
|
echo_sql: false
|
||||||
|
|
||||||
checkpointer:
|
checkpointer:
|
||||||
type: sqlite
|
type: postgres
|
||||||
connection_string: .deer-flow/data/deerflow.db
|
connection_string: $POSTGRES_CHECKPOINT_URL
|
||||||
|
use_postgres: true
|
||||||
|
# PostgreSQL 优先;SQLite 仅作历史记录读取兜底,确认稳定后可移除。
|
||||||
|
dual_read_enabled: true
|
||||||
|
dual_read_sqlite_paths:
|
||||||
|
- .deer-flow/data/deerflow.db
|
||||||
|
|
||||||
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
|
||||||
# 三层覆盖优先级(高->低):
|
# 三层覆盖优先级(高->低):
|
||||||
|
|||||||
+9
-1
@@ -54,6 +54,7 @@ class JobProgressWriter:
|
|||||||
event_sink: EventSink = None,
|
event_sink: EventSink = None,
|
||||||
on_dialogue: Any | None = None,
|
on_dialogue: Any | None = None,
|
||||||
recorder: Any | None = None,
|
recorder: Any | None = None,
|
||||||
|
lease_owner: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._store = store
|
self._store = store
|
||||||
self._job_id = job_id
|
self._job_id = job_id
|
||||||
@@ -65,6 +66,9 @@ class JobProgressWriter:
|
|||||||
# 每轮 leader/seat 模型返回后回调(dialogues, consensus)—— 执行器据此把对话
|
# 每轮 leader/seat 模型返回后回调(dialogues, consensus)—— 执行器据此把对话
|
||||||
# 写进草稿(和手动跑同一存取路径)。
|
# 写进草稿(和手动跑同一存取路径)。
|
||||||
self._on_dialogue = on_dialogue
|
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._state: dict[str, str] = {a.agent_id: _PENDING for a in agents}
|
||||||
self._spoke_cycle: dict[str, int] = {}
|
self._spoke_cycle: dict[str, int] = {}
|
||||||
self._consensus = 0
|
self._consensus = 0
|
||||||
@@ -100,7 +104,11 @@ class JobProgressWriter:
|
|||||||
fields.setdefault("dialogues", self._dialogues)
|
fields.setdefault("dialogues", self._dialogues)
|
||||||
if self._store is not None:
|
if self._store is not None:
|
||||||
try:
|
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 — 进度落库失败不能打断编排
|
except Exception: # noqa: BLE001 — 进度落库失败不能打断编排
|
||||||
logger.warning("roundtable job progress persist failed", exc_info=True)
|
logger.warning("roundtable job progress persist failed", exc_info=True)
|
||||||
if self._event_sink is not None:
|
if self._event_sink is not None:
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ class DatabaseConfig(BaseModel):
|
|||||||
"SQLAlchemy default is 30."
|
"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) --
|
# -- Derived helpers (not user-configured) --
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ async def _run_alembic_upgrade(url: str) -> None:
|
|||||||
try:
|
try:
|
||||||
await asyncio.wait_for(asyncio.to_thread(_do_upgrade), timeout=timeout_s)
|
await asyncio.wait_for(asyncio.to_thread(_do_upgrade), timeout=timeout_s)
|
||||||
logger.info("Alembic migrations applied (or already up-to-date)")
|
logger.info("Alembic migrations applied (or already up-to-date)")
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Alembic upgrade did not finish within %.0fs; continuing startup on create_all schema. "
|
"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.",
|
"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
|
This sweep compares each existing table against the ORM metadata and issues
|
||||||
``ALTER TABLE … ADD COLUMN`` for every missing column that can be added
|
``ALTER TABLE … ADD COLUMN`` for every missing column that can be added
|
||||||
safely (nullable, or carrying a server_default). Indexes covering a newly
|
safely (nullable, or carrying a server_default). Indexes covering a newly
|
||||||
added column are created as well. Works on sqlite/mysql/postgres — the
|
added column are created as well. Table-level ``UniqueConstraint`` objects
|
||||||
column DDL is compiled through the active dialect, so portable types
|
that are declared on the model but missing from the table are also created
|
||||||
(``PortableJSON``/``PortableLongText``/``BeijingDateTime``) resolve to the
|
(as a ``CREATE UNIQUE INDEX`` of the same name, which is what the migrations
|
||||||
right backend type. Everything is per-column best-effort: a failure is
|
emit too, and works on SQLite/MySQL/PostgreSQL alike) — without this a newly
|
||||||
logged and startup continues.
|
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 import inspect as sa_inspect
|
||||||
from sqlalchemy.schema import CreateColumn
|
from sqlalchemy.schema import CreateColumn
|
||||||
|
|
||||||
@@ -273,6 +279,48 @@ def _ensure_orm_columns_sync(sync_conn) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to auto-create index %s on %s", idx.name, table.name)
|
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:
|
def _is_mysql_unknown_database_error(exc: Exception) -> bool:
|
||||||
message = str(exc).lower()
|
message = str(exc).lower()
|
||||||
@@ -287,6 +335,7 @@ async def init_engine(
|
|||||||
pool_size: int = 5,
|
pool_size: int = 5,
|
||||||
max_overflow: int = 10,
|
max_overflow: int = 10,
|
||||||
pool_timeout: int = 30,
|
pool_timeout: int = 30,
|
||||||
|
pool_recycle: int = 1800,
|
||||||
sqlite_dir: str = "",
|
sqlite_dir: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create the async engine and session factory, then auto-create tables.
|
"""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).
|
pool_size: Base connection pool size (postgres/mysql).
|
||||||
max_overflow: Extra connections allowed beyond pool_size under load (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_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).
|
sqlite_dir: Directory to create for SQLite (ensured to exist).
|
||||||
"""
|
"""
|
||||||
global _engine, _session_factory, _engine_loop
|
global _engine, _session_factory, _engine_loop
|
||||||
@@ -366,6 +417,7 @@ async def init_engine(
|
|||||||
max_overflow=max_overflow,
|
max_overflow=max_overflow,
|
||||||
pool_timeout=pool_timeout,
|
pool_timeout=pool_timeout,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=pool_recycle,
|
||||||
json_serializer=_json_serializer,
|
json_serializer=_json_serializer,
|
||||||
)
|
)
|
||||||
elif backend == "mysql":
|
elif backend == "mysql":
|
||||||
@@ -376,7 +428,7 @@ async def init_engine(
|
|||||||
max_overflow=max_overflow,
|
max_overflow=max_overflow,
|
||||||
pool_timeout=pool_timeout,
|
pool_timeout=pool_timeout,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_recycle=1800,
|
pool_recycle=pool_recycle,
|
||||||
json_serializer=_json_serializer,
|
json_serializer=_json_serializer,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -407,7 +459,7 @@ async def init_engine(
|
|||||||
await _auto_create_postgres_db(url)
|
await _auto_create_postgres_db(url)
|
||||||
# Rebuild engine against the now-existing database
|
# Rebuild engine against the now-existing database
|
||||||
await _engine.dispose()
|
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)
|
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
||||||
async with _engine.begin() as conn:
|
async with _engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
@@ -423,7 +475,7 @@ async def init_engine(
|
|||||||
max_overflow=max_overflow,
|
max_overflow=max_overflow,
|
||||||
pool_timeout=pool_timeout,
|
pool_timeout=pool_timeout,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_recycle=1800,
|
pool_recycle=pool_recycle,
|
||||||
json_serializer=_json_serializer,
|
json_serializer=_json_serializer,
|
||||||
)
|
)
|
||||||
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
|
_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,
|
pool_size=config.pool_size,
|
||||||
max_overflow=getattr(config, "max_overflow", 10),
|
max_overflow=getattr(config, "max_overflow", 10),
|
||||||
pool_timeout=getattr(config, "pool_timeout", 30),
|
pool_timeout=getattr(config, "pool_timeout", 30),
|
||||||
|
pool_recycle=getattr(config, "pool_recycle", 1800),
|
||||||
sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "",
|
sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+25
@@ -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",
|
||||||
|
]
|
||||||
+60
@@ -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),
|
||||||
|
)
|
||||||
+225
@@ -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
|
||||||
+61
@@ -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)
|
||||||
+48
@@ -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")
|
||||||
+88
@@ -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")
|
||||||
+51
@@ -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")
|
||||||
+115
@@ -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)
|
||||||
+77
@@ -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)
|
||||||
+42
@@ -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)
|
||||||
+2
@@ -40,6 +40,7 @@ from deerflow.persistence.concurrency.model import ConcurrencySampleRow
|
|||||||
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
|
from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
|
||||||
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
from deerflow.persistence.embed_sessions.model import EmbedSessionRow
|
||||||
from deerflow.persistence.feedback.model import FeedbackRow
|
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.light_apps.model import LightAppRow
|
||||||
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
|
||||||
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
from deerflow.persistence.llmwiki.model import LlmWikiConversationDepositRow, LlmWikiKnowledgeBaseRow
|
||||||
@@ -147,6 +148,7 @@ __all__ = [
|
|||||||
"TaskCopTaskRow",
|
"TaskCopTaskRow",
|
||||||
"TaskReportRow",
|
"TaskReportRow",
|
||||||
"EmbedSessionRow",
|
"EmbedSessionRow",
|
||||||
|
"IntentTurnRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"ThreadShareRow",
|
"ThreadShareRow",
|
||||||
"ToolCallMetricRow",
|
"ToolCallMetricRow",
|
||||||
|
|||||||
+2
@@ -3,12 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from deerflow.persistence.position_roundtable.model import (
|
from deerflow.persistence.position_roundtable.model import (
|
||||||
|
PositionRoundtableCommandRow,
|
||||||
PositionRoundtableNodeRow,
|
PositionRoundtableNodeRow,
|
||||||
PositionRoundtableSessionRow,
|
PositionRoundtableSessionRow,
|
||||||
)
|
)
|
||||||
from deerflow.persistence.position_roundtable.sql import PositionRoundtableRepository
|
from deerflow.persistence.position_roundtable.sql import PositionRoundtableRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"PositionRoundtableCommandRow",
|
||||||
"PositionRoundtableNodeRow",
|
"PositionRoundtableNodeRow",
|
||||||
"PositionRoundtableRepository",
|
"PositionRoundtableRepository",
|
||||||
"PositionRoundtableSessionRow",
|
"PositionRoundtableSessionRow",
|
||||||
|
|||||||
+117
@@ -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
|
||||||
+44
@@ -60,6 +60,10 @@ class PositionRoundtableSessionRow(Base):
|
|||||||
# intent_pending -> active -> completed / archived. The router owns legal
|
# intent_pending -> active -> completed / archived. The router owns legal
|
||||||
# transitions; the DB keeps it as a concise, portable string.
|
# transitions; the DB keeps it as a concise, portable string.
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
|
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.
|
# position Session removes every recovery snapshot through the FK cascade.
|
||||||
conversation_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
conversation_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
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)
|
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||||
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
@@ -114,3 +123,38 @@ class PositionRoundtableNodeRow(Base):
|
|||||||
default=lambda: datetime.now(UTC),
|
default=lambda: datetime.now(UTC),
|
||||||
onupdate=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)
|
||||||
|
)
|
||||||
+851
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -11,7 +12,14 @@ from sqlalchemy import delete, select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
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 (
|
from deerflow.persistence.position_roundtable.model import (
|
||||||
|
PositionRoundtableCommandRow,
|
||||||
PositionRoundtableNodeRow,
|
PositionRoundtableNodeRow,
|
||||||
PositionRoundtableSessionRow,
|
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))
|
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:
|
class PositionRoundtableRepository:
|
||||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
self._sf = session_factory
|
self._sf = session_factory
|
||||||
@@ -70,6 +157,7 @@ class PositionRoundtableRepository:
|
|||||||
"action_plan_snapshot": _loads(row.action_plan_snapshot),
|
"action_plan_snapshot": _loads(row.action_plan_snapshot),
|
||||||
"action_plan_conversation": _loads(row.action_plan_conversation),
|
"action_plan_conversation": _loads(row.action_plan_conversation),
|
||||||
"status": row.status,
|
"status": row.status,
|
||||||
|
"version": row.version,
|
||||||
"created_at": _time(row.created_at),
|
"created_at": _time(row.created_at),
|
||||||
"updated_at": _time(row.updated_at),
|
"updated_at": _time(row.updated_at),
|
||||||
}
|
}
|
||||||
@@ -90,6 +178,7 @@ class PositionRoundtableRepository:
|
|||||||
"artifact_manifest": _loads(row.artifact_manifest) or [],
|
"artifact_manifest": _loads(row.artifact_manifest) or [],
|
||||||
"conversation_snapshot": _loads(row.conversation_snapshot),
|
"conversation_snapshot": _loads(row.conversation_snapshot),
|
||||||
"revision": row.revision,
|
"revision": row.revision,
|
||||||
|
"version": row.version,
|
||||||
"upstream_revision": _loads(row.upstream_revision),
|
"upstream_revision": _loads(row.upstream_revision),
|
||||||
"rejection_count": row.rejection_count or 0,
|
"rejection_count": row.rejection_count or 0,
|
||||||
"last_rejection": _loads(row.last_rejection),
|
"last_rejection": _loads(row.last_rejection),
|
||||||
@@ -294,11 +383,32 @@ class PositionRoundtableRepository:
|
|||||||
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
|
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||||
action_plan_conversation: dict[str, Any] | None | object = _UNSET,
|
action_plan_conversation: dict[str, Any] | None | object = _UNSET,
|
||||||
status: str | object = _UNSET,
|
status: str | object = _UNSET,
|
||||||
|
allowed_statuses: set[str] | None = None,
|
||||||
) -> dict[str, Any] | 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:
|
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):
|
if row is None or not self._can_access(row, user_id):
|
||||||
return None
|
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:
|
if task_snapshot is not _UNSET:
|
||||||
row.task_snapshot = _dumps(task_snapshot)
|
row.task_snapshot = _dumps(task_snapshot)
|
||||||
if intent_thread_id is not _UNSET:
|
if intent_thread_id is not _UNSET:
|
||||||
@@ -321,6 +431,7 @@ class PositionRoundtableRepository:
|
|||||||
row.action_plan_conversation = _dumps(action_plan_conversation)
|
row.action_plan_conversation = _dumps(action_plan_conversation)
|
||||||
if status is not _UNSET:
|
if status is not _UNSET:
|
||||||
row.status = str(status)
|
row.status = str(status)
|
||||||
|
row.version += 1
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
@@ -425,6 +536,7 @@ class PositionRoundtableRepository:
|
|||||||
upstream_revision: dict[str, Any] | object = _UNSET,
|
upstream_revision: dict[str, Any] | object = _UNSET,
|
||||||
last_rejection: dict[str, Any] | None | object = _UNSET,
|
last_rejection: dict[str, Any] | None | object = _UNSET,
|
||||||
invalidated_by: str | None | object = _UNSET,
|
invalidated_by: str | None | object = _UNSET,
|
||||||
|
expected_version: int | None = None,
|
||||||
increment_revision: bool = False,
|
increment_revision: bool = False,
|
||||||
increment_rejection: bool = False,
|
increment_rejection: bool = False,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
@@ -436,9 +548,17 @@ class PositionRoundtableRepository:
|
|||||||
PositionRoundtableNodeRow.session_id == session_id,
|
PositionRoundtableNodeRow.session_id == session_id,
|
||||||
PositionRoundtableNodeRow.node_key == node_key,
|
PositionRoundtableNodeRow.node_key == node_key,
|
||||||
).limit(1)
|
).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()
|
row = (await session.execute(stmt)).scalars().first()
|
||||||
if row is None:
|
if row is None:
|
||||||
return 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:
|
if thread_id is not _UNSET:
|
||||||
row.thread_id = thread_id or None
|
row.thread_id = thread_id or None
|
||||||
if status is not _UNSET:
|
if status is not _UNSET:
|
||||||
@@ -455,6 +575,8 @@ class PositionRoundtableRepository:
|
|||||||
row.last_rejection = _dumps(last_rejection)
|
row.last_rejection = _dumps(last_rejection)
|
||||||
if invalidated_by is not _UNSET:
|
if invalidated_by is not _UNSET:
|
||||||
row.invalidated_by = invalidated_by or None
|
row.invalidated_by = invalidated_by or None
|
||||||
|
# 任意字段变更都 bump version(乐观锁单调递增),与业务交付 revision 解耦。
|
||||||
|
row.version += 1
|
||||||
if increment_revision:
|
if increment_revision:
|
||||||
row.revision += 1
|
row.revision += 1
|
||||||
if increment_rejection:
|
if increment_rejection:
|
||||||
@@ -464,6 +586,734 @@ class PositionRoundtableRepository:
|
|||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return self._node_dict(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(
|
async def reject_node(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|||||||
+8
-1
@@ -27,8 +27,14 @@ class RoundtableArtifactSubmissionStore(ABC):
|
|||||||
*,
|
*,
|
||||||
task_delivery_id: str | None = None,
|
task_delivery_id: str | None = None,
|
||||||
actor_id: str | None = None,
|
actor_id: str | None = None,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -39,6 +45,7 @@ class RoundtableArtifactSubmissionStore(ABC):
|
|||||||
*,
|
*,
|
||||||
submission_id: str | None = None,
|
submission_id: str | None = None,
|
||||||
actor_id: str | None = None,
|
actor_id: str | None = None,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Mark a submission as cancelled without deleting its audit record."""
|
"""Mark a submission as cancelled without deleting its audit record."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
+16
@@ -54,7 +54,10 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
|
|||||||
*,
|
*,
|
||||||
task_delivery_id: str | None = None,
|
task_delivery_id: str | None = None,
|
||||||
actor_id: str | None = None,
|
actor_id: str | None = None,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict
|
||||||
|
|
||||||
key = submission_key(task_id, artifact)
|
key = submission_key(task_id, artifact)
|
||||||
row = self._rows.get((task_id, key))
|
row = self._rows.get((task_id, key))
|
||||||
values = _artifact_values(artifact)
|
values = _artifact_values(artifact)
|
||||||
@@ -67,6 +70,7 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
|
|||||||
**values,
|
**values,
|
||||||
"task_delivery_id": task_delivery_id,
|
"task_delivery_id": task_delivery_id,
|
||||||
"submitted": True,
|
"submitted": True,
|
||||||
|
"version": 0,
|
||||||
"submitted_at": now,
|
"submitted_at": now,
|
||||||
"cancelled_at": None,
|
"cancelled_at": None,
|
||||||
"created_by": actor_id,
|
"created_by": actor_id,
|
||||||
@@ -79,6 +83,9 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
|
|||||||
self._rows[(task_id, key)] = row
|
self._rows[(task_id, key)] = row
|
||||||
return deepcopy(row)
|
return deepcopy(row)
|
||||||
|
|
||||||
|
if expected_version is not None and row.get("version", 0) != expected_version:
|
||||||
|
raise SubmissionVersionConflict(deepcopy(row))
|
||||||
|
|
||||||
changed = (
|
changed = (
|
||||||
not row["submitted"]
|
not row["submitted"]
|
||||||
or row.get("task_delivery_id") != task_delivery_id
|
or row.get("task_delivery_id") != task_delivery_id
|
||||||
@@ -91,6 +98,7 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
|
|||||||
{
|
{
|
||||||
"task_delivery_id": task_delivery_id,
|
"task_delivery_id": task_delivery_id,
|
||||||
"submitted": True,
|
"submitted": True,
|
||||||
|
"version": row.get("version", 0) + 1,
|
||||||
"submitted_at": now,
|
"submitted_at": now,
|
||||||
"cancelled_at": None,
|
"cancelled_at": None,
|
||||||
"updated_by": actor_id,
|
"updated_by": actor_id,
|
||||||
@@ -108,16 +116,24 @@ class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore)
|
|||||||
*,
|
*,
|
||||||
submission_id: str | None = None,
|
submission_id: str | None = None,
|
||||||
actor_id: str | None = None,
|
actor_id: str | None = None,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
|
from deerflow.persistence.roundtable_artifact_submissions.sql import SubmissionVersionConflict
|
||||||
|
|
||||||
key = submission_key(task_id, artifact)
|
key = submission_key(task_id, artifact)
|
||||||
row = self._rows.get((task_id, key))
|
row = self._rows.get((task_id, key))
|
||||||
if row is None or (submission_id is not None and row["id"] != submission_id):
|
if row is None or (submission_id is not None and row["id"] != submission_id):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if expected_version is not None and row.get("version", 0) != expected_version:
|
||||||
|
raise SubmissionVersionConflict(deepcopy(row))
|
||||||
|
|
||||||
if row["submitted"]:
|
if row["submitted"]:
|
||||||
now = _now()
|
now = _now()
|
||||||
row.update(
|
row.update(
|
||||||
{
|
{
|
||||||
"submitted": False,
|
"submitted": False,
|
||||||
|
"version": row.get("version", 0) + 1,
|
||||||
"cancelled_at": now,
|
"cancelled_at": now,
|
||||||
"updated_by": actor_id,
|
"updated_by": actor_id,
|
||||||
"cancelled_by": actor_id,
|
"cancelled_by": actor_id,
|
||||||
|
|||||||
+4
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
|
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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)
|
task_delivery_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
submitted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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)
|
submitted_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
|
||||||
cancelled_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)
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|||||||
+101
-66
@@ -15,6 +15,21 @@ from deerflow.persistence.roundtable_artifact_submissions.memory import submissi
|
|||||||
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
|
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:
|
def _timestamp(value: datetime | None) -> str | None:
|
||||||
return value.isoformat() if isinstance(value, datetime) else value
|
return value.isoformat() if isinstance(value, datetime) else value
|
||||||
|
|
||||||
@@ -34,6 +49,7 @@ def _to_dict(row: RoundtableArtifactSubmissionRow) -> dict[str, Any]:
|
|||||||
"revision": row.revision,
|
"revision": row.revision,
|
||||||
"task_delivery_id": row.task_delivery_id,
|
"task_delivery_id": row.task_delivery_id,
|
||||||
"submitted": row.submitted,
|
"submitted": row.submitted,
|
||||||
|
"version": row.version,
|
||||||
"submitted_at": _timestamp(row.submitted_at),
|
"submitted_at": _timestamp(row.submitted_at),
|
||||||
"cancelled_at": _timestamp(row.cancelled_at),
|
"cancelled_at": _timestamp(row.cancelled_at),
|
||||||
"created_by": row.created_by,
|
"created_by": row.created_by,
|
||||||
@@ -92,34 +108,63 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
|
|||||||
*,
|
*,
|
||||||
task_delivery_id: str | None = None,
|
task_delivery_id: str | None = None,
|
||||||
actor_id: str | None = None,
|
actor_id: str | None = None,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
key = submission_key(task_id, artifact)
|
key = submission_key(task_id, artifact)
|
||||||
stmt = select(RoundtableArtifactSubmissionRow).where(
|
# 至多重试一次:第一轮 INSERT 遇唯一键冲突 → 第二轮 FOR UPDATE 读回做 CAS 更新。
|
||||||
RoundtableArtifactSubmissionRow.task_id == task_id,
|
for _attempt in range(2):
|
||||||
RoundtableArtifactSubmissionRow.idempotency_key == key,
|
async with self._sf() as session:
|
||||||
)
|
result = await session.execute(
|
||||||
async with self._sf() as session:
|
select(RoundtableArtifactSubmissionRow)
|
||||||
result = await session.execute(stmt)
|
.where(
|
||||||
row = result.scalars().first()
|
RoundtableArtifactSubmissionRow.task_id == task_id,
|
||||||
now = datetime.now(UTC)
|
RoundtableArtifactSubmissionRow.idempotency_key == key,
|
||||||
created = row is None
|
)
|
||||||
if row is None:
|
.with_for_update()
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
_apply_artifact(row, artifact)
|
row = result.scalars().first()
|
||||||
session.add(row)
|
now = datetime.now(UTC)
|
||||||
elif _requires_submission_update(row, artifact, task_delivery_id):
|
|
||||||
|
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)
|
_apply_artifact(row, artifact)
|
||||||
row.task_delivery_id = task_delivery_id
|
row.task_delivery_id = task_delivery_id
|
||||||
row.submitted = True
|
row.submitted = True
|
||||||
@@ -129,34 +174,12 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
|
|||||||
row.submitted_by = actor_id
|
row.submitted_by = actor_id
|
||||||
row.cancelled_by = None
|
row.cancelled_by = None
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
try:
|
row.version = row.version + 1
|
||||||
await session.commit()
|
await session.commit()
|
||||||
except IntegrityError:
|
await session.refresh(row)
|
||||||
# Two callers can both observe a missing row. The unique key
|
return _to_dict(row)
|
||||||
# turns that race into a normal idempotent retry instead of a
|
|
||||||
# 500. If their non-key metadata differs, apply this request
|
raise RuntimeError("submit: retry loop exhausted (unexpected)")
|
||||||
# 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)
|
|
||||||
|
|
||||||
async def cancel(
|
async def cancel(
|
||||||
self,
|
self,
|
||||||
@@ -165,24 +188,36 @@ class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
|
|||||||
*,
|
*,
|
||||||
submission_id: str | None = None,
|
submission_id: str | None = None,
|
||||||
actor_id: str | None = None,
|
actor_id: str | None = None,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
key = submission_key(task_id, artifact)
|
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:
|
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()
|
row = result.scalars().first()
|
||||||
if row is None or (submission_id is not None and row.id != submission_id):
|
if row is None or (submission_id is not None and row.id != submission_id):
|
||||||
return None
|
return None
|
||||||
if row.submitted:
|
|
||||||
now = datetime.now(UTC)
|
if expected_version is not None and row.version != expected_version:
|
||||||
row.submitted = False
|
raise SubmissionVersionConflict(_to_dict(row))
|
||||||
row.cancelled_at = now
|
|
||||||
row.updated_by = actor_id
|
if not row.submitted:
|
||||||
row.cancelled_by = actor_id
|
# 幂等:已取消,不 bump version。
|
||||||
row.updated_at = now
|
return _to_dict(row)
|
||||||
await session.commit()
|
|
||||||
await session.refresh(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)
|
return _to_dict(row)
|
||||||
+3
@@ -47,6 +47,9 @@ class RoundtableDraftRow(Base):
|
|||||||
default=lambda: datetime.now(UTC),
|
default=lambda: datetime.now(UTC),
|
||||||
onupdate=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):
|
class RoundtableRecommendHistoryRow(Base):
|
||||||
|
|||||||
+70
-17
@@ -15,7 +15,7 @@ import re
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import case, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.roundtable_drafts.model import (
|
from deerflow.persistence.roundtable_drafts.model import (
|
||||||
@@ -66,6 +66,23 @@ def _dumps(value: Any) -> str | None:
|
|||||||
return json.dumps(value, ensure_ascii=False)
|
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:
|
class RoundtableDraftRepository:
|
||||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
self._sf = session_factory
|
self._sf = session_factory
|
||||||
@@ -80,6 +97,8 @@ class RoundtableDraftRepository:
|
|||||||
"task_id": row.task_id,
|
"task_id": row.task_id,
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
"furthest_step": row.furthest_step,
|
"furthest_step": row.furthest_step,
|
||||||
|
# 乐观锁版本号:列表也携带,供前端本地副本对账(冲突时重拉合并)。
|
||||||
|
"version": row.version,
|
||||||
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
|
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
|
||||||
"chain_title": _extract_chain_title(row.step2),
|
"chain_title": _extract_chain_title(row.step2),
|
||||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||||
@@ -145,26 +164,60 @@ class RoundtableDraftRepository:
|
|||||||
step2=_UNSET,
|
step2=_UNSET,
|
||||||
step3=_UNSET,
|
step3=_UNSET,
|
||||||
task_id=_UNSET,
|
task_id=_UNSET,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
"""原子条件 UPDATE:版本判定与字段写入发生在**同一条 SQL** 里。
|
||||||
row = await session.get(RoundtableDraftRow, draft_id)
|
|
||||||
|
一致性只来自数据库:``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 聊天记录已迁出到独立存储)。
|
# 个人草稿严格按 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
|
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()
|
await session.commit()
|
||||||
|
row = await session.get(RoundtableDraftRow, draft_id)
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return self._draft_to_dict(row)
|
return self._draft_to_dict(row)
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -19,7 +19,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
@@ -28,6 +29,13 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
|
|||||||
|
|
||||||
class RoundtableJobRow(Base):
|
class RoundtableJobRow(Base):
|
||||||
__tablename__ = "roundtable_jobs"
|
__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)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
# The draft/session this job runs for. Indexed for "active job of draft" lookups.
|
# 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 指向独立的
|
# **不按 user 分权**(任何人打开同一 task 都能看在跑的研讨);其 draft_id 指向独立的
|
||||||
# ``roundtable_task_drafts`` 表。为空 = 普通个人作业(按 user 分权)。
|
# ``roundtable_task_drafts`` 表。为空 = 普通个人作业(按 user 分权)。
|
||||||
task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
|
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.
|
# 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)
|
status: Mapped[str] = mapped_column(String(32), nullable=False, default="queued", index=True)
|
||||||
# Fine-grained phase (§2.2): initializing / leader_thinking / dispatching /
|
# 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)
|
step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||||
# Terminal error detail when status=error.
|
# Terminal error detail when status=error.
|
||||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
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 对齐)。
|
# 时间列必须用 BeijingDateTime(项目规范,与 ai_writing_sessions / roundtable_drafts 对齐)。
|
||||||
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
+576
-20
@@ -12,11 +12,14 @@ threading a user id through, while gateway routes pass it explicitly.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
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 sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
|
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()
|
_UNSET = object()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 终态:列表/清理判定用。
|
# 终态:列表/清理判定用。
|
||||||
TERMINAL_STATUSES = ("done", "error", "cancelled")
|
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:
|
def _loads(value: Any) -> Any:
|
||||||
@@ -52,15 +83,72 @@ class RoundtableJobRepository:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _row_to_dict(row: RoundtableJobRow) -> dict[str, Any]:
|
def _row_to_dict(row: RoundtableJobRow) -> dict[str, Any]:
|
||||||
d = row.to_dict()
|
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):
|
if isinstance(d.get(k), datetime):
|
||||||
d[k] = d[k].isoformat()
|
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))
|
d[k] = _loads(d.get(k))
|
||||||
return d
|
return d
|
||||||
|
|
||||||
# ── create ────────────────────────────────────────────────────────────
|
# ── 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(
|
async def create(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -78,27 +166,19 @@ class RoundtableJobRepository:
|
|||||||
phase: str = "initializing",
|
phase: str = "initializing",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.create")
|
resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.create")
|
||||||
now = datetime.now(UTC)
|
row = self._make_row(
|
||||||
row = RoundtableJobRow(
|
|
||||||
id=id,
|
id=id,
|
||||||
|
resolved_user_id=resolved_user_id,
|
||||||
draft_id=draft_id,
|
draft_id=draft_id,
|
||||||
user_id=resolved_user_id,
|
task_id=task_id,
|
||||||
task_id=(task_id or None),
|
orchestration_mode=orchestration_mode,
|
||||||
|
coordinator_name=coordinator_name,
|
||||||
|
thread_ids=thread_ids,
|
||||||
|
chain=chain,
|
||||||
|
orchestration_plan=orchestration_plan,
|
||||||
|
dispatch_chain=dispatch_chain,
|
||||||
status=status,
|
status=status,
|
||||||
phase=phase,
|
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:
|
async with self._sf() as session:
|
||||||
session.add(row)
|
session.add(row)
|
||||||
@@ -106,6 +186,102 @@ class RoundtableJobRepository:
|
|||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return self._row_to_dict(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 ─────────────────────────────────────────────────────
|
# ── update progress ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def update_progress(
|
async def update_progress(
|
||||||
@@ -125,8 +301,80 @@ class RoundtableJobRepository:
|
|||||||
dialogues=_UNSET,
|
dialogues=_UNSET,
|
||||||
step3=_UNSET,
|
step3=_UNSET,
|
||||||
error=_UNSET,
|
error=_UNSET,
|
||||||
|
lease_owner: str | None = None,
|
||||||
) -> dict[str, Any] | 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")
|
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:
|
async with self._sf() as session:
|
||||||
row = await session.get(RoundtableJobRow, job_id)
|
row = await session.get(RoundtableJobRow, job_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -157,11 +405,319 @@ class RoundtableJobRepository:
|
|||||||
row.step3 = _dumps(step3)
|
row.step3 = _dumps(step3)
|
||||||
if error is not _UNSET:
|
if error is not _UNSET:
|
||||||
row.error = error
|
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)
|
row.updated_at = datetime.now(UTC)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return self._row_to_dict(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 ────────────────────────────────────────────────────────────────
|
# ── read ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
|
|||||||
+3
@@ -50,3 +50,6 @@ class RoundtableTaskDraftRow(Base):
|
|||||||
default=lambda: datetime.now(UTC),
|
default=lambda: datetime.now(UTC),
|
||||||
onupdate=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")
|
||||||
+46
-15
@@ -19,9 +19,10 @@ import re
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import case, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
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
|
from deerflow.persistence.roundtable_task_drafts.model import RoundtableTaskDraftRow
|
||||||
|
|
||||||
_UNSET = object()
|
_UNSET = object()
|
||||||
@@ -77,6 +78,8 @@ class RoundtableTaskDraftRepository:
|
|||||||
"task_id": row.task_id,
|
"task_id": row.task_id,
|
||||||
"title": row.title,
|
"title": row.title,
|
||||||
"furthest_step": row.furthest_step,
|
"furthest_step": row.furthest_step,
|
||||||
|
# 乐观锁版本号:列表也携带,供前端本地副本对账(冲突时重拉合并)。
|
||||||
|
"version": row.version,
|
||||||
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
|
# 所用业务链名(历史卡片展示用);无链条 → None → 前端显示「自由模式」。
|
||||||
"chain_title": _extract_chain_title(row.step2),
|
"chain_title": _extract_chain_title(row.step2),
|
||||||
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
||||||
@@ -159,24 +162,52 @@ class RoundtableTaskDraftRepository:
|
|||||||
step1=_UNSET,
|
step1=_UNSET,
|
||||||
step2=_UNSET,
|
step2=_UNSET,
|
||||||
step3=_UNSET,
|
step3=_UNSET,
|
||||||
|
expected_version: int | None = None,
|
||||||
) -> dict[str, Any] | 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:
|
async with self._sf() as session:
|
||||||
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
result = await session.execute(
|
||||||
if row is None:
|
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
|
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()
|
await session.commit()
|
||||||
|
row = await session.get(RoundtableTaskDraftRow, draft_id)
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return self._to_dict(row)
|
return self._to_dict(row)
|
||||||
|
|
||||||
|
|||||||
+157
-65
@@ -15,10 +15,10 @@ internal checkpoint callbacks that are not exposed in the Python public API.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||||
@@ -28,31 +28,31 @@ from langgraph.checkpoint.base import empty_checkpoint
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
from deerflow.agents.middlewares.title_middleware import (
|
from deerflow.agents.middlewares.title_middleware import (
|
||||||
finalize_thread_title,
|
finalize_thread_title,
|
||||||
log_title_debug,
|
log_title_debug,
|
||||||
)
|
)
|
||||||
from deerflow.config.app_config import AppConfig
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.runtime.serialization import serialize
|
from deerflow.runtime.serialization import serialize
|
||||||
from deerflow.runtime.stream_bridge import StreamBridge
|
from deerflow.runtime.stream_bridge import StreamBridge
|
||||||
|
|
||||||
from .manager import RunManager, RunRecord
|
from .manager import RunManager, RunRecord
|
||||||
from .schemas import RunStatus
|
from .schemas import RunStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Valid stream_mode values for LangGraph's graph.astream()
|
# Valid stream_mode values for LangGraph's graph.astream()
|
||||||
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
|
_VALID_LG_MODES = {"values", "updates", "checkpoints", "tasks", "debug", "messages", "custom"}
|
||||||
|
|
||||||
|
|
||||||
def _log_background_title_finalize_failure(thread_id: str):
|
def _log_background_title_finalize_failure(thread_id: str):
|
||||||
def _done(task: asyncio.Task[bool]) -> None:
|
def _done(task: asyncio.Task[bool]) -> None:
|
||||||
try:
|
try:
|
||||||
task.result()
|
task.result()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
|
logger.debug("Failed to finalize background title for thread %s", thread_id, exc_info=True)
|
||||||
|
|
||||||
return _done
|
return _done
|
||||||
|
|
||||||
|
|
||||||
def _build_runtime_context(
|
def _build_runtime_context(
|
||||||
@@ -364,7 +364,40 @@ async def run_agent(
|
|||||||
else:
|
else:
|
||||||
await run_manager.set_status(run_id, RunStatus.interrupted)
|
await run_manager.set_status(run_id, RunStatus.interrupted)
|
||||||
else:
|
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:
|
except asyncio.CancelledError:
|
||||||
action = record.abort_action
|
action = record.abort_action
|
||||||
@@ -414,47 +447,47 @@ async def run_agent(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to persist run completion for %s (non-fatal)", run_id, exc_info=True)
|
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
|
# Sync title from checkpoint to threads_meta.display_name
|
||||||
if checkpointer is not None and thread_store is not None:
|
if checkpointer is not None and thread_store is not None:
|
||||||
try:
|
try:
|
||||||
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
ckpt_config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
|
ckpt_tuple = await checkpointer.aget_tuple(ckpt_config)
|
||||||
if ckpt_tuple is not None:
|
if ckpt_tuple is not None:
|
||||||
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
|
ckpt = getattr(ckpt_tuple, "checkpoint", {}) or {}
|
||||||
channel_values = ckpt.get("channel_values", {}) or {}
|
channel_values = ckpt.get("channel_values", {}) or {}
|
||||||
title = channel_values.get("title")
|
title = channel_values.get("title")
|
||||||
if title:
|
if title:
|
||||||
await thread_store.update_display_name(thread_id, title)
|
await thread_store.update_display_name(thread_id, title)
|
||||||
if (
|
if (
|
||||||
record.status == RunStatus.success
|
record.status == RunStatus.success
|
||||||
and channel_values.get("title_provisional") is True
|
and channel_values.get("title_provisional") is True
|
||||||
):
|
):
|
||||||
log_title_debug(
|
log_title_debug(
|
||||||
"Run %s: scheduling background title generation for thread %s",
|
"Run %s: scheduling background title generation for thread %s",
|
||||||
run_id,
|
run_id,
|
||||||
thread_id,
|
thread_id,
|
||||||
)
|
)
|
||||||
title_task = asyncio.create_task(
|
title_task = asyncio.create_task(
|
||||||
finalize_thread_title(
|
finalize_thread_title(
|
||||||
checkpointer=checkpointer,
|
checkpointer=checkpointer,
|
||||||
thread_store=thread_store,
|
thread_store=thread_store,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
source_state=copy.deepcopy(channel_values),
|
source_state=copy.deepcopy(channel_values),
|
||||||
app_config=ctx.app_config,
|
app_config=ctx.app_config,
|
||||||
),
|
),
|
||||||
name=f"title-finalize:{thread_id}",
|
name=f"title-finalize:{thread_id}",
|
||||||
)
|
)
|
||||||
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
|
title_task.add_done_callback(_log_background_title_finalize_failure(thread_id))
|
||||||
elif record.status == RunStatus.success:
|
elif record.status == RunStatus.success:
|
||||||
log_title_debug(
|
log_title_debug(
|
||||||
"Run %s: background title generation not scheduled for thread %s because title_provisional=%r title=%r",
|
"Run %s: background title generation not scheduled for thread %s because title_provisional=%r title=%r",
|
||||||
run_id,
|
run_id,
|
||||||
thread_id,
|
thread_id,
|
||||||
channel_values.get("title_provisional"),
|
channel_values.get("title_provisional"),
|
||||||
channel_values.get("title"),
|
channel_values.get("title"),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
|
logger.debug("Failed to sync title for thread %s (non-fatal)", thread_id)
|
||||||
|
|
||||||
# Update threads_meta status based on run outcome
|
# Update threads_meta status based on run outcome
|
||||||
if thread_store is not None:
|
if thread_store is not None:
|
||||||
@@ -484,6 +517,65 @@ async def _call_checkpointer_method(checkpointer: Any, async_name: str, sync_nam
|
|||||||
return result
|
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(
|
async def _rollback_to_pre_run_checkpoint(
|
||||||
*,
|
*,
|
||||||
checkpointer: Any,
|
checkpointer: Any,
|
||||||
|
|||||||
+7
-1
@@ -3,7 +3,13 @@ from typing import Literal
|
|||||||
from langchain.tools import tool
|
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(
|
def ask_clarification_tool(
|
||||||
question: str,
|
question: str,
|
||||||
clarification_type: Literal[
|
clarification_type: Literal[
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -38,7 +38,10 @@ async def store(tmp_path):
|
|||||||
import deerflow.persistence.position_roundtable.model # noqa: F401
|
import deerflow.persistence.position_roundtable.model # noqa: F401
|
||||||
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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():
|
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:
|
def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
|
||||||
|
from deerflow.persistence.position_roundtable.delivery import markdown_delivery_changed
|
||||||
|
|
||||||
old = [
|
old = [
|
||||||
{
|
{
|
||||||
"name": "report.md",
|
"name": "report.md",
|
||||||
@@ -1214,8 +1219,8 @@ def test_rejected_or_stale_node_requires_a_new_markdown_delivery() -> None:
|
|||||||
"size_bytes": 100,
|
"size_bytes": 100,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert not position_roundtable_router._markdown_delivery_changed(old, old)
|
assert not markdown_delivery_changed(old, old)
|
||||||
assert position_roundtable_router._markdown_delivery_changed(
|
assert markdown_delivery_changed(
|
||||||
old,
|
old,
|
||||||
[{**old[0], "modified_at": "2026-07-21T00:05:00+00:00", "size_bytes": 120}],
|
[{**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,
|
"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(
|
request = SimpleNamespace(
|
||||||
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
|
app=SimpleNamespace(state=SimpleNamespace(position_roundtable_store=LockedStore())),
|
||||||
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
state=SimpleNamespace(user=SimpleNamespace(id="user-a")),
|
||||||
|
|||||||
Reference in new issue
Block a user