Skip to content

fix(workspace): turn Agent Todo proposals into confirmable previews - #5266

Open
songoow wants to merge 13 commits into
loopx-project:mainfrom
songoow:codex/workspace-chat-todo-proposals
Open

songoow wants to merge 13 commits into
loopx-project:mainfrom
songoow:codex/workspace-chat-todo-proposals

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 agent's turn prompt asks for proposals: [{kind: "todo", text, priority, rationale}], and the Chat service emits them in turn.completed (and proposal.ready). Since the Personal Workspace promotion (feat(control-plane): promote the personal Agent workspace #3274), dashboard-page.tsx wrote them into a legacy proposalsByContext state that nothing reads. The reply said it had found a reviewable step, but no step could be seen, confirmed or recovered after a reload.
  • Observable before → after: in a Goal conversation where the Agent answers with one Todo proposal, before only the message was shown and the Goal state stayed unchanged; after a 新任务 · 待你确认 card appears under the answer, survives a reload, and 确认并应用 writes the Todo through the existing typed action apply with a read-back receipt.
  • Issue/task and intended base: none; base main.

Scope And Continuation

  • Completed scope and remaining work: complete within this scope.
    • Each Goal-conversation Todo proposal becomes a typed todo.create preview (goal_id, text, priority) created through the existing preview path, so the owner still confirms every write. Cards are left in the conversation (select: false); a single protected-action preview keeps opening the drawer as before.
    • The idempotency key is chat-todo-proposal:<turn id>:<index>, and the action store returns the stored preview for a repeated key, so observing one completion twice cannot offer a duplicate.
    • A recovered Turn stores its previews only for the Session's own Goal. The previous code fell back to the selected Goal or model.goals[0], which would have attached a manager proposal to an unrelated Goal once wired to writes.
    • Manager answers without a target Goal create nothing.
    • Removed the dead legacy card state, its preview/approve/settle handlers and state labels (−160 lines).
  • Slice boundary / successor: the manager-channel hint 请进入要修改的 Goal… is stored in lines, which the workspace shows only for an empty answer, so it is still not visible; a manager proposal has no target Goal to preview. Left unchanged here.

Validation

  • Tested revision: c2f128f
  • Run state: finished
  • Input classes: synthetic
Check kind Result Public-safe evidence / limitation
static passed npx tsc --noEmit in apps/presentation/dashboard.
integration passed New scenario LOOPX_PERSONAL_WORKSPACE_SCENARIO=chat-todo-proposal node examples/personal-workspace-browser-smoke.mjs (2 runs): manager proposal → no preview; Goal proposal → exactly one todo.create preview with Goal and priority and a Turn-derived key, card visible without opening the drawer, still visible after reload, applied on confirmation. The browser fixture now forwards scripted proposals in turn.completed as the service does; it previously always sent [], which is why no scenario could catch this.
regression_parity passed Failing-before check: with the source change reverted the scenario never sees the proposal card.
integration passed 16 scenarios that pass on main also pass here (including typed-actions, team-plan, steward-journey, which share the preview path and the fixture).
real_entrypoint passed loopx serve-status + loopx chat on an isolated synthetic registry with a stub Codex app-server answering with a Todo proposal: card shown, survived reload, 确认并应用 → apply 200, drawer 操作已完成,结果状态已通过读回验证。, and the Goal state file gained - [ ] [P1] … with a loopx:todo marker.
integration not_run Six scenarios (goal-draft, capability-scope, steward-group-trigger, conversation-input, automation-cadence, steward-model-settings) already fail on a clean main checkout.
  • Coverage and gaps: the send path, the recovery path's Goal ownership rule and the manager negative case are covered; the recovery path's direct store call is covered by code review and the real run only (no scenario reconnects a Turn that carries proposals).

Frontend / Visual Evidence

  • UI impact: changed
  • Before: the answer 我找到一个可评审的步骤。 with nothing under it.
  • After: a 新任务 · 待你确认 card under the answer; opening it shows the existing typed preview with 确认并应用 / 稍后 / 拒绝.
  • States and viewports shown: desktop 1440×900 candidate and applied states (screenshots available on request; not attachable from the CLI).
  • Source data: synthetic
  • Attention review: the card is the step the answer refers to, uses the existing proposal row, and does not open the drawer unasked.

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: applied — the legacy proposal-card path was the second, unrendered owner of Agent Todo proposals; removing it leaves the typed action store as the single owner. Considered having the Chat service create the previews itself on proposal.ready; deferred because it would change a service contract that also serves Lark and CLI clients.

The Chat agent prompt asks for Todo proposals and the service emits them in
turn.completed, but the Personal Workspace wrote them into a legacy
proposalsByContext state that nothing rendered since loopx-project#3274. The reply said
it found a reviewable step while no step could be seen or confirmed.

In a Goal conversation each Todo proposal now becomes a typed todo.create
preview (goal, text and priority) keyed by its Turn, so a repeated
completion reuses the stored preview. The cards appear in the conversation
without opening the drawer, survive a reload, and still require explicit
confirmation. A recovered Turn stores its previews for the Session's own
Goal only, instead of falling back to the selected or first Goal. Manager
answers without a target Goal create nothing.

Remove the unused legacy card state, its preview/approve/settle handlers
and state labels.

Signed-off-by: song <liusongstep@gmail.com>
The browser fixture now forwards scripted proposals in turn.completed, as
the Chat service does. A new chat-todo-proposal scenario requires one
typed preview per Goal proposal that survives a reload and applies on
confirmation, and no preview for a manager answer without a target Goal.

Signed-off-by: song <liusongstep@gmail.com>
…todo-proposals

Signed-off-by: song <22676124+songoow@users.noreply.github.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 - exact head f9a5a34; recovered Goal Chat Turn persists a Todo preview but leaves no confirmation card until a second reload. Normal browser scenario and TypeScript check pass; remote CI was not consulted.

Reviewed exact head: f9a5a3444d2bbff8bd9b6b71d45888c2670eca3d.

动机

这个 PR 针对真实的用户断点:Agent 明明给出了 Todo 建议,旧 Dashboard 却把它放在不显示的临时状态里,用户无法确认。正确的结果应是建议成为当前 Goal 下的非写入预览卡,用户看懂、选择、确认后才产生 Todo;Manager 无目标 Goal 时不能擅自创建。正常回合的改动实现了这条主路径,但“连接中断或刷新后恢复回合”也是同一用户旅程,不能把卡片留到第二次刷新才出现。

改动思路

作者没有新增一套 Todo 权限体系,而是把 Agent 的 typed kind=todo 提案映射成既有 todo.create ActionPreview,使用 Turn+索引构造幂等 key。普通 Goal Chat 走 PersonalWorkspacePage 的 createPreview:既存储预览,也更新本页 proposals 列表;应用动作仍由 owner 明确确认。Manager 的无 Goal 分支保持不写。但恢复路径在 Dashboard 外层直接调用同一 API 存储,绕过页面本地状态;页面初次 listTypedActions 已执行后没有订阅或刷新,所以后端与眼前卡片分叉。

具体改动

完整 diff 为 7 文件、+120/-164:Dashboard 删除旧的不可见 proposal stash,加入 Goal/Turn 上下文映射和恢复处理;PersonalWorkspacePage 接收普通预览请求并显示已有 typed-action 卡;模型及本地化补齐字段;fixture 和浏览器场景覆盖 Manager 不写、正常 Goal 预览、刷新后的持久读回与确认应用。变更净删旧逻辑,范围适中;下面的恢复漏接是两条现有入口未合流,并非代码量问题。

关键代码讲解

  1. todoProposalPreviewRequests 只在已知 Goal 下把 Todo 提案转成 typed request,并用 Turn id 保持幂等。这个映射避免 Manager 回答越权变成 Goal Todo。
  2. 恢复回合的 preview 调用 在恢复的 SSE 完成后逐个 previewTypedAction。它确实把预览写进 store,但没有通知当前 PersonalWorkspacePage;catch 也只吞掉错误,用户并不知道卡片未进入页面。
  3. listTypedActions / createPreview 展示了两种已存在的同步机制:挂载/Goal 切换时读取 store,以及普通发送时创建后立即设置本地 proposals。恢复恰好发生在初次读取之后,却不走后者,因此当前 Chat 不显示卡片。

对主干的风险

[P2] 我在 exact-head Dashboard 的仓库浏览器 fixture 中,先在 Goal Chat 发出会产生 Todo 的回合、趁回合活跃时刷新,再等恢复 SSE 完成。结果是 store 有 1 条该 todo.create 预览、页面对应确认卡为 0;再刷新一次后,仍是 1 条预览,卡才变成 1。这里不是远端 CI 状态、卡片 CSS 或模型随机性:恢复分支的直接存储与页面 state 更新确有分叉。最小修复是复用普通 createPreview 的状态更新,或在恢复成功后触发一次有界的 listTypedActions 刷新;加入“活跃 Turn 刷新 → 恢复结束 → 未二次刷新即显示/确认”的浏览器负例,保留 Manager 不写与重复恢复不重复应用的断言。

