Skip to content

fix(workspace): report failed run actions in the drawer - #5259

Open
songoow wants to merge 4 commits into
loopx-project:mainfrom
songoow:codex/workspace-drawer-run-action-feedback
Open

songoow wants to merge 4 commits into
loopx-project:mainfrom
songoow:codex/workspace-drawer-run-action-feedback

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: In the Personal Workspace run drawer, Interrupt, Retry, New Session, Close Session and correction called their callbacks with a bare void. When the Chat service rejected the request, the rejection escaped as an unhandled promise rejection and the drawer showed nothing, so a failed Close Session looked successful.
  • Observable before → after: with the Chat service answering DELETE /api/chat/sessions/{id} with HTTP 500, before the page raised an unhandled rejection and showed no feedback; after the drawer shows 关闭 Session失败:<reason> (role="alert") and no page error is raised.
  • Issue/task and intended base: none; base main.

Scope And Continuation

  • Completed scope and remaining work: complete within this scope. Each run action tracks its own pending/error state: only the triggering button is disabled while its request is in flight, and actions never lock each other, so a correction Turn in flight can still be interrupted (the typed-actions scenario covers this).
  • Slice boundary / successor: retryManagerSession still maps every resume failure to resume_failed without its reason; it already shows the recovery panel, so it is left unchanged here.

Validation

  • Tested revision: ed23a93
  • 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: the new step closes a Session against a fixture that rejects deletion and requires the drawer alert with no page error.
regression_parity passed Failing-before check: with the source fix reverted and the new step kept, chat-recovery times out waiting for the alert.
integration passed Drawer-adjacent scenarios typed-actions, loopx-mode, execution-chip pass. A first revision that locked all run actions while one was pending failed typed-actions (Interrupt disabled during a correction Turn); the per-action state fixes it.
real_entrypoint passed loopx serve-status + loopx chat against an isolated synthetic registry and a stub Codex app-server; only the DELETE response was forced to HTTP 500 in the browser.
integration failed Pre-existing on main (7fa104d), unrelated to this change: scenarios goal-draft, capability-scope, steward-group-trigger, conversation-input, automation-cadence, steward-model-settings fail on a clean checkout of main as well; personal-workspace-contract.test.mjs fails at the workspace_ref: "current" assertion after #4376 moved it to goal-create-request.ts. The drawer assertions in that file run before it and pass.
  • Coverage and gaps: the changed paths are the drawer run-action handlers and their feedback; covered by chat-recovery (failure), typed-actions (interrupt during an in-flight correction) and a real-service run. The whole browser smoke cannot run end to end on main because of the pre-existing failures above.

Frontend / Visual Evidence

  • UI impact: changed
  • Before: after a rejected Close Session the drawer menu stays unchanged with no message.
  • After: a red line under the run menu reads 关闭 Session失败:simulated backend failure; while a request is in flight, a spinner line reads <action>进行中… and only that button is disabled.
  • States and viewports shown: desktop 1440×900, error state (screenshots available on request; not attachable from the CLI).
  • Source data: synthetic
  • Attention review: the message appears only after the user acts and only in the drawer that owns the action; no new persistent chrome. It reuses the existing feedback style of the Sub-agent settings panel.

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 moving the run actions' error handling into the dashboard-page.tsx callbacks; kept it at the drawer because the drawer owns the controls and their feedback, and the callbacks keep their existing throw-on-failure contract.

Interrupt, retry, new Session, close Session and correction called their
callbacks with a bare `void`, so a rejected Chat request escaped as an
unhandled promise rejection and the drawer showed no feedback. Closing a
Session against a failing backend looked like it had succeeded.

Track each run action separately: its own button is disabled while the
request is in flight and a failure stays visible with its reason. Actions
do not lock each other, so a correction Turn in flight can still be
interrupted.

Signed-off-by: song <liusongstep@gmail.com>
The chat-recovery scenario now closes a Session against a fixture that
rejects the deletion and requires a visible drawer alert with no unhandled
page error.

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.

结论:REQUEST_CHANGES。单个操作的错误反馈修好了,但新状态未绑定 Run/请求身份,会把 A 的迟到响应写到 B,并移除 B 尚在等待的关闭防重入保护。不是因为无关的红 CI 或基线断言而拒绝。

English verdict: REQUEST_CHANGES
Reviewed exact head: ed23a93

动机

用户在 Run 详情里关闭 Session、纠偏、中断或重试恢复时,需要知道请求是否还在进行、失败原因是什么,并能继续采取正确的恢复动作。原来的异步回调可能直接产生未处理的 rejection,抽屉没有反馈。本 PR 针对的是这条日常控制路径,不需要引入新的后端状态机或权限模型。

