Skip to content

fix(workspace): wait for a running Turn before sending - #5261

Open
songoow wants to merge 2 commits into
loopx-project:mainfrom
songoow:codex/workspace-composer-running-turn
Open

songoow wants to merge 2 commits into
loopx-project:mainfrom
songoow:codex/workspace-composer-running-turn

Conversation

@songoow

@songoow songoow commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Goal And Delivered Outcome

  • Outcome basis / optional anchor: reproduced defect (no issue).
  • Goal/source and gap: the Chat service accepts one Turn per Session. The Personal Workspace composer only blocked while this page was awaiting its own send, so after a reload with a Turn still running server-side, the composer stayed open; the next message was rejected with HTTP 409 and the reply showed the raw English error another turn is already running for this session.
  • Observable before → after: with a Turn still running after a reload, before pressing Enter posted a Turn (409) and showed the English error; after no Turn is posted, the send button and quick prompts wait, and the composer shows 本轮回答进行中。可在回答里调整或中断本轮,结束后再发送。 until the Turn completes. A 409 that still arrives (e.g. before the snapshot refreshes) is shown as the same localized text.
  • Issue/task and intended base: none; base main.

Scope And Continuation

  • Completed scope and remaining work: complete within this scope. The running state comes from the conversation's pending message with a Turn id, the same state that renders the reply's Adjust/Interrupt controls. LoopX mode keeps its existing queue/inbox/steer delivery into a running Turn and is not blocked.
  • Slice boundary / successor: the service still returns this 409 without an error_code; the client keys on active_turn_id. A typed code on the server would be a separate contract change.

Validation

  • Tested revision: 4929e8f
  • Run state: finished
  • Input classes: synthetic
Check kind Result Public-safe evidence / limitation
static passed npx tsc --noEmit in apps/presentation/dashboard.
integration passed LOOPX_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_parity passed Failing-before check: with the source change reverted, the new step times out waiting for the hint.
integration passed 16 scenarios that pass on main also pass on this branch, including loopx-mode (delivery into a running Turn stays open), typed-actions, conversation-return-continuity and steward-journey.
real_entrypoint passed loopx serve-status + loopx chat on an isolated synthetic registry with a stub Codex app-server: a Turn left running, page reloaded; main posted 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.
integration not_run Scenarios goal-draft, capability-scope, steward-group-trigger, conversation-input, automation-cadence, steward-model-settings already fail on a clean main checkout, so they cannot validate this change.
  • Coverage and gaps: the changed paths are the composer send guard, its disabled states, the hint and the 409 message branch in the Goal/manager send handler; each is exercised above. Untested: a snapshot that keeps a stale pending message after the service has finished the Turn would keep the composer waiting until the next snapshot refresh.

Frontend / Visual Evidence

  • UI impact: changed
  • Before: composer open during a running Turn; sending shows the English 409 error as the reply.
  • After: composer waits; a muted line above it explains how to adjust or interrupt the running Turn.
  • States and viewports shown: desktop 1440×900 running-Turn state (screenshots available on request; not attachable from the CLI).
  • Source data: synthetic
  • Attention review: the hint appears only while a Turn is running and replaces a failure the user could not act on; the Adjust/Interrupt controls it points to already sit on the pending reply.

Type of Change

  • Bug fix
  • Test update

LoopX Area

  • Public docs or presentation surface (README, protocols, dashboard)

Technical Direction

  • Direction / acceptance reference, when applicable: Operator surface and IM integration.

Shared-authority RFC fixture impact

N/A

Boundary Checklist

  • Neither the diff nor this PR body/comments/attachments disclose private state, credentials, raw traces or verifier output, internal links, or local machine paths.
  • I did not duplicate maintainer-owned benchmark work unless a maintainer split out a public issue for it.
  • I kept the change scoped to the linked issue/task.
  • I completed the visual evidence section for UI changes, or marked UI impact none.
  • Every commit includes a DCO Signed-off-by trailer (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.

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 huangruiteng left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 或全局调度状态。

关键代码讲解

  1. conversationTurnRunning / composerBlocked:输入是当前上下文的 managerMessages 和 loopxDeliveryOpen。新规则对任意 pending 且有 sourceTurnId 的消息返回运行中,并与 sending 合并。它统一驱动按钮禁用与状态提示,但没有核对该消息是否属于当前 Session 的当前活动 Turn,终态读回也不能覆盖旧 pending。
  2. sendMessage:保留 LoopX delivery 的 queue/steer/inbox 路由,再拒绝普通 running-Turn 提交;快捷按钮和键盘也使用同一 guard。不能通过移除 guard 来修复终态锁,那会重新打开本 PR 原来要修的重复提交路径。
  3. 恢复 effect 的 pending producer:恢复已接受 Turn 时追加 sourceTurnId 占位。切换 Goal 会取消旧恢复;其 success/catch 在 cancelled 时先返回,没有结算旧占位。返回同一 Goal 又创建新占位,新占位完成并不退休旧占位。这是 guard 的真实上游,不只是一个虚构的消息组合。
  4. 普通发送的 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。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants