feat(chat): share scoped coordination between steward and Goal conversations - #4696
Conversation
…ions Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
…bilities Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
…mmar Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
动机
现状:只有 manager 与 manager.external.* 两个频道能拿到带范围的证据和收件人目录(is_manager_channel 分散在 chat_runtime._run_turn、chat_manager_context.manager_turn_context、manager_context.authority)。本地 Goal 对话既没有读取器,也没有交办目录,更没有“回到原对话”的返回路径,所以项目层面的纠偏只能绕回全局管家。
本 PR 的目标(#4574 的 R2/R3 应用边界,文档落在 docs/reference/project-coordination.md 与 roadmap/RFC 双语镜像):让 owner 在某个 Goal 的对话里只读取该 Goal 的证据,把一次修订交给该 Goal 的一个确切注册成员,并在原对话里收到成员结论;同时不继承管家的组合视图、模型配置或主机授权。
这不是“把项目对话变成注册 coordinator”:不冒名、不 attach 原会话、不因投递启动 worker、不改 Todo/lease/quota/验收/受保护操作权限。判为 goal_achieved,因为可观察结果在 exact head 上被独立复现:范围证据、精确成员交办、重启后仍只返回一次,且 registry 与 Goal state 字节不变。
改动思路
把“这是哪一类对话”从散落的频道名判断,收敛成一个类型化判定,然后复用既有 owner 完成读/交办/回传:
- 判定 owner 是 TS 控制面:
loopx/control_plane/collaboration/conversation_scope.ts:12的resolveConversationScope只用宿主保存的channel_id/goal_id(加 turn 的origin)产出owner_portfolio | owner_goal | external_audience | unavailable;effect_runtime_handlers.ts注册为collaboration.conversation.scope,Python 侧只做适配(loopx/control_plane/collaboration/__init__.py:10)。 - 复用既有 owner:
loopx/chat_coordination.py:34把原先内联在_run_turn里的“管家准备”抽成prepare_turn_context,管家与项目对话共用它;证据读取、收件箱、adopt/return、回执仍归capabilities/manager_context,不新增第二份账本或会话。 - 边界按对话类型选择:
loopx/chat_runtime.py:1126-1133先用“会话+turn origin”判定,unavailable时清空上一 turn 的读取器并跳过证据;owner_goal时把证据限制到该 Goal(build_goal_portfolio(goal_ids=[goal])),只声明 local 证据源,并拒绝source_id != local。 - 交办与回传沿用原路径:
manager_context.authority把私有对话的目标按goal_ids过滤(None表示管家全量),peers.request用同一判定拒绝转发外部受众的父请求,roundtrip.report/drain用private_conversation决定是否需要公共边界扫描与外部传输,本地项目对话因此走本地 transcript 回传。 - 一次性迁移:
coordination_context_version(chat_store.update_session校验为正整数)让旧 Goal 会话在重新打开上游会话时刷新一次,保留可见历史但会新建上游线程,从而使新工具真实存在;健康进程内适配器与其进行中的 Turn 不被替换。
与既有实现的比较:is_manager_channel 是频道名谓词,而现在边界依赖 channel+goal+origin 三元组,要么扩展现有 owner,要么复制一份范围/收件人/证据逻辑;选择扩展,避免第二份真相。
具体改动
- 新增
loopx/chat_coordination.py(129 行):共享的 turn 准备(证据、context_delegation、按 turn 绑定的读取器)与PROJECT_COORDINATION_GUIDANCE。 - 新增
loopx/control_plane/collaboration/conversation_scope.ts(28 行)与其 Python 适配、handler 注册;peers.py改为用同一判定。 loopx/chat_runtime.py:project_coordination贯穿 objective 与 dynamic tools(项目对话用中性的loopx_context_read),_run_turn按 scope 绑定/清空 handler,旧项目会话迁移一次。loopx/chat_manager_context.py:项目范围只读该 Goal 的证据窗口、只声明 local 源、去掉管家的model_defaults/remote_evidence,scope新增owner_goal。loopx/capabilities/manager_context/:authority按 scope 过滤收件人,inspection增加CONTEXT_TOOL_NAME/CONTEXT_READ_TOOL、local-only sources 与source_outside_available_scope,roundtrip.report/drain按private_conversation决定受众处理。- 打包前端与 UI:
dashboard-page.tsx让项目对话也按同一活跃会话路径刷新回传(标签改为“协作回执”),loopx/web/chat/assets/index-CHcAj1Sg.js、asset-retention.json(当前代 + 上一代)、index.html一致重建。 - 文档:新增
docs/reference/project-coordination.md(边界表、复用轴、兼容与限制、后续顺序),并同步双语 roadmap/RFC 与 manager-context README。
关键代码讲解
resolveConversationScope(conversation_scope.ts:12):goal.X只有在goal_id与频道后缀一致、通过安全标识符正则、且不是loopx-manager、origin 缺省或为web时才是owner_goal,其余 fail-closed 成unavailable。这挡住“模型自称负责人”“频道名像 Goal”“外部锚点扩权”。prepare_turn_context(chat_coordination.py:34):把管家准备逻辑变成一段共享代码;external_audience仍走原来的授权范围重绑,owner_goal的读取器只能看到本 Goal。_run_turn的 scope 绑定(chat_runtime.py:1126-1133):判定使用持久化的 turnorigin,unavailable时先清空 handler 再跳过证据——使 Lark 输入无法继承前一个本地 turn 的读取器。此处也是本次唯一发现(F1)的现场。authority(manager_context/__init__.py:84):私有对话只允许goal_ids内的目标(管家为None全量),非external_audience且非私有时直接返回unavailable;项目对话因此永远无法指到别的 Goal。ManagerInspection.sources/read(inspection.py:54起):项目对话只声明 local 源,并拒绝远程source_id,把“不能跨项目取 SSH 证据”做成类型化拒绝而不是提示词约束。
对主干的风险
本地验证(exact head 45480b6b8a2c8405cb4926d186a1e46389687339,base f5c95ff0):tests/test_chat_*.py tests/test_manager_*.py 365 passed;conversation_scope.test.ts + effect_runtime_handlers.test.ts 12 passed;npm run typecheck:control-plane 通过;Ruff 通过;配置化 mypy “Success: no issues found in 22 source files”;examples/loopx-chat-runtime-smoke.py ok。注意:首次直接跑 python 测试时子进程里的 loopx 解析到了主 checkout(旧代码),出现一次假失败;用 PYTHONPATH=<worktree> 绑定后全部通过,这不是本 PR 的缺陷。
CI 状态(写结论时):本 head 的 run 仍在进行——Sign-off、build、adapter-contract、postgresql-authority、Windows/macOS desktop、node-minimum、stage2c 部分 job 成功;test-shard (1–4)、kernel-static-checks、node-forward-compatibility、stage2c(e2e 1, mutants 0)、windows-powershell 仍 IN_PROGRESS。此前两个 shard 失败属于上一个 head 08510f98。因此本结论是 exact-head 的代码结论,合并就绪需要在同一 head 上重读。
最强回归场景与已确认缺陷(P2,非阻塞):
F1 已宣告但未绑定的 loopx_context_read 会把 Lark turn 变成“需要主机确认”的失败。 unavailable 范围(例如 goal.<id> 会话上的 origin=lark)会清空 read_tool_handler,但 dynamicTools 在线程启动时就固定了(chat_runtime.py:421),所以工具仍在模型工具表里;CodexChatAgentSession._check_server_gate 只在有 handler 时回答 item/tool/call,否则抛 CodexChatAgentError("Codex app-server requested host approval") 并带 approval gate(chat_agent.py:677-720)。我在本 head 上用真实 ChatRuntimeController + 真实 CodexChatAgentSession 做了端到端复现(/private/tmp/probe4696/probe.py):web turn completed 且 handler 已绑定;随后的 lark turn 返回 status=failed, error_code=host_gate,没有任何 tool 结果写回,也没有任何 Goal 状态变化。触发面是已发布的 Lark 绑定会话路径(loopx/extensions/lark/goal_topic_runtime.py:868 用 expected_channel = goal.<id> 以 origin="lark" 入队)。本 PR 自己的 lark 测试只断言 read_tool_handler is None,其假上游从不投递 tool call,所以覆盖不到这个结果。
最小修复:在 unavailable 分支绑定一个类型化拒绝 handler(例如 lambda *_: {"ok": False, "error": "conversation_scope_unavailable"}),或干脆不为无法绑定读取器的会话宣告该工具;并补一个“lark turn 触发 tool call → 得到类型化结果且 turn 不失败”的测试。
其余边界:管家路径保持同名工具、goal_ids=None 全量与组合证据;外部受众继续走 sender/resource 授权与公共边界扫描;task.* 与无 scope 的对话行为不变;打包前端、retention 与 index.html 一致;旧 Goal 会话的一次性刷新保留可见历史(文档已披露)。未验证维度:真实 codex app-server 的 Lark tool-call 时序(F1 是用 shipped gate + runtime 级探针证明,而非真实 app-server 会话),以及本 head 的最终 CI。
我的整体评价
这是一次“把散落的频道谓词收敛成类型化对话范围,然后复用既有证据/交办/回传 owner”的正确重构:范围判定放进 TS 控制面,Python 只适配;管家语义保持不变;项目对话拿到的是只读、单 Goal、可审计的交办路径,而不是新的执行权限。负路径(跨 Goal、外部受众、非 web 来源)都被做成类型化拒绝,文档也明确否认了更大的 actor 生命周期。验证覆盖到嵌套 Chat→coordinator→reviewer→coordinator→原对话、重启后只回传一次、旧会话迁移、Lark 隔离与打包前端。
唯一可执行问题是 F1(P2,非阻塞):在 Goal 会话的 Lark turn 上,模型调用仍在工具表里的 loopx_context_read 会让该 turn 以“需要主机确认”的假门失败;修复是一行 handler 绑定加一个负例测试,建议在本 PR 或紧随其后的窄修中完成。合并前请在同一 head 上重读 CI 与合并就绪;合并决定权仍在维护者。
English verdict: APPROVE - 45480b6 satisfies the scoped-conversation goal: one typed TS scope rule (owner_portfolio/owner_goal/external_audience/unavailable) replaces scattered channel predicates, local Goal Chat reuses the existing evidence/inbox/return owners bounded to one Goal, and the steward plus external-audience behavior is unchanged; verified at this head with 365 chat/manager Python tests, 12 control-plane TS tests, control-plane typecheck, Ruff, configured mypy and the chat runtime smoke, plus a nested real-CLI round trip that leaves registry and Goal state bytes identical. One non-blocking P2 remains (F1): on a Lark-origin turn in a Goal session the still-advertised loopx_context_read has no bound handler, so a model tool call fails the turn with a bogus host-approval gate (reproduced end to end; one-line typed-refusal fix recommended); CI for this head was still running, so merge readiness must be re-read on the unchanged head.
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
…plies Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com>
huangruiteng
left a comment
There was a problem hiding this comment.
Approval conclusion (author-owned PR; GitHub blocks formal self-approval)
动机
现状:只有 manager 与 manager.external.* 两个频道能拿到带范围的证据和收件人目录(is_manager_channel 分散在 chat_runtime._run_turn、chat_manager_context.manager_turn_context、manager_context.authority)。本地 Goal 对话既没有读取器,也没有交办目录,更没有“回到原对话”的返回路径,项目层面的纠偏只能绕回全局管家。
目标(#4574 的 R2/R3 应用边界;文档落在 docs/reference/project-coordination.md 与双语 roadmap/RFC):让 owner 在某个 Goal 的对话里只读取该 Goal 的证据,把一次修订交给该 Goal 的一个确切注册成员,并在原对话里收到成员结论;不继承管家的组合视图、模型配置或主机授权;不冒名、不 attach 原会话、不因投递启动 worker、不改 Todo/lease/quota/验收/受保护操作权限。
判为 goal_achieved:范围证据、精确成员交办、重启后只返回一次、以及“查看回复”直接定位最后一条答案,都在本 exact head 上被独立复现,且 registry 与 Goal state 字节不变。本轮我先后提出的两个问题(Lark turn 的未绑定读取器、打包保留清单与干净构建不一致)都已在本 PR 内修复并复核。
改动思路
把“这是哪一类对话”从散落的频道名判断收敛成一个类型化判定,然后复用既有 owner 完成读取/交办/回传:
- 判定 owner 在 TS 控制面:
conversation_scope.ts:12的resolveConversationScope只用宿主保存的channel_id/goal_id(加 turn 的origin)产出owner_portfolio | owner_goal | external_audience | unavailable;effect_runtime_handlers.ts注册为collaboration.conversation.scope,Python 只做适配。 - 复用既有 owner:
chat_coordination.py:34把原先内联在_run_turn的“管家准备”抽成prepare_turn_context,管家与项目对话共用;证据读取、收件箱、adopt/return、回执仍归capabilities/manager_context。 - 边界按对话类型选择:
chat_runtime.py:1126-1145先用“会话 + turn origin”判定;unavailable时不再注入证据,并把线程仍宣告的项目读取工具绑定为类型化拒绝(conversation_scope_unavailable),而不是把它当成 host approval。 - 交办与回传沿用原路径:
manager_context.authority把私有对话的目标按goal_ids过滤(None表示管家全量);peers.request用同一判定拒绝转发外部受众父请求;roundtrip.report/drain用private_conversation决定是否需要公共边界扫描与外部传输。 - 前端一条导航路径:
openGoalConversation(personal-workspace-page.tsx:1506)统一切到 Chat、清掉嵌套执行记录与回执卡,并把最后一条 assistant 消息滚入可视区;任务视图“查看回复”、输入区回执、Goal 视图切换与抽屉导航共用它。
选择扩展现有 owner 而不是新建能力:is_manager_channel 只是频道名谓词,而边界现在依赖 channel+goal+origin 三元组,复制一份范围/收件人/证据逻辑会形成第二份真相。
具体改动
- 新增
loopx/chat_coordination.py(共享 turn 准备 + 项目协作指引)与loopx/control_plane/collaboration/conversation_scope.ts(类型化判定 + Python 适配 + handler 注册)。 loopx/chat_runtime.py:project_coordination贯穿 objective 与 dynamic tools(项目对话用中性的loopx_context_read);_run_turn按范围绑定读取器,unavailable的项目会话返回类型化拒绝;旧 Goal 会话按coordination_context_version迁移一次。loopx/capabilities/manager_context/:authority按范围过滤收件人;inspection增加CONTEXT_TOOL_NAME/CONTEXT_READ_TOOL、local-only 证据源与source_outside_available_scope;roundtrip.report/drain按private_conversation决定受众处理。- 前端:回传行统一标注“协作回执”(任务视图走 i18n 键,聊天时间线沿用该文件既有的中文字面量);
openGoalConversation让四处入口共享同一套“打开并定位最新答案”的导航。 - 打包与文档:
loopx/web/chat/assets/index-DNT2rfje.js(+index.html、asset-retention.json)随前端重建,保留清单按构建插件的字典序提交;新增docs/reference/project-coordination.md并同步双语 roadmap/RFC、manager-context README(含 provider 选择与“查看回复”说明)。 - 测试:
test_chat_project_coordination.py(嵌套 Chat→coordinator→reviewer→coordinator→Chat 真 CLI 往返、Lark 范围拒绝的直接工具断言)、conversation_scope.test.ts、manager_context_handoff/roundtrip、personal-workspace-contract.test.mjs期望刷新、浏览器场景新增“最后一条答案进入可视区”断言。
关键代码讲解
resolveConversationScope(conversation_scope.ts:12):goal.X只有在goal_id与频道后缀一致、通过安全标识符正则、不是loopx-manager、且 origin 缺省或为web时才是owner_goal,其余 fail-closed 为unavailable,挡住“模型自称负责人 / 频道名像 Goal / 外部锚点扩权”。prepare_turn_context(chat_coordination.py:34):管家准备逻辑抽成共享实现;external_audience保留授权范围重绑,owner_goal的读取器只能看到本 Goal。_run_turn的范围绑定(chat_runtime.py:1126-1145):判定使用持久化的 turnorigin;项目会话在unavailable时绑定{"ok": False, "error": "conversation_scope_unavailable"},因此仍被宣告的loopx_context_read得到类型化结果,而不是被_check_server_gate当作 host approval 抛错。authority(manager_context/__init__.py:84):私有对话只允许goal_ids内的目标(管家为None全量),其余非external_audience直接返回unavailable,项目对话无法指到别的 Goal。openGoalConversation(personal-workspace-page.tsx:1506):setSelectedGoalTab("chat")+setActiveSessionRun(null)+ 隐藏回执卡 +requestAnimationFrame内把最后一条.personal-message.is-assistant滚入.personal-channel-scroll,四处入口共用。
对主干的风险
本地验证(exact head 42c86e381a7ad3ed9fab0a369fa68b65e088d2a2,base f5c95ff0):
tests/test_chat_*.py tests/test_manager_*.py365 passed(在紧邻的前一 head;本 head 相对它只改了生成的保留清单,另跑了聚焦的 38 passed)。conversation_scope.test.ts(2)与effect_runtime_handlers.test.ts(10)共 12 passed;npm run typecheck:control-plane通过;Ruff 通过;配置化 mypy “Success: no issues found in 22 source files”。personal-workspace-contract.test.mjs通过;npm run smoke:personal-workspace-packaged通过(navigation-sorting、chat-recovery、typed-actions、team-plan、steward-journey、execution-chip、progressive-loading);examples/loopx-chat-runtime-smoke.pyok。- 打包一致性(本 head 的决定性检查):
cd apps/presentation/dashboard && npm run build:chat后git status --short --untracked-files=all -- loopx/web/chat为空 —— 与frontstage-pages工作流的Verify packaged Personal Workspace is current判定一致。
两个已修复问题的复核证据:
- 上一轮 P2(Lark turn 因未绑定 handler 被当成主机确认):我用真实
ChatRuntimeController+ 真实CodexChatAgentSession的端到端探针,在同一会话里 web turn 正常绑定读取器,随后 lark turn completed,工具调用返回{"ok": false, "error": "conversation_scope_unavailable"}(success=false)。PR 自带的测试改为直接走_check_server_gate,并断言真正的审批请求仍抛CodexChatAgentError。 - 上一轮 P1(提交的
asset-retention.json与干净构建不一致):42c86e381 fix(web): preserve canonical retained asset ordering把保留代按构建插件的字典序提交;我在6cb182f7与8309af8b复现的失败,在本 head 已变为“构建后无 diff”。该差异当时只影响 CI 门禁(保留资产集合本就相同),现在已消除。
边界与未验证项:管家路径保持同名工具、goal_ids=None 全量与组合证据;外部受众继续走 sender/resource 授权与公共边界扫描;task.* 与无范围对话行为不变;旧 Goal 会话一次性刷新保留可见历史(文档已披露)。写结论时该 head 的 CI 仍在进行(Sign-off、dependency-review、macOS/Windows desktop 与 build 已绿,test-shard/kernel/stage2c 等仍在排队),且分支相对 main 是 BEHIND —— 合并前需在同一 head 上重读合并就绪。打包浏览器场景是在本地构建的 serving tree 上跑的,不是 CI runner。
我的整体评价
方向与实现都对:把散落的频道谓词收敛成类型化对话范围,判定留在 TS 控制面,Python 只适配,读/交办/回传全部复用既有 owner;管家语义不变,项目对话拿到的是只读、单 Goal、可审计且有类型化负路径的交办能力,文档也明确否认了更大的 actor 生命周期。前端新增的“查看回复”导航有契约测试 + 真实浏览器断言支撑,打包资产在本地与干净构建逐字一致。
本 exact head 上我没有再发现需要修改的问题:先前两轮提出的两个问题都已被最小修复并独立复核。唯一剩余的合并前动作是流程性的——在同一 head 上重读 CI 与合并就绪(当前分支落后 main),合并决定权仍在维护者。
English verdict: APPROVE - 42c86e3 satisfies the scoped-conversation goal: one typed TS scope rule replaces scattered channel predicates, local Goal Chat reuses the existing evidence/inbox/return owners bounded to one Goal, the steward and external-audience paths are unchanged, and both issues raised during this review sequence are repaired and re-verified (typed conversation_scope_unavailable denial for Lark turns; regenerated packaged asset-retention manifest whose clean-build diff is now empty). Evidence at this head: 365 chat/manager Python tests plus a focused 38-test rerun, 12 control-plane TS tests, control-plane typecheck, Ruff, configured mypy, the personal-workspace contract smoke, the packaged personal-workspace browser smoke (7 scenarios), the chat runtime smoke, and an end-to-end probe showing the Lark turn completes with a typed denial; CI for this head was still running and the branch was BEHIND main, so merge readiness must be re-read on the unchanged head.
Local Goal conversations can now inspect their own work, send an explicit handoff to a registered member, and receive the conclusion in the original conversation. This removes the global steward as a required relay while keeping the steward responsible for portfolio priorities and owner attention.
Validation: 365 Chat/manager Python cases; 12 typed scope/effect-runtime cases; configured Ruff, mypy and TypeScript checks; frontend production build, all seven packaged-workspace scenarios, workspace interaction contract and packaged asset smoke. A repeated Chat build leaves tracked assets unchanged. Identical baseline/head cases show two formerly rejected project paths now passing with the steward path preserved. Packaged-browser checks cover automatic return, reload deduplication, correct reply identity and before/after reply navigation. Additional live-model read and independently handled receiver consultation passed with isolated conversation/inbox state. Required CI is evaluated on the current head.
Boundary: delivery does not launch a worker or complete a Todo. The live receiver was explicitly started once; unattended wakeup, multi-cycle DSH/Ark collaboration and independent research-artifact acceptance are not claimed. Bound sessions still share their previous history. Runtime changes remain for maintainer review and merge.
Relates to #4574 R2/R3 and the semantic-handoff RFC; continues the shared collaboration/acceptance foundation from #4688.