正常 chat-todo-proposal 浏览器场景通过,Dashboard tsc --noEmit 通过,git diff --check 通过;这些证明普通路径,不覆盖恢复时序。浏览器探针使用仓库原有 HTTP/SSE fixture 和真实页面,而非仅 mock 卡片后置条件;没有触碰真实 Goal。未查询远端 CI,任何与本 PR 无关的红灯都不作为 REQUEST_CHANGES 理由。

语义与 CI 对齐

现有 typed preview 契约把 store 作为事实源、页面卡片作为可操作投影,实际 Todo 写入仍要 owner 明确确认。恢复后 store 与页面不一致违反的是这个已有投影关系,不需要新协议或额外授权。修复后请用浏览器时序回归和当前的正常场景双向验证。

我的整体评价

正常路径的用户体验显著改进,Manager 权限与显式确认也保持正确;但长期连续性在重连路径尚未完成,Agent 答案与可操作卡片脱节,用户被迫再刷新一次。此次变更净删除旧 stash、复用 typed action,是适当的现有 owner;相关 future-facing pass 应让正常/恢复两条入口共用页面预览同步机制,而不是再建一套状态。基于可复现的当前页反例,请求修改;修复并在同一 exact head 重跑普通与恢复场景后再审,不把未查询的 CI 当作缺陷。

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

REQUEST_CHANGES — exact head f9a5a3444d2bbff8bd9b6b71d45888c2670eca3d 的主路径方向正确,但我在真实 dashboard 浏览器入口复现了两个会让 Todo proposal 继续不可见或被静默丢失的阻断问题。

动机

这个 PR 要修复的是一个真实且高频的用户缺口:Chat Agent 已在 turn.completed 中返回 Todo proposal,但 Personal Workspace 仍把它写入迁移后无人读取的 proposalsByContext,所以回答说“找到一个步骤”时,用户没有任何可见、可确认、可恢复的任务卡片。把它迁入既有 typed action preview/store 是正确方向;它能保持显式确认、持久化、幂等与统一回执,也删除了约 160 行重复且不可达的本地状态。

我用同一浏览器探针对 immutable base 和当前 head 比较:普通 Goal 回复在 base 是 0 个 typed preview / 0 张卡片,在 head 是 1 个 typed preview / 1 张卡片;manager channel 没有目标 Goal 时保持零写入,卡片不自动打开 drawer,reload 后仍在并可确认 apply。这证明主路径有明确正向收益。但“每个 Goal-conversation Todo proposal 都可见且可恢复”的完整目标尚未成立。

改动思路

todoProposalPreviewRequests 把经过 isTodoProposal 判定的候选映射为 todo.create,使用 Session/目标 Goal 的 goal_id、原始 text/priority,并用 chat-todo-proposal:<turnId>:<index> 做稳定幂等键。普通发送通过 PersonalWorkspacePage.sendMessage 接收 request 数组,再由 createPreview(request, { select: false }) 写入 typed store、更新当前 proposal state,但不抢占 drawer;单个 protected-action preview 仍走原来的立即打开逻辑。

这个 owner 选择是合适的:ChatActionStore 已负责 request digest、幂等复用、preview lifecycle 和持久读回,PersonalWorkspacePage 已负责 proposal timeline 与 drawer,没必要保留第二套 previewTodo/applyTodo 状态机。manager channel 也没有擅自猜测 Goal。问题出在两个调用分支没有完整复用这条 owner 链路:recovery 只写 store 不更新 live projection;普通 send 用“单个或数组”的返回类型,在 protected preview 提前 return 时截断了 Todos。

具体改动

关键代码讲解

todoProposalPreviewRequests(dashboard-page.tsx:153)是新的纯映射边界,正确复用了 AgentResponse["proposals"]、isTodoProposal 和现有 WorkspaceActionPreviewRequest,没有引入字符串状态分类。它把 proposal 限定为 Goal-scoped、preview-only 的 todo.create,并让同一完成事件重放时由 store 返回同一 preview。

sendManagerQuestion(约 dashboard-page.tsx:1971)完成 Chat turn、更新 transcript,再决定返回哪些 semantic previews。Todo-only 分支会在目标 Goal 存在时返回数组;manager 分支只显示引导且不创建写入。可是 protected-action 分支在 dashboard-page.tsx:2149-2155 先返回单个 preview,因此同一 schema-valid response 中的 Todo 数组永远到不了下一分支。隔离浏览器实测为 protected preview 1、Todo preview 0。当前 AgentResponse schema 与 normalization 都允许这两个字段共存,没有 typed exclusivity rule 可以证明该输入非法。

PersonalWorkspacePage.createPreview(personal-workspace-page.tsx:1109)是正确的 live-state owner:成功后同时更新 sessionProposalIds 与 proposals,select:false 只阻止自动打开 drawer。sendMessage(personal-workspace-page.tsx:1562)新增 Promise.allSettled,允许多个 Todo 独立创建并在部分失败时显示新增的中英文 feedback;这个局部失败语义合理,已有 protected 单请求行为也保留。

恢复 effect(dashboard-page.tsx:1715-1724)正确移除了 selected Goal / 第一个 Goal 的危险 fallback,只接受 active Session 自己且仍存在于 model 的 goal_id。但它直接 void previewTypedAction(...),绕过 createPreview。我的 active-turn recovery 探针观测到 store 已有 1 条、当前 DOM 有 0 张卡片,reload 后才显示 1 张;因此注释所说的“restored with the Goal's other pending previews”只在之后重新执行 list 时成立。

测试侧新增 chat-todo-proposal 场景,fixture 也开始把 scripted proposals 放进 turn.completed,这是有价值且耐久的 browser boundary。它覆盖普通 Goal、manager 负向、Goal/priority/idempotency、不开 drawer、reload 和 apply;但它没有让 recovery 的 active turn 携带 proposal,也没有构造 protected-action 与 Todo 共存,所以现有测试与 chat-recovery 都会通过而漏掉上述两处缺陷。

对主干的风险

[P1] 恢复完成后的卡片不会即时出现。触发条件是页面加载时发现 active Goal turn,并在初始 listTypedActions 读完后收到含 Todo 的 completion。previewTypedAction 虽写入成功,却没有任何 setProposals / setSessionProposalIds,用户看到“恢复完成”的回答但没有可确认卡片;只有手动 reload/切换后才由 list 恢复。最低修复是让 recovery 通过能同时持久化和更新 live state 的现有 preview owner,或在写入回执后显式 merge 当前 state,并加入无需 reload 的浏览器断言。

[P1] 合法的 combined response 会静默丢 Todo。触发条件是 Goal response 同时包含匹配用户语义的 protected_action 和 Todo proposals;当前先 return protectedPreview,所以 Todos 从未发送到 typed store。隔离浏览器实测 protected=1、Todo=0。最低修复是先聚合两类 request 再交给同一创建路径,同时保留 protected preview 的选中行为,并用组合场景断言两类都存在。

独立验证方面,dashboard tsc/production + chat bundle build、personal workspace contract smoke、chat-todo-proposal、typed-actions、team-plan、chat-recovery、steward-journey 均通过;loopx canary premerge --from-git-diff 选择的 8 项也全部通过,含 diff hygiene、maintainability、semantic vocabulary、Todo contracts 和 public/private boundary。候选文件未发现私有路径、凭据或内部上下文。

GitHub 当前四个 Python shard、聚合 pytest 与 merge gate 为红。我对 immutable base 738115bde87eef3fd153abe456d53da5e2b249f8 与当前 head 跑了同一 targeted selection,均为 6 failed / 4 passed,失败测试、断言位置和返回形态一致,集中在 quota plan、global gate source 与 scheduler ack,和本 PR 七个 UI/fixture 文件无因果路径。因此红 CI 是 pre-existing unrelated;它仍会阻止 merge readiness,但不是本次 REQUEST_CHANGES 的依据。

语义与 CI 对齐

本 PR 复用现有 todo proposal、todo.create、Goal context 与 typed preview lifecycle,没有新增 shared vocabulary,也没有把 machine obligation 描述成 guidance。typed state 与 domain-neutrality 没有问题;阻断点是 schema 已允许的组合语义没有被 UI 完整消费,以及持久状态与 live projection 不一致。

我的整体评价

这是一个方向明确、净删除为主、架构归属正确的修复:长期上收敛到 typed action owner 是正向的,普通用户路径也确实从不可见变为可确认。但严格按完整 diff 和恢复/组合路径评估,当前 head 仍会在两种真实状态下隐藏或丢失 proposal,用户体验结论为 regression,long-horizon 结论为 not yet proven,因此不能批准。