改动思路

在现有 ContextDrawer 内增加一个按 action kind 管理的轻量 Promise wrapper,把 pending/error 映射成禁用按钮和可见提示;继续使用现有 dashboard-page 回调及 Chat API,而不接管它们的 Session 生命周期。每个动作只保护自己尤其重要:纠偏请求等待时,用户仍应能中断运行。

这个方向和成本是合适的,但 request 的生命周期长于当前 selection。按 kind 存状态、切换 Run 时清空,再让旧 Promise 修改当前字典,没有解决最基本的归属问题。需要在现有 owner 内补 Run identity/request generation 的约束,不需要全局 busy 锁,也不需要新服务。

具体改动

实际 PR 是四个文件、+79/-10:抽屉新增 action kind/status、wrapper 和反馈;i18n 增加对应的中英文提示;CSS 复用既有视觉 token;chat-recovery 增加一次关闭失败仍可继续使用 Session 的回归。没有改后端关闭权限、WorkspaceRun 输入或持久化协议。

关键代码讲解

  1. performRunAction:先写 pending,再 await 原回调;成功删除该 kind,失败写 error。它确实捕获了 rejection,但删除/覆盖的是“当前字典里的 kind”,没有检查这是哪个 Run、哪一次请求。成功分支也存在同样的归属缺口。
  2. selectedRunId reset / runActionHandler:切换 Run 会清空字典;handler 只把旧 Run 捕获给 callback,没有把其身份一起绑定给状态。清空显示不等于旧请求结束,所以 reset 不能代替 completion fence。
  3. closeManagerSession:现有回调用被捕获的 sessionId 发 DELETE,失败会在后续 binding 清理之前 reject。后端目标仍然正确;错误发生在新 wrapper 把这份结果投影给另一条 Run。因此修复应落在 UI action owner,而不是改关闭 API。

已验证的阻断项 [P2]:同一 Goal 的 Session A 点击关闭,保持 DELETE pending;直接选择 B 并关闭 B;先让 A 返回 503。此时 B 抽屉出现 A 的具体失败原因,B 的关闭按钮变为 enabled,但独立服务器仍记录 B DELETE pending。两条请求目标一直是不同 Session。base 的相同场景不会把 A 错误显示在 B;head 的独立“不串 Run”断言退出 1。修复后请加入这个两 Session/乱序 completion 场景,并同时验证旧请求成功返回、自己的失败/重试和纠偏期间可中断,避免用全局禁用掩盖问题。

对主干的风险

本地已通过:完整 dashboard TypeScript/Vite/Chat asset 构建、head chat-recovery(含新的失败关闭反馈)、base/head 同一个 typed-actions 场景、实际 PR diff hygiene。独立 Ego 验证运行的是生产 dashboard、真实 React selection 和现有 Chat fetch/callback 链路;仅 Session inventory 和延迟 HTTP 错误由隔离的合成服务器提供,没有真实关闭现有 Session、调用模型或修改活动 Goal。单个动作 pending 会禁用自己的按钮,自己的失败会出现 alert,head 不再产生未处理 rejection;这些通过不能覆盖跨 Session 的迟到结果。

另有一个明确的无关失败:原 personal-workspace-contract.test.mjs:359 用静态 regex 要求 action-form 里直接出现 workspace_ref: current,但该未改动文件使用请求 builder。base/head 同命令同断言失败,完整 stderr 只替换 checkout root 后完全一致,指纹为 d78fc33605ec3c3f4e1a94867a40929738e082576e7f0380e6e906d27a1f5f32;相关 source/test blob 未变化。这条保持 failed 并交给原测试 owner 对齐,不作为本 PR request changes 的原因。本次未查询或等待 GitHub CI。

语义与 CI 对齐

新的 RunActionKind/RunActionState 是显式 TS 类型,不是文本分类;没有默认关闭能力或新 actor 权限。默认 UX 从未监督的回调变成可见 pending/error 已明确披露。当前违反的是“结果和防重入状态必须属于产生它的 Run/请求”,不是未来 RFC 条件。exact-scope change-quality 已如实记录 fail,verify 保留未修复项;无关断言也没有被改写成绿色。

我的整体评价

动机明确、复用 owner 正确、改动规模合理,单个失败路径也确实改善了。但用户最需要依赖反馈做恢复时,不能看到另一条 Session 的错误或重复关闭尚在等待的 Session。建议保留现有轻量 wrapper,补身份/请求 fencing 和真实重叠回归后再批准。这也是本次有界 future-facing pass 识别出的同域修复;本轮仅评审,没有改代码或合并。

songoow and others added 2 commits September 28, 2026 20:58
…r-run-action-feedback

