Conversation
The Chat service accepts one Turn per Session. After a reload the page only knew about its own in-flight send, so while a recovered Turn was still running the composer stayed open and a new message was rejected with HTTP 409, shown as the raw English service error. While the conversation shows a Turn in flight, the send button and quick prompts wait and the composer explains that the Turn can be adjusted or interrupted from the reply. LoopX mode keeps delivering into its running Turn. A 409 that still arrives is shown as the same localized message. Signed-off-by: song <liusongstep@gmail.com>
Sends a long Turn, reloads while it runs, and requires the composer to wait with its hint, send nothing, and reopen once the Turn completes. Signed-off-by: song <liusongstep@gmail.com>
huangruiteng
left a comment
There was a problem hiding this comment.
English verdict: REQUEST_CHANGES
Reviewed exact head: 4929e8fa9c240ca0e01e30f85ede9aeb02eee19c.
阻塞项:[P2] 新 composer guard 会把已取消的历史恢复占位当成仍运行的 Turn;切走再返回后,即使同一 Turn 已完成,仍无法正常发送下一轮。定位:conversationTurnRunning。
动机
目标是合理的:普通 Chat 恢复一个仍在执行的 Turn 时,不应因为页面的 sending 标记已重置,就允许再提交普通消息。用户仍应能调整或中断本轮,Turn 完成后继续下一轮;LoopX 模式的队列、inbox 和 steer 入口不能被这个普通对话限制一起封住。这个 head 修好了“reload 后运行中仍可发送”的局部问题,但没有保住恢复完成后的持续使用路径,所以尚不能认定该目标已完整交付。
改动思路
实现复用了现有 Dashboard Session/Turn 回调、PersonalWorkspace composer、双语状态文案和 chat-recovery 场景,没有另建服务或持久化状态。普通发送的 onTurn 给占位回复补充 sourceTurnId/sourceSessionId,composer 用当前上下文消息中的 pending 与 Turn id 判断是否应禁用发送。LoopX delivery 分支先处理自身 ingress,因此仍可接受队列与追加指令。边界方向是对的,但 pending 是界面投影,不是当前 Session 的权威存活状态;恢复 effect 在取消后返回时会留下旧占位,这个旧行为现在被新 guard 放大成发送锁。
具体改动
完整 PR 是五个文件,+48/-7:发送与快捷入口 guard、接受 Turn 后的身份投影、双语提示、提示样式,以及既有恢复 smoke 的增量;没有新增协议、provider 或全局调度状态。
关键代码讲解
- conversationTurnRunning / composerBlocked:输入是当前上下文的 managerMessages 和 loopxDeliveryOpen。新规则对任意 pending 且有 sourceTurnId 的消息返回运行中,并与 sending 合并。它统一驱动按钮禁用与状态提示,但没有核对该消息是否属于当前 Session 的当前活动 Turn,终态读回也不能覆盖旧 pending。
- sendMessage:保留 LoopX delivery 的 queue/steer/inbox 路由,再拒绝普通 running-Turn 提交;快捷按钮和键盘也使用同一 guard。不能通过移除 guard 来修复终态锁,那会重新打开本 PR 原来要修的重复提交路径。
- 恢复 effect 的 pending producer:恢复已接受 Turn 时追加 sourceTurnId 占位。切换 Goal 会取消旧恢复;其 success/catch 在 cancelled 时先返回,没有结算旧占位。返回同一 Goal 又创建新占位,新占位完成并不退休旧占位。这是 guard 的真实上游,不只是一个虚构的消息组合。
- 普通发送的 onTurn:把接受的 Session/Turn identity 写到正在显示的回复,便于现有活动控制与 guard 消费。这是投影元数据,不应单凭 id 存在推导仍在执行。
对主干的风险
[P2] 我通过实际 React 页面、原生 fetch 和隔离的 HTTP 合成后端复现了完整流程:发送一轮并保持执行 → reload 恢复 → 切到独立 Goal B → 在完成前返回 A → 后端完成同一个 Turn。head 显示完成回复,独立 Session 读回为 ready/active_turn_id=null,后端只接受了一轮,草稿仍在;但 Send 继续禁用,仍显示“本轮回答进行中”。相同输入和界面路径在不可变 base 完成后可以发送。B 的 composer 不被 A 阻塞,这个正向范围检查已通过;问题不是全局锁,而是当前上下文中的历史 pending 锁。reload 可以缓解,不能把它说成永久无法恢复。
最小修改是在现有恢复/Session owner 中绑定当前身份与生命周期,并在取消、重放、终态读回时退休旧占位;避免另外维护一个 busy flag。扩展既有 chat-recovery:A→B→A 时完成同一 Turn,验证草稿保留、下一轮可发送,同时保留真正 running 时禁用、调整/中断与 LoopX exemption。还应检查切换新 Session 不继承旧占位,不能靠只改提示文字隐藏失败。
语义与 CI 对齐
当前义务是“当前活动 Turn 排斥普通新发送,终态恢复下一轮”,不是“所有历史 pending 都是活动执行”。同一个状态事实的 base/head UI 反例证明了语义违约;请沿以上当前 owner 修复后重跑 LOOPX_PERSONAL_WORKSPACE_SCENARIO=chat-recovery node examples/personal-workspace-browser-smoke.mjs,并补上上述切换/终态场景。
已亲自运行:dashboard 的 npm run build(TS、Vite 及 packaged Chat assets)、source 与 packaged chat-recovery、loopx-mode,以及 uv run --extra test python examples/loopx-chat-runtime-smoke.py,均通过。运行时 smoke 使用隔离状态与仓库 fake executor,不是 live 模型证明。新独立终态 oracle 在 head 失败。
另有既有 source-contract 检查失败:line359 期望未改动代码中的 workspace_ref current 字面表达。不可变 base 与 head 同命令、同失败 identity、完整 stderr 仅规范化 checkout 根路径后相同(SHA256 bf9163b485948835873b89c6518def30af7112495647b0193ce7d8d99d016e18);相关代码/断言均不在 PR 改动内。这项保持 failed 记录,但不是此次 REQUEST_CHANGES 的理由。本轮没有查询、等待或推断远端 CI。
我的整体评价
这是一项规模合适、应该继续完善的默认 Chat 修复,不需要扩展成新的状态机、协议或 Python owner。我做了相邻 future-facing pass:最有价值的精简是让现有 Session/Turn 生命周期提供唯一派生状态,消除历史占位与当前执行的双重知识;评审-only 未代作者修改。long_horizon 与 user_experience 均存在已复现回归:本轮结束了,用户却不能自然进入下一轮。source/packaged 正向 smoke 通过不覆盖这个反例;修复取消占位、终态及新 Session 的边界并验证完整续聊后再重审。没有权限或 actor-lifecycle 扩张,也没有 default-off 声明;这是既有默认行为的变更。本轮不修复、合并或升级此 PR。
Goal And Delivered Outcome
another turn is already running for this session.本轮回答进行中。可在回答里调整或中断本轮,结束后再发送。until the Turn completes. A 409 that still arrives (e.g. before the snapshot refreshes) is shown as the same localized text.main.Scope And Continuation
error_code; the client keys onactive_turn_id. A typed code on the server would be a separate contract change.Validation
staticpassednpx tsc --noEmitinapps/presentation/dashboard.integrationpassedLOOPX_PERSONAL_WORKSPACE_SCENARIO=chat-recovery node examples/personal-workspace-browser-smoke.mjs, 3 consecutive runs: sends a 5-second Turn, reloads while it runs, requires the hint, a disabled send button and no new Turn request, then requires the composer to reopen after completion.regression_paritypassedintegrationpassedmainalso pass on this branch, includingloopx-mode(delivery into a running Turn stays open),typed-actions,conversation-return-continuityandsteward-journey.real_entrypointpassedloopx serve-status+loopx chaton an isolated synthetic registry with a stub Codex app-server: a Turn left running, page reloaded;mainposted a 409 Turn and showed the English error, this branch posted nothing and showed the hint. A browser-forced 409 now renders the localized text.integrationnot_rungoal-draft,capability-scope,steward-group-trigger,conversation-input,automation-cadence,steward-model-settingsalready fail on a cleanmaincheckout, so they cannot validate this change.Frontend / Visual Evidence
Type of Change
LoopX Area
Technical Direction
Shared-authority RFC fixture impact
N/A
Boundary Checklist
none.Signed-off-bytrailer (git commit -s).Future-facing refactor pass: considered deriving "Turn running" from the runtime binding in
dashboard-page.tsx; kept the timeline's pending message as the single source because it already drives the reply's Adjust/Interrupt controls, so the composer and those controls cannot disagree.