建议保留现有 typed-store consolidation,不要恢复 legacy card;只需把 callback 统一成可组合的 preview list(或等价聚合),并让 recovery 成功写入后同步当前 proposal state。补上 recovery-immediate 与 protected+Todo 两个 browser regression 后,再重跑当前主场景、相邻 typed-actions/chat-recovery、build 与 premerge,我会基于新 exact head 复审。未来相关 refactor pass 已在本次范围内定位:去掉单值/数组组合歧义并统一 live projection owner,比新增抽象更合适。

English verdict: REQUEST_CHANGES - exact head f9a5a3444d2bbff8bd9b6b71d45888c2670eca3d correctly moves ordinary Chat Todo proposals to the typed preview store and removes dead duplicate state, but two browser-reproduced blockers remain: recovered proposals are persisted without appearing until reload, and a schema-valid response containing both a protected action and Todo proposals creates only the protected preview. Please compose both preview classes, update live proposal state on recovery, and add regressions for those paths. The unrelated six quota/scheduler failures reproduce identically on base and head.

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

REQUEST_CHANGES — exact head f9a5a3444d2bbff8bd9b6b71d45888c2670eca3d 的主路径方向正确,但我在真实 dashboard 浏览器入口复现了两个会让 Todo proposal 继续不可见或被静默丢失的阻断问题。

动机

这个 PR 要修复的是一个真实且高频的用户缺口:Chat Agent 已在 turn.completed 中返回 Todo proposal,但 Personal Workspace 仍把它写入迁移后无人读取的 proposalsByContext,所以回答说“找到一个步骤”时,用户没有任何可见、可确认、可恢复的任务卡片。把它迁入既有 typed action preview/store 是正确方向;它能保持显式确认、持久化、幂等与统一回执,也删除了约 160 行重复且不可达的本地状态。

我用同一浏览器探针对 immutable base 和当前 head 比较:普通 Goal 回复在 base 是 0 个 typed preview / 0 张卡片,在 head 是 1 个 typed preview / 1 张卡片;manager channel 没有目标 Goal 时保持零写入,卡片不自动打开 drawer,reload 后仍在并可确认 apply。这证明主路径有明确正向收益。但“每个 Goal-conversation Todo proposal 都可见且可恢复”的完整目标尚未成立。

改动思路

todoProposalPreviewRequests 把经过 isTodoProposal 判定的候选映射为 todo.create,使用 Session/目标 Goal 的 goal_id、原始 text/priority,并用 chat-todo-proposal:<turnId>:<index> 做稳定幂等键。普通发送通过 PersonalWorkspacePage.sendMessage 接收 request 数组,再由 createPreview(request, { select: false }) 写入 typed store、更新当前 proposal state,但不抢占 drawer;单个 protected-action preview 仍走原来的立即打开逻辑。

这个 owner 选择是合适的:ChatActionStore 已负责 request digest、幂等复用、preview lifecycle 和持久读回,PersonalWorkspacePage 已负责 proposal timeline 与 drawer,没必要保留第二套 previewTodo/applyTodo 状态机。manager channel 也没有擅自猜测 Goal。问题出在两个调用分支没有完整复用这条 owner 链路:recovery 只写 store 不更新 live projection;普通 send 用“单个或数组”的返回类型,在 protected preview 提前 return 时截断了 Todos。

具体改动

关键代码讲解

todoProposalPreviewRequests(dashboard-page.tsx:153)是新的纯映射边界,正确复用了 AgentResponse["proposals"]、isTodoProposal 和现有 WorkspaceActionPreviewRequest,没有引入字符串状态分类。它把 proposal 限定为 Goal-scoped、preview-only 的 todo.create,并让同一完成事件重放时由 store 返回同一 preview。

sendManagerQuestion(约 dashboard-page.tsx:1971)完成 Chat turn、更新 transcript,再决定返回哪些 semantic previews。Todo-only 分支会在目标 Goal 存在时返回数组;manager 分支只显示引导且不创建写入。可是 protected-action 分支在 dashboard-page.tsx:2149-2155 先返回单个 preview,因此同一 schema-valid response 中的 Todo 数组永远到不了下一分支。隔离浏览器实测为 protected preview 1、Todo preview 0。当前 AgentResponse schema 与 normalization 都允许这两个字段共存,没有 typed exclusivity rule 可以证明该输入非法。

PersonalWorkspacePage.createPreview(personal-workspace-page.tsx:1109)是正确的 live-state owner:成功后同时更新 sessionProposalIds 与 proposals,select:false 只阻止自动打开 drawer。sendMessage(personal-workspace-page.tsx:1562)新增 Promise.allSettled,允许多个 Todo 独立创建并在部分失败时显示新增的中英文 feedback;这个局部失败语义合理,已有 protected 单请求行为也保留。

恢复 effect(dashboard-page.tsx:1715-1724)正确移除了 selected Goal / 第一个 Goal 的危险 fallback,只接受 active Session 自己且仍存在于 model 的 goal_id。但它直接 void previewTypedAction(...),绕过 createPreview。我的 active-turn recovery 探针观测到 store 已有 1 条、当前 DOM 有 0 张卡片,reload 后才显示 1 张;因此注释所说的“restored with the Goal's other pending previews”只在之后重新执行 list 时成立。

测试侧新增 chat-todo-proposal 场景,fixture 也开始把 scripted proposals 放进 turn.completed,这是有价值且耐久的 browser boundary。它覆盖普通 Goal、manager 负向、Goal/priority/idempotency、不开 drawer、reload 和 apply;但它没有让 recovery 的 active turn 携带 proposal,也没有构造 protected-action 与 Todo 共存,所以现有测试与 chat-recovery 都会通过而漏掉上述两处缺陷。

对主干的风险

[P1] 恢复完成后的卡片不会即时出现。触发条件是页面加载时发现 active Goal turn,并在初始 listTypedActions 读完后收到含 Todo 的 completion。previewTypedAction 虽写入成功,却没有任何 setProposals / setSessionProposalIds,用户看到“恢复完成”的回答但没有可确认卡片;只有手动 reload/切换后才由 list 恢复。最低修复是让 recovery 通过能同时持久化和更新 live state 的现有 preview owner,或在写入回执后显式 merge 当前 state,并加入无需 reload 的浏览器断言。

[P1] 合法的 combined response 会静默丢 Todo。触发条件是 Goal response 同时包含匹配用户语义的 protected_action 和 Todo proposals;当前先 return protectedPreview,所以 Todos 从未发送到 typed store。隔离浏览器实测 protected=1、Todo=0。最低修复是先聚合两类 request 再交给同一创建路径,同时保留 protected preview 的选中行为,并用组合场景断言两类都存在。

独立验证方面,dashboard tsc/production + chat bundle build、personal workspace contract smoke、chat-todo-proposal、typed-actions、team-plan、chat-recovery、steward-journey 均通过;loopx canary premerge --from-git-diff 选择的 8 项也全部通过,含 diff hygiene、maintainability、semantic vocabulary、Todo contracts 和 public/private boundary。候选文件未发现私有路径、凭据或内部上下文。

GitHub 当前四个 Python shard、聚合 pytest 与 merge gate 为红。我对 immutable base 738115bde87eef3fd153abe456d53da5e2b249f8 与当前 head 跑了同一 targeted selection,均为 6 failed / 4 passed,失败测试、断言位置和返回形态一致,集中在 quota plan、global gate source 与 scheduler ack,和本 PR 七个 UI/fixture 文件无因果路径。因此红 CI 是 pre-existing unrelated;它仍会阻止 merge readiness,但不是本次 REQUEST_CHANGES 的依据。

语义与 CI 对齐

本 PR 复用现有 todo proposal、todo.create、Goal context 与 typed preview lifecycle,没有新增 shared vocabulary,也没有把 machine obligation 描述成 guidance。typed state 与 domain-neutrality 没有问题;阻断点是 schema 已允许的组合语义没有被 UI 完整消费,以及持久状态与 live projection 不一致。

我的整体评价

这是一个方向明确、净删除为主、架构归属正确的修复:长期上收敛到 typed action owner 是正向的,普通用户路径也确实从不可见变为可确认。但严格按完整 diff 和恢复/组合路径评估,当前 head 仍会在两种真实状态下隐藏或丢失 proposal,用户体验结论为 regression,long-horizon 结论为 not yet proven,因此不能批准。

建议保留现有 typed-store consolidation,不要恢复 legacy card;只需把 callback 统一成可组合的 preview list(或等价聚合),并让 recovery 成功写入后同步当前 proposal state。补上 recovery-immediate 与 protected+Todo 两个 browser regression 后,再重跑当前主场景、相邻 typed-actions/chat-recovery、build 与 premerge,我会基于新 exact head 复审。未来相关 refactor pass 已在本次范围内定位:去掉单值/数组组合歧义并统一 live projection owner,比新增抽象更合适。

