refactor: improve result reconciliation by distinguishing retryable errors and fatal failures
This commit is contained in:
@@ -3251,7 +3251,7 @@
|
||||
## 2026-07-13 画布 Agent 工具执行状态复用外部生成任务
|
||||
|
||||
- 决策:画布 Agent 的 OSS 工具消息使用 `status=not_completed|completed|failed|cancelled` 和可选 `externalJobId`;不使用 `cancelledAt`,不新增关联表。`external_generation_job` 是排队、执行、lease 与计费结算的唯一真相;OSS status 只表达该消息回填结果,不复制 queued / running。确认接口按 `conversationId + messageId + toolName` 稳定去重并复用既有编辑器 worker job kind。
|
||||
- 懒回填:`GET /conversation` 会在持有 conversation lock 后扫描 `status=not_completed` 且有 `externalJobId` 的工具消息,按 job id 定向读取主任务;完成时复用原工具 formatter 更新 system text、写入轻量媒体引用并标记 `completed`,任务本身失败时写入 `error` 并标记 `failed`。任务结果读取或 completed payload 解析 / formatter 首次失败后,在同一次 GET 内最多重试 3 次,每次等待 100ms 并重新读取主任务;仍失败才把工具消息写为 `failed`,不依赖前端再次刷新。排队 / 执行保持 `not_completed`。worker 的 `result_payload_json` 只保留 formatter 与媒体引用所需的轻量生成回包,不向通用 summary 状态接口投影。
|
||||
- 懒回填:`GET /conversation` 会在持有 conversation lock 后扫描 `status=not_completed` 且有 `externalJobId` 的工具消息,按 job id 定向读取主任务;完成时复用原工具 formatter 更新 system text、写入轻量媒体引用并标记 `completed`,任务本身失败时写入 `error` 并标记 `failed`。任务结果读取或 completed payload 解析 / formatter 首次失败后,在同一次 GET 内最多重试 3 次,每次等待 100ms 并重新读取主任务;读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后保留 `not_completed + externalJobId` 供下次 GET 继续 reconcile,确定性的 payload 损坏、结构不兼容或 formatter 错误才在重试耗尽后写为 `failed`,避免致命错误永久循环。排队 / 执行保持 `not_completed`。worker 的 `result_payload_json` 只保留 formatter 与媒体引用所需的轻量生成回包,不向通用 summary 状态接口投影。
|
||||
- 影响范围:画布 Agent 共享契约、确认/取消接口、编辑器生成入队 helper、对话状态展示与恢复。
|
||||
- 验证方式:`cargo check -p api-server -p shared-contracts --manifest-path server-rs/Cargo.toml`、画布 Agent 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ npm run check:server-rs-ddd
|
||||
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
|
||||
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
|
||||
- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
|
||||
- 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务;仍失败后把 OSS 工具消息原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误。不得依赖浏览器再次轮询才能耗尽重试,也不得让消息永久停在 `not_completed`。
|
||||
- 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。
|
||||
- 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、`editor_canvas.layers_json` 或 `external_generation_job` 的业务真相。
|
||||
|
||||
### 创作 / 游玩统一流程主干
|
||||
|
||||
@@ -34,6 +34,18 @@ use tokio::time::{Duration, sleep};
|
||||
pub(crate) const EDITOR_AGENT_RESULT_RECONCILE_MAX_RETRIES: u8 = 3;
|
||||
const EDITOR_AGENT_RESULT_RECONCILE_RETRY_DELAY: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum ResultReconcileFailure {
|
||||
RetryLater(String),
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
impl From<String> for ResultReconcileFailure {
|
||||
fn from(error: String) -> Self {
|
||||
Self::Fatal(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reconcile_editor_agent_tool_calls(
|
||||
state: &AppState,
|
||||
conversation: &EditorAgentConversationRecord,
|
||||
@@ -59,15 +71,19 @@ pub async fn reconcile_editor_agent_tool_calls(
|
||||
for retry_count in 0..=EDITOR_AGENT_RESULT_RECONCILE_MAX_RETRIES {
|
||||
let job = match state
|
||||
.spacetime_client()
|
||||
.get_external_generation_job_generated_artifacts(ExternalGenerationJobGetRecordInput {
|
||||
job_id: job_id.clone(),
|
||||
owner_user_id: conversation.owner_user_id.clone(),
|
||||
})
|
||||
.get_external_generation_job_generated_artifacts(
|
||||
ExternalGenerationJobGetRecordInput {
|
||||
job_id: job_id.clone(),
|
||||
owner_user_id: conversation.owner_user_id.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(job) => job,
|
||||
Err(error) => {
|
||||
last_reconcile_error = Some(format!("读取生成任务结果失败:{error}"));
|
||||
last_reconcile_error = Some(ResultReconcileFailure::RetryLater(format!(
|
||||
"读取生成任务结果失败:{error}"
|
||||
)));
|
||||
if should_retry_result_reconcile(retry_count) {
|
||||
sleep(EDITOR_AGENT_RESULT_RECONCILE_RETRY_DELAY).await;
|
||||
}
|
||||
@@ -114,12 +130,12 @@ pub async fn reconcile_editor_agent_tool_calls(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(error) = last_reconcile_error {
|
||||
// unlikely.
|
||||
// if we don't mark this as failed,
|
||||
// the client will poll external job id -> found completed -> get conv and reconcile again,
|
||||
// if here is fatal, it will be stuck in a loop.
|
||||
mark_result_reconcile_failed(&mut document.messages[message_index], error);
|
||||
if let Some(error) = last_reconcile_error
|
||||
&& apply_exhausted_result_reconcile_failure(
|
||||
&mut document.messages[message_index],
|
||||
error,
|
||||
)
|
||||
{
|
||||
reconciled.push(document.messages[message_index].clone());
|
||||
}
|
||||
}
|
||||
@@ -141,7 +157,7 @@ fn mark_job_failed(message: &mut EditorAgentMessage, error: String) {
|
||||
message.text = format!("[tool_call:{}] output: {error}", tool_call.tool_name);
|
||||
}
|
||||
|
||||
fn mark_result_reconcile_failed(message: &mut EditorAgentMessage, error: String) {
|
||||
fn mark_fatal_result_reconcile_failed(message: &mut EditorAgentMessage, error: String) {
|
||||
mark_job_failed(
|
||||
message,
|
||||
format!(
|
||||
@@ -150,21 +166,39 @@ fn mark_result_reconcile_failed(message: &mut EditorAgentMessage, error: String)
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_exhausted_result_reconcile_failure(
|
||||
message: &mut EditorAgentMessage,
|
||||
failure: ResultReconcileFailure,
|
||||
) -> bool {
|
||||
match failure {
|
||||
ResultReconcileFailure::RetryLater(_) => false,
|
||||
ResultReconcileFailure::Fatal(error) => {
|
||||
mark_fatal_result_reconcile_failed(message, error);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reconcile_completed_editor_agent_tool_call(
|
||||
message: &mut EditorAgentMessage,
|
||||
result_payload_json: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<(), ResultReconcileFailure> {
|
||||
let response = result_payload_json
|
||||
.ok_or_else(|| "生成任务缺少结果数据".to_string())
|
||||
.and_then(|payload| serde_json::from_str::<Value>(payload).map_err(|e| e.to_string()))?
|
||||
.ok_or_else(|| ResultReconcileFailure::RetryLater("生成任务缺少结果数据".to_string()))
|
||||
.and_then(|payload| {
|
||||
serde_json::from_str::<Value>(payload)
|
||||
.map_err(|error| ResultReconcileFailure::Fatal(error.to_string()))
|
||||
})?
|
||||
// TODO extract const
|
||||
.get("editor-agent-tool-call-result")
|
||||
.cloned()
|
||||
.ok_or_else(|| "生成任务缺少 Agent 工具调用结果".to_string())?;
|
||||
.ok_or_else(|| {
|
||||
ResultReconcileFailure::Fatal("生成任务缺少 Agent 工具调用结果".to_string())
|
||||
})?;
|
||||
let tool_call = message
|
||||
.tool_call
|
||||
.as_mut()
|
||||
.ok_or_else(|| "消息缺少工具调用".to_string())?;
|
||||
.ok_or_else(|| ResultReconcileFailure::Fatal("消息缺少工具调用".to_string()))?;
|
||||
let tool_name = tool_call.tool_name.clone();
|
||||
|
||||
match tool_name.as_str() {
|
||||
@@ -226,7 +260,11 @@ fn reconcile_completed_editor_agent_tool_call(
|
||||
};
|
||||
tool_call.audios = vec![resp_to_asset::editor_agent_generated_audio(&result)];
|
||||
}
|
||||
_ => return Err(format!("不支持回填工具 {tool_name}")),
|
||||
_ => {
|
||||
return Err(ResultReconcileFailure::Fatal(format!(
|
||||
"不支持回填工具 {tool_name}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
tool_call.error = None;
|
||||
tool_call.status = EditorAgentToolCallStatus::Completed;
|
||||
@@ -274,11 +312,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_terminal_result_reconcile_marks_the_tool_call_failed() {
|
||||
fn exhausted_fatal_result_reconcile_marks_the_tool_call_failed() {
|
||||
let mut message = pending_tool_message();
|
||||
|
||||
mark_result_reconcile_failed(&mut message, "invalid result payload".to_string());
|
||||
let changed = apply_exhausted_result_reconcile_failure(
|
||||
&mut message,
|
||||
ResultReconcileFailure::Fatal("invalid result payload".to_string()),
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
let tool_call = message.tool_call.expect("tool call should remain present");
|
||||
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Failed);
|
||||
assert_eq!(
|
||||
@@ -287,4 +329,41 @@ mod tests {
|
||||
);
|
||||
assert!(message.text.contains("重试 3 次后仍失败"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_result_payload_is_retryable_and_preserves_pending_job_reference() {
|
||||
let mut message = pending_tool_message();
|
||||
|
||||
let error = reconcile_completed_editor_agent_tool_call(&mut message, None)
|
||||
.expect_err("missing result payload should be retried later");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
ResultReconcileFailure::RetryLater("生成任务缺少结果数据".to_string())
|
||||
);
|
||||
assert!(!apply_exhausted_result_reconcile_failure(
|
||||
&mut message,
|
||||
error,
|
||||
));
|
||||
let tool_call = message.tool_call.expect("tool call should remain present");
|
||||
assert_eq!(tool_call.status, EditorAgentToolCallStatus::NotCompleted);
|
||||
assert_eq!(tool_call.external_job_id.as_deref(), Some("job-1"));
|
||||
assert_eq!(tool_call.error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_failure_is_retryable_without_terminal_message_mutation() {
|
||||
let mut message = pending_tool_message();
|
||||
let error = ResultReconcileFailure::RetryLater(
|
||||
"读取生成任务结果失败:temporarily unavailable".to_string(),
|
||||
);
|
||||
|
||||
assert!(!apply_exhausted_result_reconcile_failure(
|
||||
&mut message,
|
||||
error,
|
||||
));
|
||||
let tool_call = message.tool_call.expect("tool call should remain present");
|
||||
assert_eq!(tool_call.status, EditorAgentToolCallStatus::NotCompleted);
|
||||
assert_eq!(tool_call.external_job_id.as_deref(), Some("job-1"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user