feat: 岗位会商命令幂等与圆桌后台作业租约调度
岗位会商写操作引入 command_id 幂等与版本锁,前端支持同键重试;圆桌后台作业改为 DB 租约 dispatcher、去重与草稿 outbox 补偿,并持久化 intent turns。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
4f536d9b39
commit
75963c9ad9
51 files changed
+4449
-670
No files matched your search
@@ -138,6 +138,52 @@ const PRIMARY_API_PATH = "/api/position-roundtable";
|
||||
const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
|
||||
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
|
||||
|
||||
/**
|
||||
* 生成一次写操作的幂等键(command_id)。
|
||||
*
|
||||
* 后端命令式状态机用 ``UNIQUE(session_id, node_key, command_id)`` 保证同一 command_id
|
||||
* 只执行一次。把它在**一次调用入口**生成并固定进请求体,使得 `positionApiFetch` 的
|
||||
* 主/兼容路由回退重试复用同一 command_id —— 网络层重发绝不会把同一操作执行两遍
|
||||
* (不会重复 +revision / 重复解锁下游 / 重复驳回)。每次新的用户操作则取新键。
|
||||
*/
|
||||
function newCommandId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID().replace(/-/g, "");
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用**同一个 command_id** 重试一个写操作至多 ``attempts`` 次(指数退避)。
|
||||
*
|
||||
* 后端命令幂等表保证同 command_id + 同参数只执行一次:即便第一次其实已提交成功、
|
||||
* 只是响应在网络层丢失,重试也只会重放上次结果,而不会重复推进状态机(重复
|
||||
* +revision / 重复解锁下游)。因此「提交产物给下一阶段」这类关键写操作可以放心
|
||||
* 重试,把偶发网络错误/超时变成自愈,而不是让下一阶段永远拿不到上游产物。
|
||||
*
|
||||
* 返回 ``{ ok, result?, error? }``;``ok=false`` 时 ``error`` 是最后一次失败。
|
||||
*/
|
||||
export async function retryWithSameCommandId<T>(
|
||||
operation: (commandId: string) => Promise<T>,
|
||||
options?: { attempts?: number; baseDelayMs?: number },
|
||||
): Promise<{ ok: true; result: T } | { ok: false; error: unknown }> {
|
||||
const attempts = Math.max(1, options?.attempts ?? 3);
|
||||
const baseDelay = options?.baseDelayMs ?? 400;
|
||||
const commandId = newCommandId();
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
return { ok: true, result: await operation(commandId) };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: false, error: lastError };
|
||||
}
|
||||
|
||||
/**
|
||||
* Some intranet gateways only forward the long-established `/api/multi-agent`
|
||||
* namespace. The position workspace was added later under its own top-level
|
||||
@@ -383,7 +429,7 @@ export async function activatePositionSession(
|
||||
await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ chain_id: chainId }),
|
||||
body: JSON.stringify({ chain_id: chainId, command_id: newCommandId() }),
|
||||
}),
|
||||
"activate position session",
|
||||
);
|
||||
@@ -417,6 +463,7 @@ export async function bindPositionNodeThread(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
threadId: string,
|
||||
commandId?: string,
|
||||
): Promise<PositionRoundtableNode> {
|
||||
const result = await readJson<ServerNode>(
|
||||
await positionApiFetch(
|
||||
@@ -424,7 +471,7 @@ export async function bindPositionNodeThread(
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ thread_id: threadId }),
|
||||
body: JSON.stringify({ thread_id: threadId, command_id: commandId ?? newCommandId() }),
|
||||
},
|
||||
),
|
||||
"bind position node thread",
|
||||
@@ -438,6 +485,7 @@ export async function savePositionNodeConversation(
|
||||
input: {
|
||||
threadId?: string | null;
|
||||
conversationSnapshot: PositionConversationSnapshot;
|
||||
commandId?: string;
|
||||
},
|
||||
): Promise<PositionRoundtableNode> {
|
||||
const result = await readJson<ServerNode>(
|
||||
@@ -449,6 +497,7 @@ export async function savePositionNodeConversation(
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId || null,
|
||||
conversation_snapshot: input.conversationSnapshot,
|
||||
command_id: input.commandId ?? newCommandId(),
|
||||
}),
|
||||
},
|
||||
),
|
||||
@@ -533,6 +582,8 @@ export async function completePositionNodeTurn(
|
||||
latestAnswer: string;
|
||||
artifactManifest?: Array<Record<string, unknown>>;
|
||||
upstreamRevision?: Record<string, unknown> | null;
|
||||
/** Retry-stable idempotency key; omit to auto-generate per call. */
|
||||
commandId?: string;
|
||||
},
|
||||
): Promise<PositionRoundtableNode> {
|
||||
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
|
||||
@@ -546,6 +597,7 @@ export async function completePositionNodeTurn(
|
||||
latest_answer: input.latestAnswer,
|
||||
artifact_manifest: input.artifactManifest ?? [],
|
||||
upstream_revision: input.upstreamRevision ?? null,
|
||||
command_id: input.commandId ?? newCommandId(),
|
||||
}),
|
||||
},
|
||||
),
|
||||
@@ -557,7 +609,7 @@ export async function completePositionNodeTurn(
|
||||
export async function rejectPositionNode(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
input: { reason?: string; title?: string },
|
||||
input: { reason?: string; title?: string; commandId?: string },
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await positionApiFetch(
|
||||
@@ -570,6 +622,7 @@ export async function rejectPositionNode(
|
||||
// workspace deliberately sends only an optional reason.
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
reason: input.reason ?? "",
|
||||
command_id: input.commandId ?? newCommandId(),
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
bindPositionNodeThread,
|
||||
completePositionNodeTurn,
|
||||
getPositionNodeContext,
|
||||
retryWithSameCommandId,
|
||||
savePositionNodeConversation,
|
||||
type PositionRoundtableNode,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
@@ -530,17 +531,26 @@ export function PositionNodeChat({
|
||||
// Artifact listing is best-effort: a valid text answer must still be
|
||||
// persisted and unlock downstream work even if files cannot be read.
|
||||
}
|
||||
try {
|
||||
const updated = await completePositionNodeTurn(sessionId, node.nodeKey, {
|
||||
// 这是「岗位完成问答 → 把产物提交给下一阶段」的关键路径。偶发网络错误/超时
|
||||
// 不能让它悄悄失败(否则下一阶段永远拿不到上游产物):用同一个 command_id
|
||||
// 重试,后端命令幂等表保证即使第一次其实已提交成功,重试也只重放结果,
|
||||
// 绝不重复推进状态机(不会重复 +revision / 重复解锁下游)。
|
||||
const delivery = await retryWithSameCommandId((commandId) =>
|
||||
completePositionNodeTurn(sessionId, node.nodeKey, {
|
||||
latestAnswer,
|
||||
artifactManifest,
|
||||
upstreamRevision: upstreamRevisionRef.current,
|
||||
});
|
||||
commandId,
|
||||
}),
|
||||
);
|
||||
if (delivery.ok) {
|
||||
const updated = delivery.result;
|
||||
onNodeUpdate(updated);
|
||||
if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
|
||||
toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
|
||||
}
|
||||
} catch (error) {
|
||||
} else {
|
||||
const error = delivery.error;
|
||||
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
})();
|
||||
@@ -665,14 +675,19 @@ export function PositionNodeChat({
|
||||
[messageId]: textOfMessage(updatedMessage) ?? content,
|
||||
}));
|
||||
if (updatedMessage.type === "ai" && messageId === latestAiMessageId) {
|
||||
try {
|
||||
const updatedNode = await completePositionNodeTurn(sessionId, node.nodeKey, {
|
||||
// 与自动交付路径同样:同一 command_id 重试,幂等保证不重复推进状态机。
|
||||
const sync = await retryWithSameCommandId((commandId) =>
|
||||
completePositionNodeTurn(sessionId, node.nodeKey, {
|
||||
latestAnswer: content,
|
||||
artifactManifest: node.artifactManifest,
|
||||
upstreamRevision: node.upstreamRevision,
|
||||
});
|
||||
onNodeUpdate(updatedNode);
|
||||
} catch (syncError) {
|
||||
commandId,
|
||||
}),
|
||||
);
|
||||
if (sync.ok) {
|
||||
onNodeUpdate(sync.result);
|
||||
} else {
|
||||
const syncError = sync.error;
|
||||
toast.warning(
|
||||
`消息已保存,但节点交接摘要同步失败:${
|
||||
syncError instanceof Error ? syncError.message : String(syncError)
|
||||
|
||||
@@ -189,6 +189,11 @@ export interface DraftMeta {
|
||||
/** Auto-derived task summary (objective → first user message → timestamp). */
|
||||
title: string;
|
||||
furthestStep: 1 | 2 | 3;
|
||||
/**
|
||||
* 乐观锁版本号:写回时经 `expectedVersion` 回传;并发冲突(409)时用它识别
|
||||
* 本地副本已过期,重拉最新草稿合并后重试。
|
||||
*/
|
||||
version: number;
|
||||
/**
|
||||
* 本次会商所用业务链名(历史卡片展示)。无业务链(自由/recommend 模式)→ null/undefined,
|
||||
* 由前端展示为「自由模式」。后端从 step2 快照里抽取(见 roundtable_drafts/sql.py)。
|
||||
@@ -234,6 +239,33 @@ export interface DraftUpdatePayload {
|
||||
step1?: DraftStep1Snapshot;
|
||||
step2?: DraftStep2Snapshot;
|
||||
step3?: DraftStep3Snapshot | null;
|
||||
/**
|
||||
* 本地副本基于的草稿版本号(乐观锁)。提供时服务端做原子条件 UPDATE;
|
||||
* 版本不匹配 → 409(`DraftVersionConflictError`),调用方应重拉合并后重试。
|
||||
* 省略 = 盲写(向后兼容旧调用点,不推荐)。
|
||||
*/
|
||||
expectedVersion?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 乐观锁冲突(HTTP 409):服务端草稿版本比本地副本新。
|
||||
* `currentVersion` 是服务端最新版本,调用方应重拉最新草稿、合并本地改动后用
|
||||
* 新版本号重试——一致性来自 DB,不靠前端运气。
|
||||
*/
|
||||
export class DraftVersionConflictError extends Error {
|
||||
readonly draftId: string;
|
||||
readonly currentVersion: number | null;
|
||||
|
||||
constructor(draftId: string, currentVersion: number | null) {
|
||||
super(`Draft ${draftId} was modified concurrently (server version ${currentVersion})`);
|
||||
this.name = "DraftVersionConflictError";
|
||||
this.draftId = draftId;
|
||||
this.currentVersion = currentVersion;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDraftVersionConflictError(error: unknown): error is DraftVersionConflictError {
|
||||
return error instanceof DraftVersionConflictError;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-drafts`;
|
||||
@@ -256,6 +288,7 @@ export interface ServerDraftMeta {
|
||||
task_id?: string | null;
|
||||
title: string;
|
||||
furthest_step: number;
|
||||
version?: number;
|
||||
chain_title?: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
@@ -276,6 +309,7 @@ export function toMeta(s: ServerDraftMeta): DraftMeta {
|
||||
taskId: s.task_id ?? null,
|
||||
title: s.title ?? "",
|
||||
furthestStep: step,
|
||||
version: s.version ?? 0,
|
||||
chainTitle: s.chain_title ?? null,
|
||||
createdAt: s.created_at ?? "",
|
||||
updatedAt: s.updated_at ?? "",
|
||||
@@ -313,6 +347,7 @@ export function fromUpdate(p: DraftUpdatePayload) {
|
||||
if (p.step1 !== undefined) out.step1 = p.step1;
|
||||
if (p.step2 !== undefined) out.step2 = p.step2;
|
||||
if (p.step3 !== undefined) out.step3 = p.step3;
|
||||
if (p.expectedVersion !== undefined) out.expected_version = p.expectedVersion;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -366,6 +401,23 @@ export async function createDraft(payload: DraftCreatePayload): Promise<DraftRec
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 409 乐观锁冲突转成类型化错误(解析服务端最新版本号)。个人与任务草稿客户端共用,
|
||||
* 保证两条 PUT 路径的冲突语义完全一致。非 409 原样放行。
|
||||
*/
|
||||
export async function throwIfDraftConflict(res: Response, id: string): Promise<void> {
|
||||
if (res.status !== 409) return;
|
||||
const raw = await res.text().catch(() => "");
|
||||
let currentVersion: number | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { detail?: { current_version?: number } };
|
||||
currentVersion = parsed?.detail?.current_version ?? null;
|
||||
} catch {
|
||||
currentVersion = null;
|
||||
}
|
||||
throw new DraftVersionConflictError(id, currentVersion);
|
||||
}
|
||||
|
||||
export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise<DraftRecord | null> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
@@ -373,6 +425,7 @@ export async function updateDraft(id: string, patch: DraftUpdatePayload): Promis
|
||||
body: JSON.stringify(fromUpdate(patch)),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
await throwIfDraftConflict(res, id);
|
||||
const data = await readJson<ServerDraftFull>(res, "update draft");
|
||||
const record = toRecord(data);
|
||||
writeYLog({
|
||||
|
||||
@@ -190,6 +190,12 @@ export interface StreamIntentRequest {
|
||||
model?: string;
|
||||
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
|
||||
intentAgentId?: string;
|
||||
/**
|
||||
* 本回合幂等键(Phase 4 单飞):同一 (threadId, clientTurnId) 在后端至多一个 run。
|
||||
* 缺省由调用方(useStep1Intent)每条消息生成;同一回合的重试 / 双击复用它,后端据此
|
||||
* 去重(在跑→丢弃重复请求 / 已完成→幂等回放),杜绝并发下双发意图 / 重复澄清卡。
|
||||
*/
|
||||
clientTurnId?: string;
|
||||
/** Optional hidden business/task context injected into the model run. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Step 1 用户上传的参考文件(可选)。 */
|
||||
@@ -403,6 +409,8 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
model: req.model,
|
||||
intent_agent_id: req.intentAgentId || undefined,
|
||||
task_context: req.taskContext ?? undefined,
|
||||
// Phase 4 单飞幂等键:缺省由后端兜底生成,但调用方应始终传(useStep1Intent 每条消息生成)。
|
||||
client_turn_id: req.clientTurnId || undefined,
|
||||
// 仅在有文件时带上,空数组后端也安全忽略。
|
||||
files: req.files && req.files.length > 0 ? req.files : undefined,
|
||||
}),
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* 增量事件归并。
|
||||
*/
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
@@ -87,6 +89,9 @@ function toStartBody(p: StartJobPayload) {
|
||||
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
|
||||
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
|
||||
return {
|
||||
// 幂等请求 id:缺省现生成;「同一草稿至多一个活跃作业」由后端作用域唯一键保证,
|
||||
// requestId 仅作审计/排查线索(重试时复用可让后端识别「同 id 但入参变了」的异常重试)。
|
||||
requestId: p.requestId ?? nanoid(),
|
||||
draftId: p.draftId ?? null,
|
||||
// taskId 非空 → task 作业(按 task 共享、不分权,报告写回独立 task-draft 存储)。
|
||||
...(p.taskId ? { taskId: p.taskId } : {}),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
fromCreate,
|
||||
fromUpdate,
|
||||
readJson,
|
||||
throwIfDraftConflict,
|
||||
toMeta,
|
||||
toRecord,
|
||||
type DraftCreatePayload,
|
||||
@@ -103,6 +104,7 @@ export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Pr
|
||||
body: JSON.stringify(fromUpdate(patch)),
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
await throwIfDraftConflict(res, id);
|
||||
const data = await readJson<ServerDraftFull>(res, "update task draft");
|
||||
const record = toRecord(data);
|
||||
writeYLog({
|
||||
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
deleteDraft,
|
||||
deriveDraftName,
|
||||
getDraft,
|
||||
isDraftVersionConflictError,
|
||||
listDrafts,
|
||||
updateDraft,
|
||||
type DraftCreatePayload,
|
||||
type DraftRecord,
|
||||
type DraftStep2RunSnapshot,
|
||||
type DraftStep2Snapshot,
|
||||
type DraftStep3Snapshot,
|
||||
type DraftUpdatePayload,
|
||||
@@ -42,6 +44,34 @@ import {
|
||||
} from "@/roundtable-planning/api/task-drafts";
|
||||
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
|
||||
|
||||
/**
|
||||
* 409 冲突后的 step2 合并:以**服务端**为基准,把本地 runs 按 id upsert 回去。
|
||||
*
|
||||
* 这样既保住并发写手(后台作业 / 另一个标签页)新增的 runs,又保留本标签页刚产出的
|
||||
* run 内容。activeRunId 若本地显式有值则以本地为准(本标签页正在看的 run),否则保留
|
||||
* 服务端值。其余 step2 顶层字段以本地为准(本地是最新交互态)。一致性来自 DB 版本号,
|
||||
* 合并只是把双方都不丢地拼回来。
|
||||
*/
|
||||
function mergeStep2OnConflict(
|
||||
local: DraftStep2Snapshot,
|
||||
server: DraftStep2Snapshot | null,
|
||||
): DraftStep2Snapshot {
|
||||
if (!server) return local;
|
||||
const byId = new Map<string, DraftStep2RunSnapshot>();
|
||||
for (const run of server.runs ?? []) {
|
||||
if (run?.id) byId.set(run.id, run);
|
||||
}
|
||||
for (const run of local.runs ?? []) {
|
||||
if (run?.id) byId.set(run.id, run); // 本地覆盖同 id 的服务端 run
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
...local,
|
||||
runs: Array.from(byId.values()),
|
||||
activeRunId: local.activeRunId ?? server.activeRunId,
|
||||
};
|
||||
}
|
||||
|
||||
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
|
||||
export interface DraftListItem {
|
||||
id: string;
|
||||
@@ -198,6 +228,10 @@ export function useDraftPersistence(
|
||||
|
||||
// 同步镜像,供保存队列在 render 之前读到最新 id(修复重复建草稿竞态)。
|
||||
const currentDraftIdRef = useRef<string | null>(null);
|
||||
// 当前草稿的乐观锁版本号(本地副本基于的 server version)。update 时经 expectedVersion
|
||||
// 回传;冲突(409)后重拉最新草稿、合并本地改动、用新版本号重试。load/create/update 成功
|
||||
// 后同步刷新,保证下一次写基于最新版本。
|
||||
const draftVersionRef = useRef<number | null>(null);
|
||||
// 串行化所有写操作,避免并发 create。
|
||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
// 当前步骤镜像 —— 自动保存(不带 furthestStep)落到 create 分支时,用它兜底建档步骤。
|
||||
@@ -207,10 +241,12 @@ export function useDraftPersistence(
|
||||
}, [currentStep]);
|
||||
|
||||
// 包一层 setter:同时更新 state 与同步 ref。主页面通过它 setCurrentDraftId(null)。
|
||||
// 切换到不同草稿(或清空)时把乐观锁版本一并复位,防止拿旧草稿的版本号去写新草稿。
|
||||
const setCurrentDraftId = useCallback<React.Dispatch<React.SetStateAction<string | null>>>(
|
||||
(action) => {
|
||||
setCurrentDraftIdState((prev) => {
|
||||
const next = typeof action === "function" ? (action as (p: string | null) => string | null)(prev) : action;
|
||||
if (next !== currentDraftIdRef.current) draftVersionRef.current = null;
|
||||
currentDraftIdRef.current = next;
|
||||
return next;
|
||||
});
|
||||
@@ -273,14 +309,40 @@ export function useDraftPersistence(
|
||||
|
||||
const id = currentDraftIdRef.current;
|
||||
if (id) {
|
||||
const updated = await apiUpdate(id, {
|
||||
...(furthestStep !== undefined ? { furthestStep } : {}),
|
||||
step1,
|
||||
step2,
|
||||
...(step3 ? { step3 } : {}),
|
||||
});
|
||||
if (updated) return updated.id;
|
||||
// 服务端已不存在(被删/换账号)→ 落回新建。
|
||||
try {
|
||||
const updated = await apiUpdate(id, {
|
||||
...(furthestStep !== undefined ? { furthestStep } : {}),
|
||||
step1,
|
||||
step2,
|
||||
...(step3 ? { step3 } : {}),
|
||||
...(draftVersionRef.current !== null ? { expectedVersion: draftVersionRef.current } : {}),
|
||||
});
|
||||
if (updated) {
|
||||
draftVersionRef.current = updated.version;
|
||||
return updated.id;
|
||||
}
|
||||
// 服务端已不存在(被删/换账号)→ 落回新建。
|
||||
} catch (err) {
|
||||
if (!isDraftVersionConflictError(err)) throw err;
|
||||
// 乐观锁冲突:另一写手(后台作业 / 另一标签页 / 重命名)已推进版本。
|
||||
// 重拉最新草稿,把本地 step2.runs 合并回去,用新版本号重试一次——双方都不丢。
|
||||
const latest = await apiGet(id);
|
||||
if (latest) {
|
||||
const mergedStep2 = mergeStep2OnConflict(step2, latest.step2);
|
||||
const retried = await apiUpdate(id, {
|
||||
...(furthestStep !== undefined ? { furthestStep } : {}),
|
||||
step1,
|
||||
step2: mergedStep2,
|
||||
...(step3 ? { step3 } : {}),
|
||||
expectedVersion: latest.version,
|
||||
});
|
||||
if (retried) {
|
||||
draftVersionRef.current = retried.version;
|
||||
return retried.id;
|
||||
}
|
||||
}
|
||||
// latest 为 null(被删)或重试仍 404 → 落回新建。
|
||||
}
|
||||
}
|
||||
const created = await apiCreate({
|
||||
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
||||
@@ -294,6 +356,7 @@ export function useDraftPersistence(
|
||||
...(step3 ? { step3 } : {}),
|
||||
});
|
||||
currentDraftIdRef.current = created.id;
|
||||
draftVersionRef.current = created.version;
|
||||
setCurrentDraftId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
@@ -301,7 +364,7 @@ export function useDraftPersistence(
|
||||
saveQueueRef.current = next.catch(() => undefined);
|
||||
return next as Promise<string | null>;
|
||||
},
|
||||
[apiCreate, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
|
||||
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, getStep2Snapshot, getStep3Snapshot, hasSavableContent, setCurrentDraftId],
|
||||
);
|
||||
|
||||
const ensureDraft = useCallback(
|
||||
@@ -340,12 +403,35 @@ export function useDraftPersistence(
|
||||
const run = async (): Promise<string | null> => {
|
||||
const id = currentDraftIdRef.current;
|
||||
if (id) {
|
||||
const updated = await apiUpdate(id, { furthestStep, step2 });
|
||||
if (updated) {
|
||||
currentDraftIdRef.current = updated.id;
|
||||
return updated.id;
|
||||
try {
|
||||
const updated = await apiUpdate(id, {
|
||||
furthestStep,
|
||||
step2,
|
||||
...(draftVersionRef.current !== null ? { expectedVersion: draftVersionRef.current } : {}),
|
||||
});
|
||||
if (updated) {
|
||||
currentDraftIdRef.current = updated.id;
|
||||
draftVersionRef.current = updated.version;
|
||||
return updated.id;
|
||||
}
|
||||
// 服务端已不存在 → 落回新建。
|
||||
} catch (err) {
|
||||
if (!isDraftVersionConflictError(err)) throw err;
|
||||
// 冲突 → 重拉合并 runs 后用新版本号重试一次(与 enqueueSave 同策略)。
|
||||
const latest = await apiGet(id);
|
||||
if (latest) {
|
||||
const retried = await apiUpdate(id, {
|
||||
furthestStep,
|
||||
step2: mergeStep2OnConflict(step2, latest.step2),
|
||||
expectedVersion: latest.version,
|
||||
});
|
||||
if (retried) {
|
||||
currentDraftIdRef.current = retried.id;
|
||||
draftVersionRef.current = retried.version;
|
||||
return retried.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 服务端已不存在 → 落回新建。
|
||||
}
|
||||
const step1 = getStep1Snapshot();
|
||||
if (!hasSavableContent(step1, step2)) return null;
|
||||
@@ -360,6 +446,7 @@ export function useDraftPersistence(
|
||||
step2,
|
||||
});
|
||||
currentDraftIdRef.current = created.id;
|
||||
draftVersionRef.current = created.version;
|
||||
setCurrentDraftId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
@@ -367,7 +454,7 @@ export function useDraftPersistence(
|
||||
saveQueueRef.current = next.catch(() => undefined);
|
||||
return next as Promise<string | null>;
|
||||
},
|
||||
[apiCreate, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
|
||||
[apiCreate, apiGet, apiUpdate, getStep1Snapshot, hasSavableContent, setCurrentDraftId],
|
||||
);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
@@ -463,6 +550,8 @@ export function useDraftPersistence(
|
||||
setStep2InitGate(draft.furthestStep >= 2);
|
||||
|
||||
setCurrentDraftId(draft.id);
|
||||
// 必须在 setCurrentDraftId 之后:切换草稿会先把版本复位,这里再打上加载到的版本。
|
||||
draftVersionRef.current = draft.version;
|
||||
setIsHistoryOpen(false);
|
||||
prevStepRef.current = draft.furthestStep;
|
||||
setCurrentStep(draft.furthestStep);
|
||||
@@ -563,8 +652,12 @@ export function useDraftPersistence(
|
||||
void (async () => {
|
||||
try {
|
||||
// 只发 title —— 与自动保存的「不带 title」互补,重命名是唯一改标题的入口。
|
||||
// 不带 expectedVersion:部分更新不触碰内容字段,与自动保存并发时双方各写各的
|
||||
// 字段;自动保存侧撞 409 会重拉合并重试(标题被保留),一致性由 DB 版本保证。
|
||||
const updated = await apiUpdate(id, { title: trimmed });
|
||||
if (updated) {
|
||||
// 重命名的正是当前草稿 → 同步版本号,省掉下一次自动保存的一次 409 往返。
|
||||
if (id === currentDraftIdRef.current) draftVersionRef.current = updated.version;
|
||||
await refreshList();
|
||||
triggerToast("已重命名");
|
||||
}
|
||||
|
||||
@@ -447,12 +447,19 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4 单飞幂等键:每个回合一个稳定 id。后端据此对 (threadId, clientTurnId)
|
||||
// 跨 worker 单飞——重复 / 双击命中既有回合即幂等返回,杜绝并发下双发意图。
|
||||
const clientTurnId =
|
||||
(typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID()) ||
|
||||
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const res = await streamIntent({
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
intentAgentId: intentAgentIdRef.current,
|
||||
taskContext: taskContextRef.current,
|
||||
clientTurnId,
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (delta) => {
|
||||
|
||||
@@ -153,6 +153,13 @@ export interface StartJobPayload {
|
||||
* 普通会商省略。`toStartBody` 里下发为后端 `business_code`。
|
||||
*/
|
||||
businessCode?: string | null;
|
||||
/**
|
||||
* 幂等请求 id(Phase 3 并发根治)。缺省由 `toStartBody` 用 nanoid 现生成;调用方若想在
|
||||
* 逻辑重试间复用同一 id(便于后端审计「同 requestId 但入参变了」的异常重试),可显式传入。
|
||||
* 「同一草稿至多一个活跃作业」的真正互斥由后端按 `(draftId/userId/taskId)` 作用域的唯一活跃键
|
||||
* 保证,requestId 仅作审计/排查线索,不承担正确性。
|
||||
*/
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/** 终态集合——SSE/轮询判定作业是否结束。 */
|
||||
|
||||
Reference in new issue
Block a user