English verdict: REQUEST_CHANGES - exact head f9a5a3444d2bbff8bd9b6b71d45888c2670eca3d correctly moves ordinary Chat Todo proposals to the typed preview store and removes dead duplicate state, but two browser-reproduced blockers remain: recovered proposals are persisted without appearing until reload, and a schema-valid response containing both a protected action and Todo proposals creates only the protected preview. Please compose both preview classes, update live proposal state on recovery, and add regressions for those paths. The unrelated six quota/scheduler failures reproduce identically on base and head.

A Turn recovered after a reload stored its Todo previews directly, but
the page reads the typed-action store only on mount or Goal change, so
the confirmation card appeared only after a second reload. The recovery
now bumps a revision the page includes in that store read, so the card
appears as soon as the recovery stores it. A failed draft adds a line to
the answer instead of being dropped silently.

The browser fixture completes a resumed Turn with its scripted answer.
The chat-todo-proposal scenario reloads into a running Turn, requires
the recovered card without another reload, confirms it, and checks a
further reload applies nothing again.

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 f9a5a34 的 P2,新 head 6e696dbe9。

修复:store 仍然是唯一事实源,没有新增第二套状态。恢复路径写完预览后,会递增 typedActionsRevision。PersonalWorkspacePage 已有的 listTypedActions effect 把它加进依赖,所以恢复写入后页面会立刻重新读取一次 store,卡片随之出现,不需要再刷新。预览写入失败时,会在这条回答下追加 feedback.proposalDraftFailed,不再静默吞掉。Manager 无 Goal 时不写入的分支保持不变。

验证:

  • chat-todo-proposal 新增:在 Turn 运行中刷新 → 恢复完成 → 不再刷新就出现恢复出的卡片,且只对应一个 todo.create 预览(Goal 正确)→ 确认并应用 → 再刷新一次不会重复应用。
  • 原有的 Manager 不写入、普通 Goal 预览、刷新后读回和确认断言保持不变。
  • fixture 让恢复的 events 也返回该 Turn 的脚本化回答(包括 proposals)。以前恢复路径总是返回固定文本,所以这条时序没法测。
  • 用上一个 head 的源码跑,会在等待恢复卡片出现处超时。
  • 全部 23 个 personal-workspace 浏览器场景、personal-workspace-contract.test.mjs 和 npm run build(无 bundle 漂移)均通过。

huangruiteng
huangruiteng previously approved these changes Sep 29, 2026

@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: APPROVE - exact head 6e696db; the recovered-Turn card regression reported on f9a5a34 is repaired. The old/new browser probe, repository recovery scenario, Dashboard typecheck and diff hygiene pass; remote CI was not consulted.

Reviewed exact head: 6e696dbe965308803fab5d841f9f6f256db438da. This is a new whole-PR review, not an inherited conclusion from f9a5a3444d2bbff8bd9b6b71d45888c2670eca3d.

动机

这个 PR 修复了 Goal Chat 中真实的用户断点:Agent 提出 Todo 后,旧页面把建议放进不可操作的临时状态,用户无法确认。可用的终点不是“后端有预览”,而是普通和重连恢复回合都能在当前 Goal 对话中看见待确认卡,用户明确确认后才写入 Todo。上一提交的恢复路径仍要求第二次刷新;本提交针对同一目标补上了该断点,没有扩大到其他 Goal 或自动写入。

改动思路

现有 typed todo.create ActionPreview 仍是唯一持久事实源。todoProposalPreviewRequests 仅把有目标 Goal 的 typed Todo 提案映射成带 Turn 幂等键的预览;Manager 没有目标 Goal 时跳过。普通回合沿用页面的预览创建和卡片更新;恢复回合先存储,再在至少一个写入成功时递增 typedActionsRevision,让 PersonalWorkspacePage 重新读取既有 store。失败时对话给出草稿生成失败反馈;真正应用始终由 owner 确认。

具体改动

完整 PR 为 7 文件、+176/-167:删除 Dashboard 旧的不可见 proposal stash,复用 Goal/Turn 到 typed preview 的映射、既有卡片和确认对话框,并调整模型及本地化。最后一轮 4 文件修复只处理恢复路径的 store→页面投影同步和相关浏览器 fixture。恢复后的 Promise.allSettled 处理部分写入成功/失败,成功触发一次目标页刷新;浏览器场景新增“活跃 Turn 刷新→恢复→不二次刷新即出现卡片→确认应用→再刷新不重复应用”的完整链路。相关 future-facing 简化已在现有状态 owner 内完成,没有另建持久提案表。

关键代码讲解

  1. todoProposalPreviewRequests 用精确 kind=todo、目标 Goal 与 Turn id 生成 typed 请求及幂等键,Manager 回答不会越权变成某个 Goal 的待办。
  2. 恢复回合的预览与刷新 收到恢复 SSE 的提案后,调用 previewTypedAction;有成功写入才递增 revision,失败则在当前回答显示重试提示,而非静默吞错。
  3. PersonalWorkspacePage 读回 在选中 Goal 或 revision 改变时从现有 typed-action store 读取卡片。显示态是投影,不拥有第二套 Todo 意图;确认仍走原有显式应用路径。
  4. 恢复浏览器用例 将回复延迟到刷新恢复之后,断言卡片未二次刷新就可见、预览幂等且 Todo 只应用一次。

对主干的风险

最强反例是页面已完成初次 listTypedActions,而活跃 Turn 随后恢复并写入预览。我在旧提交 f9a5a344 上用独立仓库浏览器探针复现“预览 1、当前卡片 0,二次刷新后卡片 1”;在本 exact head 上以同样时序得到“二次刷新前预览 1、卡片 1”。仓库 chat-todo-proposal 浏览器场景通过,覆盖普通 Goal、Manager 无 Goal、恢复卡片、显式确认和刷新后不重复应用;Dashboard tsc --noEmit 与 git diff --check 也通过。没有查询远端 CI;本次结论不把无关的红 CI 当作缺陷,也不宣称已跑全仓库测试或活模型。

剩余边界是浏览器测试使用仓库 HTTP/SSE fixture,而非真实在线模型;但它执行了实际页面、恢复时序和 typed preview/apply 交互。预览失败的反馈路径在代码中可见,未另做网络故障注入;若后续 UI 文案或 store 错误处理改变,应保留其回归覆盖。此 PR 只变更前端入口和浏览器 fixture,后台 typed-action 合约不变,故没有新的 CLI/Lark companion 操作要迁移。

语义与 CI 对齐

todo.create 预览是建议,不是自动应用的义务。Goal 绑定、Turn 幂等键、owner 确认仍由既有 typed-action 契约承担;新 revision 只是投影失效信号,不新增权限或持久状态。这里没有宣称 default-off:Goal Chat 的默认行为有意从不可见建议变为可确认卡片,PR 描述与具名浏览器场景都揭示了这个变化。

我的整体评价

当前提交完成了本 PR 的有界用户结果:普通和恢复回合都能把 Agent 的 Todo 建议交给用户审阅、确认,并保持 Manager 隔离和重复加载安全。修复留在原有 store/页面边界内,7 文件的范围与交互链路相称;没有发现仍需阻断的代码问题,因此 APPROVE。合并就绪、远端 CI 和真实模型资格仍由维护者另行判断,本评审不授予自合并或部署权。

An answer may carry a protected action and Todo proposals together; the
schema allows both. The send result was either one preview or a list, so
the protected preview returned first and the Todos never reached the
typed store.

onSendMessage now returns a typed WorkspaceSendPreviews with an optional
decision and a list of candidates. Candidates become cards; the decision is
created last so it keeps the drawer selection.

Signed-off-by: song <liusongstep@gmail.com>
chat-todo-proposal now answers the fixture's protected merge message with a
Todo proposal as well and requires both typed previews, the protected
decision in the drawer, and the Todo card one step behind it.

Signed-off-by: song <liusongstep@gmail.com>
@songoow

songoow commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed cocolord's second [P1] (combined response drops Todos) at the new head; the first [P1] (recovered card needs a reload) was addressed in 6e696dbe9.

  • onSendMessage now returns a typed WorkspaceSendPreviews = { decision?, candidates? } instead of "one request or a list", so one answer can hand back both. sendManagerQuestion builds the protected decision and the Todo candidates independently and returns them together; sendMessage creates the candidates as cards and the decision last, so the protected preview keeps the drawer selection as before.
  • chat-todo-proposal now answers the fixture's 请合并 PR #123 message with a Todo proposal too and requires: the goal.update protected preview, exactly one todo.create preview for the Todo, the drawer on the protected decision, and the Todo card available one step behind it (the existing newest-draft-leads rule folds it into the backlog).

