From 9459e195e0cf4033bb98f587ed26375163c81bdb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:49:01 +0800 Subject: [PATCH] fix(chat): recover admitted project continuation before result review Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/app-conversation-and-async-inbox-v0.md | 3 +++ .../rfcs/app-conversation-and-async-inbox-v0.zh-CN.md | 5 +++-- .../collaboration/conversation_binding.ts | 7 +++++-- tests/control_plane_ts/conversation_binding.test.ts | 10 ++++++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md index a65cc9192b..5d16026790 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md @@ -2046,6 +2046,9 @@ send, delegate or replace the original work declaration. References in the old question use its original-time Core context. The original partial Turn and delivery remain immutable, and the same Lark worker returns the new answer in a separate, idempotent phase. Reservation crashes adopt that exact queued review. +If writable continuation was already admitted before the request journal was +updated, the worker first recovers that same queued Turn. Dispatch still +requires the original source, queue and native effect checks. The retained business obligation stays pending independently of the review's prose or failure; a review never starts a writable retry or another review. diff --git a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md index 7fef138d7f..4ea46d1948 100644 --- a/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md +++ b/docs/architecture/rfcs/app-conversation-and-async-inbox-v0.zh-CN.md @@ -1344,8 +1344,9 @@ completed Turn 继续明确保留,本路径不提供通用动作或执行重 复核使用现有工作区只读权限配置,禁用网络;可检查当前本地产物和回执,不能写入、 发送、委派或替换原未完声明。旧问题中的指代仅使用原提问之前的 Core 上下文。 原部分答复 Turn 和投递保持不变,由同一个飞书 worker 使用独立、幂等阶段补回新 -答复;预约崩溃只采用同一个已入队复核。原业务义务独立于复核正文或失败继续保留 -为待办;复核不能触发可写重试或又一次复核。 +答复;预约崩溃只采用同一个已入队复核。如果可写续办已入队、原请求日志尚未更新, +worker 先认回同一个 Turn。执行前仍重查原来源、队列和原生效果。原业务义务独立于复核正文或失败继续保留 +为待办。复核不能触发可写重试或又一次复核。 ### File 后端的空闲恢复观测 diff --git a/loopx/control_plane/collaboration/conversation_binding.ts b/loopx/control_plane/collaboration/conversation_binding.ts index 7da8338857..a3abecb673 100644 --- a/loopx/control_plane/collaboration/conversation_binding.ts +++ b/loopx/control_plane/collaboration/conversation_binding.ts @@ -1143,8 +1143,11 @@ function planUnfinishedWork(recovery: JsonObject, row: JsonObject, current: Json || recovery.attachments_present !== false || typeof completed.upstream_turn_id !== "string" || !completed.upstream_turn_id) return no; const observedResult = recovery.native_observation as JsonObject | null | undefined; - if (previous?.read_only_review === true || recovery.later_equivalent_input_present === true - || observedResult?.completed_result_review === true) { + // An admitted continuation may already have appended the original input. + // Recover its existing receipt before considering a fresh result review. + const existingWorkTurn = previous?.work_attempt === 1 && recovery.admitted_turn != null; + if (previous?.read_only_review === true || (!existingWorkTurn + && (recovery.later_equivalent_input_present === true || observedResult?.completed_result_review === true))) { return planCompletedResultReview(recovery, row, current, completed, previous, version, work); } const client = `external-${ref(row.request_ref, "original request")}-recovery-${version}-work-1`; diff --git a/tests/control_plane_ts/conversation_binding.test.ts b/tests/control_plane_ts/conversation_binding.test.ts index 451863502f..579584e174 100644 --- a/tests/control_plane_ts/conversation_binding.test.ts +++ b/tests/control_plane_ts/conversation_binding.test.ts @@ -1055,8 +1055,14 @@ test("unfinished completed work requires typed intent and pinned settled native/ message: packet.request.message, origin: "lark", attachments: [], status: "completed"}; const crash = {...packet, request: {...packet.request, context_recovery: reserved}, current_session: {...session, status: "busy", active_turn_id: admitted.turn_id}, - context_recovery: {...packet.context_recovery, native_observation: null, admitted_turn: admitted}}; - assert.equal(planBoundConversationRequest(crash).admitted_turn_id, admitted.turn_id); + context_recovery: {...packet.context_recovery, native_observation: null, admitted_turn: admitted, + later_equivalent_input_present: true, queued_input_present: true}}; + const recovered = planBoundConversationRequest(crash); + assert.equal(recovered.admitted_turn_id, admitted.turn_id); + assert.equal(recovered.work_attempt, 1); + assert.equal(recovered.read_only_review, undefined); + assert.equal(planBoundConversationRequest({...crash, context_recovery: {...crash.context_recovery, + source_verified: false}}).recovery_eligible, false); assert.equal(planBoundConversationRequest({...crash, context_recovery: {...crash.context_recovery, admitted_turn: {...admitted, message: "Changed input"}}}).recovery_eligible, false); for (const status of ["completed", "failed"]) {