Signed-off-by: song <22676124+songoow@users.noreply.github.com>
Run action state was keyed only by action kind and cleared on selection
change, so a late result from Session A overwrote the current Run's
entry: A's failure appeared on Session B and B's pending close guard was
released while B's DELETE was still in flight. Key the state by Run, so a
late completion only settles its own Run's action and a Run's failure is
still shown when the user returns to it.

The chat-recovery scenario holds two Session closes and settles A's late
failure and late success while B's close is pending.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: song <22676124+songoow@users.noreply.github.com>
@songoow

songoow commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

已处理 exact-head ed23a93 的 P2,新 head 899adc7(另合入最新 main)。

修复:抽屉的 run action 状态从 Record<kind, state> 改成 Record<runId, Record<kind, state>>。performRunAction 接收发起请求的 run,pending、成功和失败都只写回该 Run 的条目,防重入检查也按该 Run 判断。去掉了切换 selection 时清空状态的 effect:迟到的结果不会再落到当前 Run 上,回到原 Run 时仍能看到它自己的失败。仍然是每个动作各自保护自己,纠偏进行中不会阻塞中断。

验证(Node 24.21.0):chat-recovery 新增两个 execution Session(同一 Goal),并用 route 挂起它们的 DELETE:

  1. A 关闭挂起 → 选 B 关闭挂起 → A 以 503 返回:B 不显示 A 的原因,B 的关闭按钮仍然禁用;B 以 503 返回后显示 B 自己的原因,按钮恢复;回到 A 显示 A 的原因,不显示 B 的。
  2. A 重试关闭(旧错误消失)→ B 关闭挂起 → A 成功返回:B 仍然禁用;B 成功后反馈清空、按钮恢复。

去掉生产代码修复后,场景在 "Session A's late close failure was reported on Session B" 处失败。personal-workspace-contract.test.mjs 和 npm run build 均通过。"纠偏期间可中断" 需要 canInterrupt 的运行态,fixture 里不方便构造,这次没有新增断言;每个 kind 独立的 guard 没有变化。

@cocolord cocolord 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.

APPROVE — exact head 899adc7 修复了上轮跨 Run 迟到结果污染的阻塞问题,并为真实失败、同类防重、异类并发和逆序完成建立了浏览器级证据。未发现当前 diff 引入的 blocker。

动机

这个 PR 解决的是一个明确的操作者体验缺口:Personal Workspace 的纠偏、中断、重试、新建 Session 和关闭 Session 原先都以 fire-and-forget 方式调用回调。一旦 Chat 服务拒绝请求,Promise rejection 会逃逸到页面,drawer 既不显示进行中,也不显示失败,用户会误以为操作成功,或者在不清楚真实状态时重复点击。

相对 base,当前 head 把每次动作变成可观察、可归属、可重试的交互:只有发起动作的按钮在等待时禁用,失败会在该 Run 的 drawer 内以本地化 alert 展示,纠偏文本只在真正成功后清空。这个切片没有改变 Session/Turn 的服务端权威,也没有引入自动重试;收益集中且可以由用户直接感知。

改动思路

改动把临时交互状态留在现有 ContextDrawer owner。RunActionKind 明确枚举五类动作,RunActionState 只表达 pending/error;runActions 先按 runId、再按 action kind 保存状态。performRunAction 捕获发起时的 WorkspaceRun,写入 pending,await 既有 callback,然后只清理或更新同一个 Run 的同一个 kind。这样既阻止同一 Run 同一动作的重复提交,又保留不同动作并行,例如纠偏进行中仍可中断。

底层 mutation、权限和 Session/Turn 生命周期仍由原 callbacks 与 Chat service 决定;drawer 只把 Promise settlement 投影成 UI receipt。当前选中的 Run 只读取自己的 RunActionStates,因此用户切换到另一个 Run 后,前一个请求的迟到成功或失败不会污染新 Run,也不会释放新 Run 的 guard。失败不吞掉业务状态或假装成功,而是保留错误并允许用户再次操作。

具体改动

完整 diff 为四个文件、+164/-10:context-drawer.tsx 增加约 65 行净 production 逻辑;i18n.tsx 增加中英文 pending/failure 文案;personal-workspace.css 增加反馈布局样式;chat-recovery.mjs 增加 83 行 durable browser regression。没有新增 API、权限、持久化 schema、后端行为或首屏布局。