Validation: scenario 2/2; failing-before check with the source change reverted fails with "The combined answer dropped its Todo proposal"; typed-actions (protected merge path), team-plan, newest-draft, steward-journey, chat-recovery pass; tsc --noEmit passes.

The contract smoke still matched the removed single-preview call, so it
failed once onSendMessage returned WorkspaceSendPreviews. It now pins
the same invariant on the new shape: the decision enters createPreview,
and candidates enter it too without taking the drawer.

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

补充一个提交 3a611b3:43aa139 把 onSendMessage 改成返回 WorkspaceSendPreviews 之后,personal-workspace-contract.test.mjs:119 还在按源码匹配旧的 if (semanticPreview) await createPreview(semanticPreview),本地会失败,CI 的 build/chat-bundle 任务也会变红。

现在按新形状固定同一个不变量:decision 进入 createPreview;candidates 也进入 createPreview,且使用 { select: false },不会占用抽屉。只改了这一条测试,没有改生产代码。

在 3a611b3 上:contract test、chat-todo-proposal 场景(包含恢复后的卡片,以及受保护操作和 Todo 同时出现的情况)、tsc 和 npm run build 均通过。

@mergify

mergify Bot commented Sep 29, 2026

Copy link
Copy Markdown

This pull request has merge conflicts with main and cannot be merged
until they are resolved. Please rebase or merge the base branch, @songoow.

Choose the remote for the base repository, not an out-of-date fork.
For a fork clone, first inspect git remote -v; upstream must point
to https://github.com/loopx-project/loopx.git. If it is absent, add it
with git remote add upstream https://github.com/loopx-project/loopx.git.
Then run:

git fetch upstream
git rebase upstream/main
# Resolve each conflict, git add the resolved files, then git rebase --continue.
git push --force-with-lease origin HEAD

For a same-repository clone whose origin points to
https://github.com/loopx-project/loopx.git, use origin instead of
upstream for fetch/rebase. If you prefer merging the base, use
git merge <base-remote>/main and push normally.

Keep the DCO Signed-off-by trailer on every commit when you rebase.
https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase Mergify: the pull request has merge conflicts with its base branch label Sep 29, 2026

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

REQUEST_CHANGES — exact head 3a611b3e3e174660021a15adc52046f51e316c9f 已修复上一轮的 recovery 即时显示与 protected-action/Todo 组合丢失,但新增的 Goal Todo 卡会串入 Manager Chat。这个 channel-isolation 回归已在真实 Dashboard 页面和仓库 HTTP fixture 中稳定复现。

动机

这个 PR 要解决的是一个明确的用户断点:Agent 已在 Goal Chat 的完成响应中提出 Todo,但旧页面把它放进无人读取的 legacy state,用户看不到、无法确认,也不能依靠 typed-action store 在刷新后恢复。目标应是每个 Goal 的建议只出现在所属 Goal 对话中,作为待确认预览;Manager 没有目标 Goal 时不得创建,更不能显示其他 Goal 的候选。

当前 head 相比 base 有真实收益:普通回合、刷新恢复回合以及同时包含 protected action 与 Todo 的回合,都能创建持久 typed preview;恢复完成后无需二次刷新,protected decision 仍占据 drawer,Todo 留在 backlog。可是 Goal 候选创建后切换到 Manager Chat 时,同一张卡仍会显示,因此完整的会话归属目标尚未达到。

改动思路

todoProposalPreviewRequests 将 typed kind=todo 提案映射到现有 todo.create ActionPreview,固化 goal_id、text、priority,并以 Turn id 与数组序号构造幂等键。普通发送用新的 WorkspaceSendPreviews 同时携带 candidates 与 decision:候选先创建且不抢 drawer,受保护 decision 最后创建并成为当前选择。恢复路径仍直接写 typed-action store,再通过 typedActionsRevision 让页面重新读取。

这个 authority 方向是正确的:Chat 响应只产生待确认 proposal,typed-action store 继续负责持久化与幂等,真正 Todo 写入仍需 owner 点击确认。Manager 无目标 Goal 的响应不会创建 Todo。问题位于展示投影:createPreview 将所有本页新建 preview 都加入全局 sessionProposalIds,而 managerChatItems 又把这个集合中的任意 proposal 当作 Manager 会话项目,没有按来源 context 或 goalId 限定。

具体改动

完整 PR 为 8 个文件、+207/-177。personal-workspace-model.ts 把单一 preview 返回值改成 WorkspaceSendPreviews;personal-workspace-page.tsx 增加 candidate/decision 的顺序创建、部分失败提示和 typedActionsRevision 触发的 store 读回;dashboard-page.tsx 删除约 160 行 legacy proposal card 状态与处理函数,统一构造 typed Todo requests,并补齐恢复和组合响应;i18n 与 contract smoke 同步新反馈和 callback shape;browser runner、fixture 与 chat-todo-proposal 场景覆盖普通、Manager 无目标、恢复、应用及组合响应。

关键代码讲解

  • todoProposalPreviewRequests 使用精确的 proposal discriminant,只把 kind=todo 转成 todo.create,并把目标绑定到发起 Turn 的 Goal;chat-todo-proposal:<turnId>:<index> 使同一 completion 重放复用后端 preview。
  • sendManagerQuestion 在完成 transcript 后独立构造 decision 与 candidates,从而关闭上一轮 protected action 提前 return 导致 Todo 丢失的问题。Manager 没有目标 Goal 时仅保留提示,不创建 durable preview。
  • recovery effect 从 Session 自己的 goal_id 生成 requests,用 Promise.allSettled 保留成功项,并在至少一次成功后递增 typedActionsRevision;页面随后从唯一 typed store 重读,失败项则在回答上留下可见提示。
  • PersonalWorkspacePage.sendMessage 先以 select:false 创建 candidates,再创建 decision,确保候选存在而 protected decision 保持 drawer 焦点;createPreview 同时更新 proposals 与 sessionProposalIds。
  • managerChatItems 当前将 sessionProposalIds 中的 proposal 无条件并入 Manager transcript。由于该集合也记录 Goal Chat 中由 createPreview 创建的候选,切换 channel 后会发生跨上下文泄漏。

对主干的风险

[P1] Goal-scoped Todo proposal 会出现在 Manager Chat。触发方式是进入 Product Release 的 Goal Chat,让 Agent 返回一个 Todo proposal,等待卡片出现,然后点击 LoopX Manager 并打开 Manager Chat。exact-head 浏览器探针得到 goalCount=1、managerCount=1、后端 actionPreviewCount=1:数据的 goal_id 没写错,但同一 Goal-only 卡被错误投影进了 Manager transcript。根因是 createPreview 在 personal-workspace-page.tsx:1154 将 Goal candidate 加入页面级 sessionProposalIds,而 managerChatItems 在 personal-workspace-page.tsx:1000-1004 只按该 id 集合过滤,没有记录或核对 preview 的来源 channel。

最低修复是在 session-created preview 的可见性中保留 context identity,例如按 manager/Goal 分组记录,或让 Manager Chat 仅接收明确属于 manager channel 的 proposal;同时保留当前 Goal timeline 的 goalId 过滤。请加入回归:Goal Chat 创建 candidate 后切到 Manager Chat,Manager 中必须为 0;切回原 Goal 仍为 1,另一 Goal 也为 0。这个修复不需要改变 typed-action store、幂等键或权限模型。

独立验证方面,源码态与 packaged chat-todo-proposal Chrome 场景通过;相邻 typed-actions、chat-recovery、team-plan、newest-draft 通过;Personal Workspace contract、Dashboard TypeScript/production/chat bundle build 通过;loopx canary premerge --from-git-diff 的 8/8 风险选择检查和 public/private boundary 通过。上述既有场景没有执行“Goal candidate → Manager Chat”切换,所以会在当前缺陷存在时保持全绿;新增探针走同一生产页面、fetch 与 fixture,只控制 Agent 响应。

GitHub checks 由 gh 读取。当前已完成的 DCO、build、desktop、chat bundle、TypeScript、Dashboard acceptance、Stage 2C 等检查通过;Python test-shard (1) 与 (3) 为红。失败集中在 quota scheduler acknowledgement 与 project-registry census,PR 的 8 个文件不触及这些 owner;同一聚焦命令在该 workflow 的 main parent ee1ea64b0aef45fdda81d2d7e48da356a1750eab 复现 4 个失败,在 PR exact head 复现相同的 2 个 quota 失败,GitHub synthesized merge b00da4e90997df84d17bdca42b3df45012169573 的日志也显示相同失败族。它们不构成本次代码 blocker 的依据,但 required CI 未全绿且 PR 当前 CONFLICTING/DIRTY,merge readiness 仍须保持阻塞。

语义与 CI 对齐

