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

岗位会商写操作引入 command_id 幂等与版本锁,前端支持同键重试;圆桌后台作业改为 DB 租约 dispatcher、去重与草稿 outbox 补偿,并持久化 intent turns。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-14 11:08:12 +08:00
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/轮询判定作业是否结束。 */