关键代码讲解

  • RunActionKind、RunActionState 和 RunActionStates 把动作类型与 pending/error 状态做成精确 typed contract,不依赖错误文本或 substring 分类。外层 Record 使用 WorkspaceRun.runId 作为展示归属,内层只允许五个 action kind。
  • setRunActionState 使用 functional update,只替换指定 runId + kind;成功时删除该 kind,只有内层为空时才删除 Run key。这个边界是本轮修复上轮 blocker 的核心,能承受两个 Run 的 promise 逆序完成。
  • performRunAction 在同一 runId + kind 已 pending 时拒绝重入,随后 await 原 callback。成功返回 true 并清理状态,失败捕获 Error message、保存 error 并返回 false;sendCorrection 因而只在 true 时清空草稿。
  • runActionHandler 捕获触发时的 selection.item,而不是在 promise settle 时重新读取当前 selection。五个现有按钮都经过统一 helper,但不同 kind 不互锁,保留 typed-actions 已要求的 correction-in-flight 时 Interrupt 能力。
  • runActionFeedback 只投影 selectedRunId 的状态。pending 使用 role=status 和 spinner,error 使用 role=alert,并通过中英文 key 组合动作名称和 public-safe reason;它不写 canonical Run 状态。

对主干的风险

最强回归场景是:Run A 发起 Close 后切换到 Run B,B 也发起 Close,随后 A 的迟到结果覆盖 B 或提前解除 B 的 guard。旧 reviewed head 只按 action kind 保存状态,确实允许这个竞态;当前 head 改为 runId -> kind,并新增两个 Session 逆序 failure/success 的真实浏览器回归。我复跑后,两边状态都只出现在发起它的 Run,失败没有 pageerror,同一 Run/kind 不会重复发送。

我也验证了明显的收益和关键反例:拒绝 DELETE 会显示“关闭 Session失败”且 Session 仍可继续使用;纠偏成功才清空输入,失败保留草稿;同 kind pending 时禁用,但纠偏与中断仍可并行。另用两个可见 Run 形态检查底层资源身份,task Run 和 Goal aggregate Run 实际分别请求 task Session 与 Goal conversation Session,没有复现“不同 runId 绕过 guard 却操作同一 Session”。runActions 可能在 drawer 生命周期内保留访问过的 Run 的小型 error 对象,但规模很小、卸载即释放,暂不构成 blocker;若未来有真实积累数据,可做有界淘汰。

exact head 的 chat-recovery 与 typed-actions real headless Chrome development smoke 通过,完整 dashboard build 通过,只有既有 chunk-size warning。安装 lockfile 依赖后,risk-based premerge 的 3 项 direct checks、4 项 catalog canaries 和 1 项 public-boundary check 全部通过,0 failure、0 manual hold。

远端仍有四个 Python shard、aggregate pytest 和 merge-gate 红灯。我把失败的相同 10 个 quota/control-plane case 在 immutable base 738115b 和当前 head 分别执行,结果均为相同 6 failed / 4 passed,失败 test ids 与断言一致;本 PR 只改 drawer、翻译、两行样式和 browser smoke,changed invariant 又有独立通过证据,因此这些红灯属于 pre-existing unrelated merge-readiness hold。它们仍需主干 owner 修复或重跑,但不构成本 PR 的 UI blocker。

语义与 CI 对齐

状态分类使用 typed action union 和精确 pending/error discriminant,没有 substring denylist。RunActionState 的命名准确限定为本地 presentation state,不冒充 canonical Run/Session lifecycle;动作回调和后端继续拥有实际 effect authority。PR 正文、可见文案与 smoke 已披露默认行为从“无等待/失败反馈”变为“同 Run 同动作防重并显示结果”。没有把机器强制约束描述为 guidance,也没有把产品特定措辞写入通用 control-plane contract。

我的整体评价

这是一个明确正向且比例合适的修复:用户收益可直接观察,production 机制仅是一层 drawer-local typed state,没有扩张后端、协议或权限边界;关键成功和失败路径都有真实浏览器证据。更重要的是,上轮容易被单一失败 smoke 漏掉的跨 Run 竞态,已通过身份归属修复和逆序完成回归闭合。

未来向前看的边界也合理:反馈状态继续归 drawer,Session/Turn 权威不被复制;当前没有必要抽出通用 action framework。除已独立归因的远端 CI hold,以及低风险的生命周期内小对象积累建议外,没有剩余 blocker,因此批准当前 exact head;APPROVE 不代表绕过 required checks 或授权 merge。

English verdict: APPROVE — exact head 899adc7 fixes the prior cross-Run late-result race by keying transient action state by Run and action kind. Real headless-browser coverage now proves rejected actions, same-kind re-entry guards, cross-kind interruptibility, correction draft retention, and reverse-order settlement across two Runs; the full dashboard build and all eight risk-based premerge checks pass. The remote quota/control-plane failures reproduce identically on the immutable base and head, so they remain a separate merge-readiness hold rather than a blocker introduced by this UI diff.

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.

3 participants