本 PR 复用既有 todo、todo.create、Goal context 与 typed preview lifecycle,没有 substring denylist、领域特化的控制面文案或把强制义务称为 guidance。WorkspaceSendPreviews 清楚区分候选与即时 decision,availability 不等于授权,Todo 仍需 owner 确认。当前 blocker 是现有 typed Goal identity 在 UI channel 投影中被一个无 context 的 id 集合绕过,而不是后端权限扩大。

我的整体评价

REQUEST_CHANGES。当前 exact head 已实质修复前两轮指出的恢复和组合响应问题,净删除 legacy 状态、复用 typed-action owner、正负路径与构建证据也都说明方向和规模是合理的。但一个 Goal 的待确认操作出现在 Manager Chat,会让用户误判建议来源和作用域;这是主用户旅程上的可见语义回归,不能用其他 happy-path 通过覆盖。

future-facing pass 应保持有界:无需新增框架,只需让页面的“本会话新建 proposal”集合携带并执行现有 context/Goal 归属,随后补三向 channel-isolation browser regression。修复后请重跑主场景、相邻浏览器场景、build 与 premerge。此 review 只评价代码,不修改作者分支,也不授予 merge authority。

English verdict: REQUEST_CHANGES - exact head 3a611b3e3e174660021a15adc52046f51e316c9f. The prior recovery and protected-action-plus-Todo blockers are fixed, and source/packaged Chrome, adjacent browser scenarios, the workspace contract, Dashboard builds, and risk-based premerge pass. However, a newly created Goal-only Todo card leaks into Manager Chat: the real-page probe observes one card in the Goal and the same one in Manager because sessionProposalIds is page-global and managerChatItems does not enforce context ownership. Scope session previews by manager/Goal and add a Goal-to-Manager-to-other-Goal isolation regression. Unrelated current-main quota/registry failures and the conflicting PR state separately keep merge readiness blocked.

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

Reviewed exact head: 3a611b3. This is a new whole-PR review after the earlier reviews on f9a5a34 and 6e696db; neither old verdict was inherited.

动机

Goal Chat 的 Agent 已经在完成事件里给出 Todo 建议,旧 Personal Workspace 却把它放在迁移后无人读取的本地列表。用户看见“找到步骤”却没有可确认的步骤,刷新恢复或同时出现受保护操作时还会漏卡。验收点不只是写入预览,而是建议只在所属 Goal 的对话中成为持久但尚未执行的卡片,用户明确确认后才写入;Manager 没有目标 Goal 时既不创建,也不显示其他 Goal 的候选。当前 head 已解决普通、恢复及组合回答,但我复现了 Goal 卡串入 Manager Chat,完整用户结果尚未成立。

改动思路

本 PR 复用既有 typed ActionPreview/store 作为唯一事实源,不新建 Todo 写入权限。Dashboard 只把精确 kind=todo 的 AgentResponse 提案映射为 goal-scoped todo.create,Turn 与索引提供重放幂等键;PersonalWorkspacePage 负责展示和确认。新的 WorkspaceSendPreviews 将“当前需打开抽屉的 decision”和“留在对话中的 candidates”显式分开,使同一回答能同时携带两者。恢复路径无法返回 composer,于是在持久预览写入后递增 revision,通知页面从同一 store 读回。Manager 的无 Goal 分支确实保持零写入,真正 apply 仍在 owner 确认之后;但页面级 sessionProposalIds 没保存创建卡的 channel 归属,Manager Chat 的展示过滤又把这些 id 都算作本会话候选,持久数据的 Goal 绑定没传到可见投影。

具体改动

完整 diff 为八个前端/fixture 文件,+207/-177;主要是删除旧 proposal stash/处理器并补同一 owner 下的组合与恢复路径,没有新增后端状态、CLI 或 Lark 行为。i18n 为生成草稿失败补中英文反馈;浏览器 fixture 现在传递完成事件的 proposals、在恢复时重放脚本化回答;注册的 chat-todo-proposal 场景覆盖 Manager 自己的回答不创建 Todo、普通 Goal、刷新恢复、确认应用与受保护操作+Todo 共存。contract test 也更新为新 callback 形状,不再错误地匹配旧源码文本。现有场景却没有在 Goal 创建候选后切回 Manager Chat,因而漏了跨 channel 展示回归。

关键代码讲解

  1. todoProposalPreviewRequests 是 Goal/Turn 到 typed request 的唯一映射;没有目标 Goal 就不构造 Todo 预览,同一 Turn 重播会复用幂等键。
  2. 恢复写入与 revision 只接受 Session 自身且仍存在的 Goal;至少一条预览成功才触发页面读回,失败在回答中给提示,避免“后端已有、眼前没有”。
  3. 组合结果的构造 独立生成 protected decision 与 Todo candidates,不再因先返回受保护操作而静默丢掉任务。
  4. PersonalWorkspacePage 创建顺序 先以 select:false 存 candidates,再创建 decision;因此任务卡留在 backlog、抽屉仍聚焦用户刚请求的受保护操作。
  5. managerChatItems 用页面级 sessionProposalIds 认定 Manager 会话候选;createPreview 在该页创建 Goal Todo 时也把 id 加入同一集合,因此跨 channel 复用页面时会错误显示该卡。

对主干的风险

[P1] Goal Todo 卡会出现在 Manager Chat。独立地在同一真实 Dashboard 页面/仓库 HTTP fixture 中,我先进入 Product Release Goal Chat,让 Agent 返回一个 Todo 并等卡可见,再点击 LoopX Manager、打开 Manager Chat。临时加入的隔离断言在当前 exact head 失败于 “Goal Todo proposal leaked into Manager Chat”。后端只创建了 Goal 绑定的预览,错误发生在 UI 投影:createPreview 将 Goal 卡加入无 context 的 sessionProposalIds,managerChatItems 又据此把它显示到 Manager transcript。用户会误判建议来源及作用域;这是可见语义回归,不是一个无关 CI 红灯。最低修复是让本会话创建的预览 id 携带或核对 manager/Goal 归属,Manager 只展示明确属于 Manager channel 的卡;加上 Goal → Manager → 原 Goal/另一 Goal 的三向隔离浏览器回归。无需改 typed store、幂等键或确认权限。

先前两个阻断则确实已修复:活跃 Turn 刷新后卡片在恢复完成时出现;合法回答同时含 protected_action 与 Todo 时两类预览都存在。我暂时把组合返回改回“decision 优先”的旧行为,同一页面场景精准失败于 “The combined answer dropped its Todo proposal”,恢复源码后通过。原仓库 chat-todo-proposal、typed-actions、team-plan、newest-draft、contract test、TypeScript 与生产/Chat bundle build 均通过,git diff --check 无问题。风险套件用项目 Python 3.13 和根目录 npm 依赖重跑后八项全通过,含 public/private scan;最初用系统 Python 3.9 时语义扫描在固定 base/head 同样报 zip(strict=...) 兼容错误,属于工具环境。未查询远端 CI,也不因无关红灯请求修改。

另一个边界是浏览器使用合成 Chat HTTP/SSE fixture,未在在线模型上测多 Todo 部分失败;部分失败已有反馈,但真实服务故障注入尚未做。当前 PR 不新增 schema 或后端权限:Todo 建议仍是建议而非自动执行的 obligation,默认 Goal Chat 从“不可见”有意变为“可确认”,已由 PR 描述与具名场景公开。没有前端之外的受影响入口。八文件范围与原问题相称;future-facing 简化是删除旧双轨状态,当前需补的是同一页面投影的 channel identity,而非新框架。

我的整体评价

REQUEST_CHANGES。当前 head 实质解决了不可见、恢复和组合丢弃,typed store 归属与代码规模也合理;但 Goal 候选串入 Manager Chat,使用户在错误上下文看到可确认操作,主用户旅程尚不安全。请只在现有 PersonalWorkspacePage 投影里补 channel/Goal 隔离,并把三向切换加入 chat-todo-proposal 回归;修复后重跑该场景、相邻 typed-actions/newest-draft、build 和风险套件。此结论基于 exact head 的独立浏览器反例,不是沿用他人的意见,也不授予合并权限。

English verdict: REQUEST_CHANGES - exact head 3a611b3 fixes recovery and combined protected-action/Todo handling, but an independently reproduced Goal Todo card leaks into Manager Chat through page-global sessionProposalIds. Scope visible cards to their channel/Goal and add a three-way navigation regression. The existing browser/build/premerge suite passes but misses this case; remote CI was not consulted.

Manager Chat showed every preview this page created, so a Todo proposal
offered in a Goal conversation also appeared in Manager Chat. Record
which previews were created from the Manager channel and let only those
(and the Manager-scoped store readback) join the Manager transcript.

The chat-todo-proposal browser scenario now checks the card stays out of
Manager Chat and another Goal, returns with its Goal, and keeps its Goal
when the answer lands after the owner left for Manager Chat.

Signed-off-by: song <liusongstep@gmail.com>
Resolve two conflicts: keep upstream's new browser scenarios in the
catalog alongside chat-todo-proposal, and keep upstream's abortable
prepareGoalConversation signature while dropping the legacy
updatePersonalProposal helper this branch already retired with the
local proposal card state.

Signed-off-by: song <liusongstep@gmail.com>
Signed-off-by: song <liusongstep@gmail.com>
@songoow

songoow commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review request — exact head 8c695b352edc048bb6b98c28ee22ca4501053201

Thanks @cocolord and @huangruiteng. Both blocking reviews on 3a611b3e3 reported the same P1: a Goal-scoped Todo card shown in Manager Chat. Reproduced on the exact head first, fixed, then verified.

Blocking finding → fix

Finding (latest reviews on 3a611b3e3) Status Change Evidence
[P1] Goal Todo proposal leaks into Manager Chat: createPreview put every page-created card into the page-global sessionProposalIds (personal-workspace-page.tsx:1154) and managerChatItems (:1000-1004) treated that set as Manager candidates fixed (b81ca4f81) Session previews now record the channel that created them: rememberSessionProposal(previewId, channelGoalId) keeps a Manager-channel list (managerSessionProposalIds) beside sessionProposalIds; managerChatItems reads only the Manager list plus the Manager-scoped store readback. Goal-timeline filtering by goalId is unchanged; typed store, idempotency keys and confirmation authority are untouched chat-todo-proposal scenario asserts 0 cards in Manager Chat after a Goal candidate exists, 0 in another Goal, 1 back in its own Goal
Requested Goal → Manager → other-Goal isolation regression added (b81ca4f81) Three-way navigation assertions plus a fourth case: the answer lands after the owner left the Goal for Manager Chat and still belongs to the Goal that asked same scenario, chat-todo-proposal
Recovery / combined protected-action+Todo regressions from the earlier rounds still green on this head unchanged by this fix chat-recovery, typed-actions, team-plan, newest-draft, full 26-scenario suite

Mutation check (revert each fix → its test fails → restore)

  • managerChatItems back to sessionProposalIds (the exact pre-fix filter) → scenario fails with A Goal Todo proposal leaked into Manager Chat.
  • The isolation block, run against the unmodified pre-fix head, fails with the same message; that failing run is the signed-off reproduction referred to in both reviews.
  • tsc --noEmit: clean.

Conflict resolution (merge commit, no rebase / no force-push)

git merge --signoff upstream/main (main moved during the session; final merge base 3ec049e13). Two conflicts:

  • examples/personal-workspace-browser-smoke.mjs: kept upstream's conversation-startup, lark-cli-missing, execution-service-offline scenarios and chat-todo-proposal.
  • apps/presentation/dashboard/src/views/dashboard-page.tsx: kept upstream's new abortable prepareGoalConversation(goalId, agentId, signal?) and dropped only the legacy updatePersonalProposal helper that this branch had already retired together with the local proposal-card state (no remaining references; tsc confirms).
    Otherwise upstream's auto-merge of personal-workspace-page.tsx (composer sizing, message activity, follow-conversation) coexists with the channel-ownership change.

Validation on 8c695b352

  • npm run smoke:personal-workspace (development, 26 scenarios) — pass; npm run smoke:personal-workspace-packaged (chat bundle, 26 scenarios) — pass; npm run build:chat — pass.
  • python -m pytest tests -k "chat_action or proposal or typed_action" — 104 passed.
  • loopx canary premerge --from-git-diff --git-diff-base upstream/main — 8/8 selected checks, 0 failures, public/private boundary pass, gate passed.
  • Confirmed origin/codex/workspace-chat-todo-proposals was still 3a611b3e3 before pushing; push was a fast-forward.

Not covered

  • No live-model run: the browser scenarios drive the repo HTTP/SSE fixture, so multi-Todo partial failure on a real service is still untested.
  • Remote CI was not consulted; the unrelated quota-scheduler/project-registry failures reported earlier are not addressed here.
  • No first-screen change: the fix only changes which items Manager Chat's transcript receives.

Please re-review on 8c695b352.

中文摘要

两位在 3a611b3e3 上的阻断项是同一个 P1:Goal 的待确认 Todo 卡会串进 Manager Chat。根因是页面级 sessionProposalIds 不分来源,managerChatItems 又把它当作 Manager 候选。现在 createPreview 会记录创建该卡的 channel(Manager 单独一个集合),Manager Chat 只显示 Manager 渠道创建的卡和 Manager 作用域的 store 读回;typed store、幂等键、确认权限未改。chat-todo-proposal 场景新增 Goal → Manager → 另一 Goal 三向隔离断言,以及“答案在离开 Goal 后才到达”的用例;反向改动可让该场景失败。已合并 upstream/main(合并提交,未 rebase/未强推),解决两处冲突。开发态与打包态 26 个浏览器场景、build:chat、相关 Python 测试与 premerge 全部通过。请在新 head 8c695b352 上复审。

@mergify mergify Bot removed the needs-rebase Mergify: the pull request has merge conflicts with its base branch label Sep 30, 2026

@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 — 仍有一个可复现的 P1:恢复中的回合在离开原 Goal 后完成,其 Todo 建议没有持久化为预览;返回原 Goal、再次重载也无法找回。此前的管家串卡问题在当前头已修复,不再作为阻塞项。

Exact head: 5266@8c695b352edc048bb6b98c28ee22ca4501053201;比较基线:3ec049e138917a8cce4f84197ba196d26445b2b0。按 LoopX PR-review capability policy 12 执行,检查完整差异、现有实现和当前反馈,不只检查最后一个修复提交。

动机

原问题是 Agent 已经返回 Todo 草稿,Personal Workspace 却把它放进不再渲染的旧状态,用户看见回答而无法确认下一步。当前实现让普通 Goal 回答产生原 Goal 的可确认卡片,确认前零 Todo 写入,确认后可读回回执。这是有价值的改进;但“刷新恢复 → 临时离开 → 后台完成 → 回到原 Goal”的同一用户旅程仍会丢失草稿,因此还不能认为本 PR 的建议可恢复性已经交付完整。长期推进会因下一步丢失而需要重复提问;用户体验也仍依赖把恢复页面留在前台。

改动思路

方向正确:复用既有 typed-action 预览、确认、持久化和回执,不让模型输出直接成为 canonical Todo,也不再维护一套旧的候选状态机。普通发送在原 Goal 上捕获归属,再把 Todo 候选与 protected action 分开返回;管家只展示管家通道的卡片。Turn 派生幂等键是派生投影身份,不是新权限。需要进一步把恢复结果投影的生命周期与当前视图解耦:取消浏览器恢复流可以停止展示订阅,但不应让已完成回合的草稿永远失去进入现有预览存储的机会。

具体改动

完整差异为 8 文件、+264/-181。主要删除旧 PersonalProposalCard、proposalsByContext 和旧 preview/apply 状态处理;增加普通发送的多候选返回、恢复后 typed-action 读回刷新、错误反馈和浏览器回归场景。没有新建权限、后端 Todo 写入协议或模型调用入口。

关键代码讲解

  • todoProposalPreviewRequests 只映射真正的 Todo proposal,使用原 Goal 和 chat-todo-proposal:<turn>:<index>;重复观察同一完成事件应复用存储预览,而不是重复写 Todo。
  • PersonalGoalHome 的恢复投影 使用 Session 自身的 Goal,避免回退到当前选中 Goal。但它位于 cancelled 返回之后,只在仍在订阅的恢复流完成时调用 previewTypedAction,这正是下面反例的缺口。
  • rememberSessionProposal / createPreview 区分所有 Session 卡片与管家来源卡片;异步普通回答切走后不会借用新 Goal,也不再混入管家。
  • sendMessage 逐个创建 Todo 预览,并独立处理决策卡;这修复了同一回答带 protected action 时 Todo 被提前返回吞掉的问题。

对主干的风险

[P1] 完成的恢复回合必须能重新投影其原 Goal 草稿。 位置是上述新增恢复映射的 L1726–1736。复现顺序:在 Goal 发起带 Todo proposal 的慢回合,刷新使它进入 active-turn 恢复;完成前切到管家;后台完成后回到原 Goal,再重载。当前包装构建显示完整回答,但没有候选卡片。完成事件仍含合法 Todo proposal,Session 已 ready 且 active_turn_id=null;native typed-action 存储没有该 Turn 的预览。不是服务拒绝:按同一 Turn/索引/原 Goal 手动调用 /api/actions/preview 得到 HTTP 201、preview_ready,重载后卡片立即出现。

原因:视图切换清理会 cancelled=true 并中断恢复订阅;完成处理在新映射之前返回。再次进入时历史只恢复文字,而 !activeTurnId 提前返回,既不会重放已完成回合的草稿,也无法从 typed-action list 找到根本没创建的预览。建议在现有 owner 中增加完成回合的幂等投影/重入恢复,不引入自动 apply、选中 Goal 回退或新的授权状态。补充回归应覆盖离开到管家及另一个 Goal、后台完成、重入和再重载:原 Goal 恰好一张预览、其他通道零泄漏、确认前零 Todo 写入,重复读回不增加预览。

验证已独立执行:workspace contract、dashboard TypeScript、104 项 proposal/action Python 测试、实际 Chat bundle build/install、标准 premerge(3 direct + 8 selected)、开发与包装浏览器套件各 26 场景均通过;native typed-action smoke 在基线和头均通过。另用包装 UI 与真正的 typed-action HTTP/Todo 存储核验了预览、确认回执 projection_verified=true、拒绝零写入、重载不重复 apply,以及上述失败场景。Chat/SSE 和 status 使用合成 fixture,没有调用付费模型;不能把 fixture 套件通过说成所有实际产品旅程都已验证。

语义与 CI 对齐

复用现有 typed-action/Todo 语义,没有扩大 actor 生命周期或确认权限;卡片是未信任建议,不是已接受工作。当前缺口是完成事件到可恢复预览的交付语义,不是 CI 失败。未查询、等待或轮询远程 CI。修复后请扩展 examples/personal-workspace-browser/chat-todo-proposal.mjs,重跑 node examples/personal-workspace-browser-smoke.mjs(开发及包装构建)、uv run --extra test python examples/loopx-chat-actions-smoke.py 和差异对应的 premerge。

我的整体评价

普通路径的前后对照成立:同一合法合成建议在基线只有回答、零 typed preview,头产生可确认预览;确认及拒绝继续由既有后端 owner 负责。当前长期推进与用户体验判断是“改善但仍有恢复缺口”,不是 Goal 完成。删除旧 UI 状态机是恰当的有界未来向重构;保留仍有实际调用者的旧 data API,不为本修复扩大协议清理。无需新增框架或后续任务,最小修复应留在本 PR 的恢复投影边界内。当前头要求修改;新头修复该反例并完成原 Goal/管家隔离和幂等读回后再评审。未执行合并。

English verdict: REQUEST_CHANGES — A completed recovered Turn loses its Todo preview when the owner leaves the Goal before completion. Re-entry restores only text. Preserve idempotent projection for the originating session without widening write authority; the earlier manager-card leak is fixed.

…todo-proposals

Signed-off-by: song <liusongstep@gmail.com>

# Conflicts:
#	examples/personal-workspace-browser-smoke.mjs
…its Goal

A Turn recovered after a reload exists because the owner reloaded into it. Its
recovery stream is owned by the mounted conversation, so switching to Manager
Chat — or to another Goal — aborts that stream. The completion event then
returned at the cancellation guard, before the Todo projection, and the drafts
the Turn had already produced were never persisted as previews. Re-entering the
Goal restored the answer text from history but no card, and because
`active_turn_id` is null by then nothing replayed it: the owner had to ask
again to get the step back.

The projection now outlives the view that started it, which is what the
cancellation flag actually governs — the display subscription, not the owner's
claim on drafts their Turn already produced:

- `pendingRecoveryTurns` records the Goals whose recovered Turn is still
  running, keyed by `sessionId:turnId`. It survives the teardown of the view
  that created it.
- The completion path projects through `projectRecoveredTurnProposals` before
  the guard, so a Turn that finished while its Goal was unmounted still reaches
  the typed-action store. The Turn-derived key makes a repeat a no-op.
- Returning to the owning Goal replays any still-pending entry by re-reading the
  stored completion, so the card appears on re-entry rather than on a manual
  reload. An interrupted or failed Turn drops its claim instead of replaying.

Only the Session's own Goal may own a recovered draft; a card never follows its
owner into Manager Chat or another Goal, and nothing is auto-applied.

Coverage extends `chat-todo-proposal` with the reviewer's sequence: reload into
a running Turn, leave for Manager Chat before it completes, return to the Goal,
then leave and reload again. It asserts the preview exists only after the owner
returns to its Goal, that re-entry and reload map to exactly one preview, and
that no apply happened without confirmation.

Signed-off-by: song <liusongstep@gmail.com>
@songoow

songoow commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review request — exact head 9207e6967

Thanks for the exact sequence. I reproduced it on the reviewed head first, fixed the owning boundary, then re-ran it in the browser scenario.

[P1] A completed recovered Turn lost its Todo preview when the owner left the Goal — addressed

Your diagnosis was precise: the projection sat after the cancellation guard, so a view switch aborted the recovery stream and the completion handler returned before previewTypedAction ever ran. Re-entering restored only text, and because active_turn_id is null by then, !activeTurnId returned early — nothing replayed it and no preview existed to find.

Fix. The projection is decoupled from the mounted view, which is what the cancellation flag actually governs — the display subscription, not the owner's claim on drafts their Turn already produced:

  • pendingRecoveryTurns records the Goals whose recovered Turn is still running, keyed by sessionId:turnId. It survives the teardown of the view that created it.
  • The completion path projects through projectRecoveredTurnProposals before the guard, so a Turn that finished while its Goal was unmounted still reaches the typed-action store. The Turn-derived key makes a repeat a no-op.
  • Returning to the owning Goal replays any still-pending entry by re-reading the stored completion, so the card appears on re-entry instead of on a manual reload. An interrupted or failed Turn drops its claim rather than replaying forever.

Only the Session's own Goal may own a recovered draft. No auto-apply was added, the selected Goal is not used as a fallback, and no new authority state was introduced — this stays inside the existing typed-action preview owner.

Evidence

examples/personal-workspace-browser/chat-todo-proposal.mjs extends the same scenario with your sequence over the same fixture (a resumed 刷新恢复 Turn answers after 5s, which is the window the owner leaves in):

  1. send the Turn, reload into it so recovery owns it, leave for Manager Chat before it completes;
  2. while the Goal is unmounted: zero previews for that draft, and no card in Manager Chat;
  3. return to the owning Goal: exactly one preview, and the card is visible without another reload;
  4. leave and reload again: still exactly one preview for that Turn, and no apply happened without confirmation.

Scenario passes in the development smoke. The earlier manager-card leak stays covered by the existing assertions, so both directions are pinned.

Verification on the merged head: tsc --noEmit clean; personal-workspace-contract.test.mjs passes; chat-todo-proposal browser scenario passes (development). Latest origin/main merged with sign-off.

English verdict request: the completed recovered Turn is projected idempotently for its originating Session, with no widening of write authority and no view-dependent delivery. Please re-review 9207e6967.

…todo-proposals

Signed-off-by: song <liusongstep@gmail.com>

# Conflicts:
#	apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx
#	apps/presentation/dashboard/src/views/dashboard-page.tsx
#	examples/personal-workspace-browser-smoke.mjs
@mergify

mergify Bot commented Sep 30, 2026

Copy link
Copy Markdown

This pull request has merge conflicts with main and cannot be merged
until they are resolved. Please rebase or merge the base branch, @songoow.

Choose the remote for the base repository, not an out-of-date fork.
For a fork clone, first inspect git remote -v; upstream must point
to https://github.com/loopx-project/loopx.git. If it is absent, add it
with git remote add upstream https://github.com/loopx-project/loopx.git.
Then run:

git fetch upstream
git rebase upstream/main
# Resolve each conflict, git add the resolved files, then git rebase --continue.
git push --force-with-lease origin HEAD

For a same-repository clone whose origin points to
https://github.com/loopx-project/loopx.git, use origin instead of
upstream for fetch/rebase. If you prefer merging the base, use
git merge <base-remote>/main and push normally.

Keep the DCO Signed-off-by trailer on every commit when you rebase.
https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase Mergify: the pull request has merge conflicts with its base branch label Sep 30, 2026
@songoow

songoow commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Merged latest origin/main with sign-off; new head 5a53e4afe (no force-push). Three conflicts resolved by keeping both sides:

  • i18n.tsx — union of this PR's feedback.proposalDraftFailed and main's history.* messages, en + zh.
  • dashboard-page.tsx — kept main's useConversationHistory wiring; proposalsByContext/contextProposals are not carried over, since this PR removes that old proposal state machine and main has no remaining reader for it.
  • personal-workspace-browser-smoke.mjs — union of this PR's chatTodoProposalScenario and main's conversationHistoryRecoveryScenario.

Re-verified after the merge: tsc --noEmit clean, personal-workspace-contract.test.mjs passes, and the chat-todo-proposal browser scenario (including the recovery-after-departure case) passes in the development smoke.

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

needs-rebase Mergify: the pull request has merge conflicts with its base branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants