fix(desktop): 统一 device-link 项目的协同入口判定并补齐草稿链路 - #1212
Conversation
device-link 项目的协同入口在新建草稿与已创建会话之间不一致(#1170):草稿被 硬编码关闭协同,发出第一条消息进会话页后开关又出现。根因是两处各写一份 eligible 判据,判据分叉没有任何编译或测试信号。 还有更隐蔽的一层:协同的项目级 collab 开关一律查控制端本机 —— 拿被控端的路径在 自己的 fs 上找 .cindy/plugins.json 必然落空,读到的是控制端自己的用户级开关,与 被控端 main 的 assertCollabProjectEnabled 可能相反,入口据此置灰或放行都可能是错的。 本次按「device-link 支持协同」补齐: - 新增 resolveCollabEntryPolicy,草稿路由与会话视图共用同一份判定,覆盖本地、 SSH 远端、device-link、对话模式与 Orca Worker 五类场景。 - useCollabProjectPolicy 支持 deviceId,经新增的 makerTransport.pluginEnableStateFor 隧道到被控端读它自己的项目级真相;maker:plugins:get-state 登记进 device-link allowlist(只读、无 event.sender 依赖、真相在被控端)。老被控端回 CHANNEL_NOT_ALLOWED 时单独归类为 unsupported,置灰并给专属文案,不挂一个永远 不会成功的重试。 - 解除草稿状态层对 device-link 的硬编码禁用,改为「换目标设备时清掉 Worker 富配置、 保留协同开关」——model / providerId / effort / fast 都是设备作用域的。 - 草稿的发送与新建目标两条 device-link 分支在建会话后隧道 enableOrca 拉起 Worker, 收敛进 remoteCollabHandoff:等 enableOrca(首个 turn 才带得上协同 MCP),但镜像 回流 fire-and-forget(它的退避重试最长约 6.75 秒,挡在交接前面会丢掉用户的首条 消息或目标文案)。CreateWorkerPopover 传 deviceId,模型清单来自被控端。 - 同步修正 main 侧与两个路由里「协同仅本地」的过时注释,以及 Orca 架构文档的边界段。 Signed-off-by: Dash <dashhuang@gmail.com>
|
| Filename | Overview |
|---|---|
| apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx | 将远程协同等待、目标订阅、可恢复交付和发送锁集中到 pending 消费阶段,已覆盖此前线程指出的归属与输入丢失路径。 |
| apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx | device-link 草稿在创建远程会话后持久化交接正文并立即导航,同时携带协同意图供会话视图处理。 |
| apps/desktop/src/renderer/state/pendingFirstMessage.ts | 新增按用户隔离、带 TTL 的可恢复交接副本,并仅在确认交付成功后删除。 |
| apps/desktop/src/renderer/features/cc-agent/remoteCollabHandoff.ts | 统一远程协同初始化、超时终态回查、失败降级及非阻塞镜像刷新。 |
| apps/desktop/src/renderer/lib/makerTransport.ts | 新增远端插件策略查询并统一协同启停等操作的粘滞设备路由。 |
| packages/device-link/src/allowlist.ts | 将只读的项目插件状态查询加入 device-link 远程调用白名单。 |
Sequence Diagram
sequenceDiagram
participant U as 用户
participant D as NewMakerDraftRoute
participant R as 恢复副本
participant V as CCAgentSessionView
participant C as 被控设备
U->>D: 提交首条消息或目标
D->>C: 创建远程会话
D->>R: 保存可恢复副本
D->>V: 登记 pending 并立即导航
V->>C: 订阅 session topic
V->>C: 查询并初始化协同
V->>C: 发送首条消息或设置目标
C-->>V: 返回交付结果与会话事件
V->>R: 确认交付后删除副本
Reviews (11): Last reviewed commit: "fix(desktop): 订阅判据改粘滞、副本贴紧提交点、回查加总时限" | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6b2add9da7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
该 PR 修复 desktop 端 device-link 项目在「新建草稿」与「已创建会话」之间协同入口判定不一致的问题,并补齐 device-link 草稿阶段开启协同的链路:将入口 eligible 判定收敛为单一 helper,同时将项目级 collab 开关查询归属正确路由到被控端(避免控制端误用本机开关导致入口误放行/误置灰)。
Changes:
- 新增
resolveCollabEntryPolicy,让草稿路由与会话视图共用同一套协同入口判定,并明确区分 project / dialogue / worker 子会话等场景。 useCollabProjectPolicy支持deviceId:device-link 场景下通过makerTransport.pluginEnableStateFor隧道到被控端查询项目级开关;并将老被控端CHANNEL_NOT_ALLOWED单独归类为unsupported(非可重试的unavailable)。- device-link 草稿两条创建路径(发送 / 新建目标)在建会话后隧道
enableOrca拉起 Worker(引入remoteCollabHandoff),同时更新 allowlist、i18n 文案、文档与单测覆盖。
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/device-link/src/allowlist.ts | 将 maker:plugins:get-state 加入 device-link invoke allowlist(只读查询)。 |
| docs/dev-rules/orca-team-architecture.md | 更新 Orca 架构文档,补充 device-link 协同入口与策略查询归属说明。 |
| apps/desktop/src/renderer/state/newMakerDraft.ts | 草稿切换目标设备时清理 Worker 富配置但保留协同开关;移除 device-link 草稿硬编码禁用协同。 |
| apps/desktop/src/renderer/lib/makerTransport.ts | 新增 pluginEnableStateFor:本机直连 IPC,device-link 走隧道查询被控端插件状态。 |
| apps/desktop/src/renderer/i18n/locales/zh-CN/common.json | 新增 newChat.collaboration.unsupportedRemoteHint 文案(zh-CN)。 |
| apps/desktop/src/renderer/i18n/locales/en/common.json | 新增 unsupportedRemoteHint(en)。 |
| apps/desktop/src/renderer/i18n/locales/ja/common.json | 新增 unsupportedRemoteHint(ja)。 |
| apps/desktop/src/renderer/i18n/locales/ko/common.json | 新增 unsupportedRemoteHint(ko)。 |
| apps/desktop/src/renderer/features/cc-agent/remoteCollabHandoff.ts | 新增 device-link 远程会话开启协同收尾 helper(await enableOrca + 回流 fire-and-forget)。 |
| apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx | 草稿路由接入统一入口判定;device-link 两条路径补齐远端 enableOrca;CreateWorkerPopover 传入 deviceId。 |
| apps/desktop/src/renderer/features/cc-agent/hooks/useCollabProjectPolicy.ts | 支持 device-link 隧道查询与 unsupported 状态;查询键包含 deviceId + workingDir 防串台。 |
| apps/desktop/src/renderer/features/cc-agent/hooks/tests/useCollabProjectPolicy.test.ts | 覆盖 device-link 隧道路由、不同设备同路径不串台、unsupported/unavailable 分类等。 |
| apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts | 新增协同入口单点判定 helper(五类场景 + 查询归属提示)。 |
| apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx | 会话视图改用 resolveCollabEntryPolicy;项目级查询按 device-link/SSH 场景路由并展示 unsupported 提示。 |
| apps/desktop/src/renderer/tests/orcaWorkflowRoute.test.ts | 更新 legacy /orca 路由的 drift/invariant 断言以匹配新判定结构。 |
| apps/desktop/src/renderer/tests/newMakerProjectPicker.test.ts | 确认回流逻辑不回流到组件内联,新增 helper 仍满足守卫。 |
| apps/desktop/src/renderer/tests/newMakerOrcaCreateOrder.test.ts | 覆盖 device-link 两条草稿路径 enableOrca 时序与不 await 回流等不变量。 |
| apps/desktop/src/renderer/tests/newMakerDraft.test.ts | 覆盖 device-link 草稿协同开启/对话模式仍禁用/换设备清 workerConfig 等 store 行为。 |
| apps/desktop/src/renderer/tests/makerTransportRouting.test.ts | 覆盖 pluginEnableStateFor 本机/远程分流与 skipQuery 透传。 |
| apps/desktop/src/renderer/tests/collabEntryPolicy.test.ts | 新增对 resolveCollabEntryPolicy 五类场景与“两个入口必须共用 helper”的 drift 守卫。 |
| apps/desktop/src/main/maker-ipc/collabProjectPolicy.ts | 补充注释:device-link 在 main 侧无需特判,授权仍以执行端为准。 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
被控端 maker:create-session 返回 sessionId 那一刻就是提交点。之前把远程 enableOrca 放在 setPending / setPendingGoal 之前,而它是一次隧道往返 —— 被控端起 Worker 本就慢,还可能一路走到 invoke 默认 30s 超时。于是「对端会话 已建好、用户的首条消息或目标文案还没被登记」的窗口被从一次本地 rehome 撑到 半分钟;窗口内应用被关掉,用户重开重试就会在对端建出第二个会话,第一个空着 滞留。这与 remoteSessionHandoff 第 33 轮 P1 是同一条不变量。 改为卡在 setPending / setPendingGoal 之后、navigate 之前:首条消息由 CCAgentSessionView mount 后 consumePending 发出,而它要等 navigate,所以 Lead 的第一个 turn 仍然带得上协同 MCP;setPending 只是往内存 Map 放一份 payload,不会提前触发发送。 镜像回流同时挪进 finally:控制端的 invoke 超时不会取消被控端正在跑的 enableOrca,「控制端报失败、对端稍后仍建成 team」是真实终态,失败路径也刷一次 让 orcaRole 尽快回流,由 external-enable 边沿检测把协同 tab 补开。 守卫测试同步改成断言修正后的时序(原断言方向写反了),并新增一条覆盖 finally 回流。 Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 34fc717ede
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts:53
nonEmpty()目前用trim()做了非空判断,但返回的是原始字符串;如果上游传入带前后空格的 deviceId/remoteHostId,会导致 policyDeviceId / skipProjectQuery 的键不稳定(同时与 main 侧普遍的.trim()归一化不一致)。建议直接返回 trim 后的值,保证判定与缓存键稳定。
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
apps/desktop/src/renderer/features/cc-agent/hooks/useCollabProjectPolicy.ts:70
requestedDeviceId用trim()只做了非空判断但未归一化返回值;如果 deviceId 带前后空格,会导致requestKey与实际 IPC 目标不一致(main 侧会deviceId.trim()),从而产生重复查询/缓存分片。建议这里也直接使用 trim 后的 deviceId。
const requestedDeviceId =
eligible && typeof opts?.deviceId === 'string' && opts.deviceId.trim() !== ''
? opts.deviceId
: null;
入口与协同策略查询按粘滞 remoteDeviceId 指向被控端,但两个 mutation 走的是 非粘滞的 makerApiFor(只读 getSessionDeviceId)。relay 瞬时重连清空 remoteProjectsStore 的窗口内,用户看到的入口分明指向被控端,点下去却会退回 本机 enableOrca / disableOrca —— 在控制端建出或销毁一个 team;本机恰好存在 同 id 会话时还会操作错对象。开启后的镜像回流同样用非粘滞判定,该窗口内会解析 成 undefined 直接跳过,被控端刚建的 worker 永远进不了控制端注册表。 归属判定收进传输层:新增 makerApiForSticky(与 isRemoteSessionSticky 同一判据, 一个服务 gating、一个服务调用),requestEnableCollab 与 useStopOrcaCollab 两条 对称路径一并改;回流改取 getStickySessionDeviceId。 send / setModel 这类高频操作仍用 makerApiFor:它们跟随实时判定,误判的代价是 一次失败重试,不是在错误的机器上留下持久状态。 守卫覆盖:makerApiForSticky 在瞬断窗口内仍走隧道、从未解析过归属的本机会话 零回归;enable/disable 两条路径不得退回非粘滞版;归属判定只住在传输层一处。 Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ce53a605bf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/desktop/src/renderer/features/cc-agent/hooks/useCollabProjectPolicy.ts:123
- 这里的注释写“隧道回 CHANNEL_NOT_ALLOWED”,但 renderer 侧通过 extractIpcError 能识别到的实际错误码是 main 端映射后的
DEVICE_LINK_CHANNEL_NOT_ALLOWED(见 apps/desktop/src/main/device-link/ipc.ts 的 DEVICE_LINK_CODE_MAP)。建议把注释改成与实际可观察到的错误码一致,避免后续排障时按错码搜。
// 老被控端没收录 maker:plugins:get-state → 隧道回 CHANNEL_NOT_ALLOWED。这是
// **确定性**的不支持,不是瞬时故障:同 getWorkflowProgressFor 的判别方式,单独
// 分类,让 UI 不去挂一个永远不会成功的重试。
device-link 隧道超时只删掉控制端的等待项,并不取消被控端仍在执行的 enableOrca (ipc.ts 对 INVOKE_TIMEOUT 的既有判定就是「远端仍存活」)。之前的 catch 把 DEVICE_LINK_TIMEOUT 和 PRECONDITION_FAILED / INVALID_PARAMS 这类权威拒绝一视同仁, 吞错后立刻 navigate,于是「被控端起 Worker 慢了几秒」变成「用户明确开了协同, 首轮却以普通单会话跑」—— 而这恰是把 enableOrca 排在 navigate 之前要保住的东西。 改为按错误性质分流:权威失败原样抛出降级;超时先回查被控端 DB 的权威终态, worker 已落库就照成功返回并带上 reveal,查不到才抛原始超时。回查有限次 (3 次 / 间隔 2s)且失败即放弃,fail-closed,绝不把「没建成」猜成「建成了」; 探针用 listWorkersByLead 而非 getByLeadSession —— 非空列表同时证明 team 与首个 Worker 都已提交,还顺带拿到 reveal 需要的 workerSessionId。 镜像回流仍在 finally,因此排在定性之后:成功路径上 enableOrca 返回即代表 DB 已提交,回查路径上已确认 worker 落库,判定为没建成时也仍刷一次,让被控端在回查 窗口之后才提交的情况经 orcaRole 回流 + external-enable 边沿检测自愈。 传输层补 orcaWorkflowsForDevice(与 makerApiForDevice 同款):调用方手里已有权威 deviceId 时不重新解析易失的 session origin。 新增 remoteCollabHandoff 行为测试覆盖成功 / 权威失败不回查 / 超时已建成 / 超时未建成 fail-closed / 回查自身失败 / 回流失败不影响返回值六条路径。退避用 fake timers 快进,避免用例真睡 4s 逼近 vitest 默认超时。 Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 69fe0eb036
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@dashhuang 👋 这个 PR 还有 5 条 review conversation 没 resolve(apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx / apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx / apps/desktop/src/renderer/features/cc-agent/remoteCollabHandoff.ts / apps/desktop/src/renderer/state/newMakerDraft.ts),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
前三轮把「等 enableOrca」放在导航之前的形状本身站不住 —— 它被两侧同时夹击: 等得越久,用户输入在内存 pending Map 里裸奔的窗口越长(隧道往返可能走到 invoke 默认 30s 超时,窗口内应用被关掉就永久丢消息、对端留下空会话);等得不够久, 空的 worker 列表又从来不是仍在执行的 enableOrca 的权威终态。 改结构:草稿路由登记完 setPending / setPendingGoal(载荷带上 remoteCollab)就 立刻 navigate,等待整体挪进 CCAgentSessionView 的 pending 消费 —— 先 await 开 协同、再发首轮 / 起目标。新建页不再卡住,用户输入已经交到会话视图手里,而首轮 仍由同一个 await 串在协同之后。等待不再占着 UI 后,超时回查也放宽到 6 次 / 3s, 但仍有界:被控端可能永远不返回,无界等待会把首轮永久挂起。 同轮修掉三处对称缺口: - 粘滞归属只留一份缓存。视图原本自己记一份 ref,而 makerApiForSticky 读的是模块级 stickySessionOrigin,后者只在被查询时预热 —— 用户在首次开启协同之前撞上 relay 瞬断,那份还是冷的,mutation 就会退回本机、在控制端建出 team。视图改走同一份。 - Worker 类型也按目标设备目录收窄。换设备只清了 workerConfig,collab.worker 仍是 旧值,切到没有该 agent 供应商的设备必撞 NO_PROVIDER_FOR_AGENT;现按 live 目录 换用可用 agent,并丢弃属于旧 agent 的 model / providerId。 - 降级提示说清「这条仍会发出去」。_REMOTE 文案只讲去那台机器修好再重试,用户会 以为没发而重复提交;补一句独立说明,不铺一套 _REMOTE_CONTINUE 文案。 Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/desktop/src/renderer/state/pendingFirstMessage.ts:240
__clearAllForTest()里先把activeDataOwnerId置空再removeItem(RECOVERY_STORAGE_KEY),会导致按 owner 分命名空间写入的${RECOVERY_STORAGE_KEY}:<owner>残留(清理的 key 与实际写入的 key 不一致)。建议在清空 owner 之前先计算当前recoveryStorageKey()并一并删除,避免测试/调试清理不彻底。
goalMap.clear();
activeDataOwnerId = null;
try {
window.localStorage.removeItem(RECOVERY_STORAGE_KEY);
} catch {
apps/desktop/src/renderer/state/pendingFirstMessage.ts:157
- 这里的注释写的是“读全表并顺手剔除过期项”,但
readRecoveryTable()只是过滤后返回,并不会把剔除结果写回 localStorage(过期项会一直留到下一次写入时才会被清掉)。建议把注释改成“过滤过期项”以免误导。
This issue also appears on line 236 of the same file.
/** 读全表并顺手剔除过期项。localStorage 不可用 / schema 损坏时静默回退空表。 */
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 84704c5a04
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
本轮三条 review 反馈归为两族。 一、可恢复副本必须真的做到它声称的事 - 起目标那条路径把副本落在了 `deviceLink.subscribe` **之后**。subscribe 同样是隧道 invoke、同样可能走到 30s 超时,而 `consumePendingGoal` 早就删了内存那份 —— 这段 窗口目标正文照样会丢。改为在任何远程等待之前就落副本,判据也从「开没开协同」 换成「是不是一次远程交接」:否则非协同的 device-link 起目标仍有同一个缺口。 - 过期项只在读取时被过滤掉,从没写回磁盘。该账号只要不再写新交接项,正文就无限期 留在 localStorage 里,与声明的 TTL 和持久数据生命周期不符。改为剔除即写回, 整份 JSON 损坏时同样覆盖掉。 二、超时回查的重试预算要按错误性质花 触发回查的前提就是链路刚抖过(enableOrca 超时),第一次回查撞上同一段抖动是常态。 原来只要回查抛错就立即 return null,等于「因为链路抖所以判定协同没建成」,后面五次 预算一次没用。改为:瞬态错误继续用完剩余轮次,永久错误(被控端版本过旧 / 远程已禁用) 立即降级,未知错误按不可重试处理。判据复用 device-link 既有的 isTransientRemoteError —— 同一条传输、同一类判断,另写一份等价物正是本 PR 一路在消灭的形状。 测试:回查新增 4 条路径(瞬态后恢复成功 / 瞬态耗尽 fail-closed / 永久立即降级 / 未知不空转),副本新增 4 条(过期真的离盘 / 不牵连有效条目 / 损坏 JSON 被覆盖 / 顺序守卫覆盖 subscribe)。仓库根 pnpm test:unit 全量通过,desktop typecheck 通过。 Signed-off-by: Dash <f9dftwf5tj@privaterelay.appleid.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx:2768
- pendingGoal 交接里用的是非粘滞的 getSessionDeviceId;在 device-link relay 瞬断清空注册表的窗口内这里会把远程会话误判成本机,从而跳过 deviceLink.subscribe(以及上面的可恢复副本判据也会变窄),与本文件其余位置改用 getStickySessionDeviceId 的口径不一致。建议这里同样改用粘滞归属。
const deviceId = getSessionDeviceId(sessionId);
apps/desktop/src/renderer/state/pendingFirstMessage.ts:283
- __clearAllForTest 会先把 activeDataOwnerId 置空再 removeItem(RECOVERY_STORAGE_KEY),但 rememberRecoverableHandoff 在设置过 owner 后会写入
${RECOVERY_STORAGE_KEY}:<owner>命名空间;这种情况下测试清理不会删掉实际写入的 key,可能导致后续用例读到脏数据。建议先按当前 owner 计算一次 recoveryStorageKey() 并同时清理两种 key。
export function __clearAllForTest(): void {
map.clear();
goalMap.clear();
activeDataOwnerId = null;
try {
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6694139064
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@dashhuang 👋 这个 PR 还有 2 条 review conversation 没 resolve(apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx),auto-review 因此暂时跳过、没法继续审查 / 合并。 如果你已经按评论改完或回应了,请到对应 thread 上点 Resolve conversation;全部 resolve 后,下一轮 auto-review 会自动重新审查这个 PR。 |
一、副本要在登记时落,不能等消费(codex P1) 上一版把 `rememberRecoverableHandoff` 放在 SessionView 消费 pending 处,而那条 effect 要等 `historyLoaded`。被控端此刻离线、或首次拉历史超过 PENDING_TTL_MS(60s) 时,effect 根本轮不到执行,内存项却已被 TTL 删掉 —— 链路恢复后 consumePending 只能 拿到 null,磁盘上也从来没写过,正文照样永久丢失。这是**消费之前**的窗口,与前两轮 修的「消费之后」不是同一个。 改为在草稿路由登记 `setPending` / `setPendingGoal` 的同一刻落副本,窗口从「消费之后」 提前覆盖到「登记之后」的全程;SessionView 不再自己落。 二、发送锁要覆盖整条远程交接(codex P2) 起目标的锁只包住开协同那段:前面的 `subscribe`、后面的 `setGoal` 同样是隧道 invoke、 同样可能走到 30s 超时,却都在锁外 —— 这两个窗口里用户补发的消息会抢在目标首轮之前。 首条消息那条有对称缺口:锁在命令派发那次 await 之前就释放了。 两条都改为锁覆盖「消费 pending → 首轮发出 / setGoal 结束」的全程。 顺带把 `remoteCollabPreparing` 改名 `remoteHandoffPreparing`:它现在也覆盖没开协同的 远程起目标路径,继续叫 collab 会让下一个人以为只在开协同时为真。 守卫相应改写:副本改为断言落在 draft route 的两条登记之后、navigate 之前,并断言 SessionView 不再出现 rememberRecoverableHandoff;锁改为断言 lock 早于 subscribe 与 collab 两处 await、解锁晚于 sendMessage / setGoal。仓库根 pnpm test:unit 全量通过, desktop typecheck 通过。 Signed-off-by: Dash <f9dftwf5tj@privaterelay.appleid.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (3)
apps/desktop/src/renderer/tests/remoteSessionSyncInvariants.test.ts:76
- 源码里
ChatInput.disabled建议改为使用sessionHandoffPreparing后,这里的不变式断言也需要同步更新,避免测试继续锁定旧字符串。
// device-link 远程交接期间也要禁用(见 remoteHandoffPreparing):那几段 await
// 可能数十秒,不禁用的话用户补发的消息会插到草稿提交的首条之前。断线这一档不变。
expect(sessionViewSrc).toContain(
'disabled={remoteSessionUnavailable || remoteHandoffPreparing}',
);
apps/desktop/src/renderer/tests/newMakerOrcaCreateOrder.test.ts:125
- 同上:如果
ChatInput.disabled改为remoteSessionUnavailable || sessionHandoffPreparing,这里的字符串断言也要跟着更新,否则会把实现锁回remoteHandoffPreparing。
// 「会话正在准备」只允许有一个下游判据:handleSend 拦截读合并值,ChatInput 也禁用。
expect(sessionViewSource).toContain(
'const sessionHandoffPreparing = worktreePreparing || remoteHandoffPreparing;',
);
expect(sessionViewSource).toContain('if (sessionHandoffPreparing) return false;');
expect(sessionViewSource).not.toContain('if (worktreePreparing) return false;');
expect(sessionViewSource).toContain(
'disabled={remoteSessionUnavailable || remoteHandoffPreparing}',
);
apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx:3464
ChatInput的disabled仍只依赖remoteHandoffPreparing,没有复用上方合并后的sessionHandoffPreparing(worktree + remote handoff)。这会让“会话正在准备”的判据再次分叉,和注释里“下游只读这一个值”的意图不一致。建议直接改为使用sessionHandoffPreparing。
disabled={remoteSessionUnavailable || remoteHandoffPreparing}
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e221ff1097
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
第 10 轮反馈仍是同一族,所以按收敛止损规则不再逐点打补丁,改为把生成这些缺口的 形状本身修掉。 本轮的 P1:`sendMessage` 在设备离线 / 访问被撤销 / 远端 maker:input:enqueue 拒绝时 **不抛错**,而是 resolve false,并且对远程会话还会把那条乐观气泡从 transcript 里撤掉。 之前没 await 就丢副本,等于正文从界面和磁盘上同时消失 —— 而内存 pending 已消费、 新建页草稿也已清空。 根因不是这一处漏了 await:"正文已经有新归宿了吗"这个判断此前散在三个调用点 (命令派发 / 首轮发送 / 起目标),各自就地判断,而这一族 review 每轮都是其中某一处 判错。所以收敛成单一入口: - `forgetRecoverableHandoff` 不再导出,改为模块私有; - 新增 `deliverRecoverableHandoff(sessionId, deliver)` 作为删除副本的唯一途径: resolve true → 丢副本;resolve false → 保留;抛错 → 保留并向上冒泡; - 三处交接全部改走它。 于是"什么时候可以删"只剩一处可改,裸调 forget 直接编译不过(TS 已验证)。 守卫也从"forget 排在交出去之后"改成"会话视图里不得出现 forgetRecoverableHandoff、 且三处交接都经 deliver"。 测试:新增 3 例交付语义(resolve false 保留 / 抛错保留且冒泡 / resolve true 丢弃)。 仓库根 pnpm test:unit 全量通过,desktop typecheck 通过。 Signed-off-by: Dash <f9dftwf5tj@privaterelay.appleid.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (1)
apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx:2684
- 这里的发送锁(remoteHandoffPreparing)目前只在
pending.remoteCollab非空时才会开启,但注释写的是“远程交接才上锁”。device-link 草稿即使没开协同,首轮sendMessage仍是隧道 invoke,可能长时间 await;若不锁住 composer,用户在会话首屏空白期间补发会插队到草稿首轮之前,造成顺序倒置。建议用“是否远程会话(粘滞归属)”作为判据,而不是“是否携带 remoteCollab”。
// 锁要覆盖**整条交接**(消费 pending → 首轮发出),不能只包住开协同那段 await:
// 解锁后到 sendMessage 之间还有一次 await(命令派发),那个窗口里用户补发的消息
// 会抢在草稿提交的首条之前。远程交接才上锁 —— 本机交接没有远程等待。
const holdComposer = !!pending.remoteCollab;
if (holdComposer) setRemoteHandoffPreparing(true);
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 47a885dd5f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
三条反馈分属三条既有不变量,各补一处漏网: 一、不变量 #3(归属判定唯一且粘滞)的第三处漏网 远程草稿起目标时,「要不要订阅 session:<id>」用的是非粘滞 getSessionDeviceId,而真正 发 setGoal 的 goalApiFor 走粘滞归属。relay 瞬断清空注册表的窗口内,订阅被跳过、setGoal 照样发到被控端 —— 目标首轮的 maker:event/status 推送落在没有订阅者的窗口里。改为 getStickySessionDeviceId,判据与执行端归属同口径。 二、不变量 #1(提交点之后不得插入等待)对副本同样成立 发送分支的副本落在 rehomeDraftAttachments 之后。那是本机 IPC,但含 base64 / 草稿缓存 图片时并不快;提交点之后每多一次 await,「对端会话已建好、正文却还没有第二份」的窗口 就长一分。副本只存正文、不依赖附件迁移结果,所以移到紧贴 commitRemoteSessionHandoff。 起目标分支的提交点与 setPendingGoal 之间没有 await,原位置已等价,只补注释说明。 三、回查只限次数不限时间 链路「可连但每个 invoke 都黑洞」时,每次 listWorkersByLead 自己就要走满 30s 隧道超时, 6 次串行 ≈ 3 分钟,而这段时间 composer 是锁住的。次数上限只约束了退避总和(15s), 没约束探针自身耗时。加 30s 总 deadline,两道闸谁先到都停:回查最坏 30s 收尾,叠加 最初 enableOrca 的 30s,composer 最坏锁约 1 分钟而不是 3 分钟。 顺带说明未做的:CCAgentSessionView 里还有三处非粘滞 getSessionDeviceId(extraDirs 写、 forkStripEncrypted 后的注册表重拉、vendor↔model 回退跳过),同样有瞬断窗口问题,但都是 本 PR 未触及的既有代码、且分属无关功能线,按「一个 PR 只做一件事」不并入,已在 thread 里说明并建议另开 issue。 测试:回查新增探针黑洞时按 deadline 收尾一例;守卫新增订阅判据必须粘滞、副本必须排在 提交点之后与附件迁移之前且只落一次。仓库根 pnpm test:unit 全量通过,typecheck 通过。 Signed-off-by: Dash <f9dftwf5tj@privaterelay.appleid.com> Signed-off-by: Dash <dashhuang@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
apps/desktop/src/renderer/features/cc-agent/remoteCollabHandoff.ts:98
recoverTimedOutTeam的 overall deadline 检查只发生在 loop 顶部;当attempt > 0时会先await delay(3000)再发起 probe,这可能导致在 deadline 已到/即将到时仍额外等待并继续 probe,从而突破这里注释承诺的“谁先到都停”的总时限语义。建议在 delay 之后、发 probe 之前再做一次 deadline 检查(或按 remaining time 决定是否 delay)。
if (attempt > 0) await delay(TIMEOUT_RECOVERY_DELAY_MS);
apps/desktop/src/renderer/state/pendingFirstMessage.ts:313
__clearAllForTest目前只 remove 了未分命名空间的RECOVERY_STORAGE_KEY,但该模块实际会在setPendingHandoffOwner()后把 key 变成${RECOVERY_STORAGE_KEY}:<owner>(见recoveryStorageKey())。这会导致测试清理时仍残留其它 owner 的可恢复正文,后续用例可能串台/flake。建议至少清掉“当前 owner 的 namespaced key + 旧的 base key(兼容历史/无 owner)”;若底层 localStorage 支持枚举,再按前缀批量清理会更稳。
export function __clearAllForTest(): void {
map.clear();
goalMap.clear();
activeDataOwnerId = null;
try {
window.localStorage.removeItem(RECOVERY_STORAGE_KEY);
} catch {
// ignore
}
MagicLizi
left a comment
There was a problem hiding this comment.
审查通过。单一策略入口 collabEntryPolicy 消除了双判定漂移,sticky 路由和可恢复 handoff 正确处理了 relay 窗口期,device-link allowlist 新增的只读通道 fail-closed,119 条测试全过。无 P0/P1。
|
合了。device-link 双端判定终于收进一个 policy,断线重连窗口的 sticky 路由和可恢复 handoff 解决了一个实打实的竞态——119 条覆盖。 |
这次改了什么
摘要
device-link(跨设备远程控制)项目的协同入口在「新建草稿」与「已创建会话」之间不一致(#1170):草稿里被硬编码关闭协同,发出第一条消息进入会话页后开关又出现了。根因是两处各写一份 eligible 判据,而判据分叉没有任何编译或测试信号。
排查中还发现更隐蔽的一层:协同的项目级 collab 开关一律查控制端本机。device-link 会话的
workingDir是被控端机器上的路径,拿它在控制端自己的 fs 上找.cindy/plugins.json必然落空,读到的是控制端自己的用户级开关 —— 与被控端 main 的assertCollabProjectEnabled(唯一权威授权)可能相反,入口据此置灰或放行都可能是错的,用户点下去才撞PRECONDITION_FAILED。本 PR 按「device-link 支持协同」的方向补齐(会话页的远端 Worker 创建、团队读写、推送与 IPC allowlist 早已实现,缺的只是草稿阶段的开启链路与查询归属):
resolveCollabEntryPolicy,草稿路由与会话视图共用同一份判定,覆盖本地 / SSH 远端 / device-link / 对话模式 / Orca Worker 五类场景。useCollabProjectPolicy支持deviceId,经新增的makerTransport.pluginEnableStateFor隧道到被控端读它自己的项目级真相;maker:plugins:get-state登记进 device-link allowlist。老被控端回CHANNEL_NOT_ALLOWED时单独归类为unsupported(置灰 + 专属文案,不挂一个永远不会成功的重试),与瞬时unavailable区分。model/providerId/effort/fast都是设备作用域的,原样带到另一台机器会撞被控端的精确 preflight。enableOrca拉起 Worker,收敛进remoteCollabHandoff:等enableOrca(Lead 的首个 turn 才带得上协同 MCP),但镜像回流 fire-and-forget(refreshRemoteDeviceSessions对瞬态错误有最长约 6.75 秒的退避重试,挡在交接前面会丢掉用户的首条消息或目标文案 —— 即remoteSessionHandoff第 33 轮 P1 那条不变量)。CreateWorkerPopover传deviceId,模型清单来自被控端。变更类型
feat新功能fix缺陷修复refactor/perf重构或性能优化docs/test/chore文档、测试或工程维护范围
maker:plugins:get-state进 device-link allowlist;草稿两条 device-link 创建路径接通enableOrca;换设备清 Worker 配置;unsupportedRemoteHint四语文案;相关注释与 Orca 架构文档更新;对应单测。apps/mobile只渲染协同消息卡片,见下方「远程连接与手机版适配」);远端项目级 collab 配置机制(SSH 侧仍是skipQuery,属既有 follow-up);协同 UI 本身的形态调整。UI 变化
不涉及:本次没有新增或修改任何组件、布局、样式与动效,只复用既有
ChatInputcollaborationprop 的disabled/disabledReason态,并让 device-link 草稿命中已有的协同 pill 渲染分支。新增的唯一用户可见文案是newChat.collaboration.unsupportedRemoteHint(四语齐全,走既有 tooltip 通道,无新增颜色或硬编码样式)。怎么验证的
自动验证
首轮全量门禁是
GATE_EXIT=1,暴露 3 个 source-guard 失败,全部真实且已修:newMakerProjectPicker守住「组件不得自己 import 回流函数」——由此把远程开协同的收尾抽成共享的remoteCollabHandoff,两条草稿路径不再逐字重复;orcaWorkflowRoute守住「legacy /orca 路由必须被!orcaMode &&短路掉」——判据收敛后同义更新断言;useCollabProjectPolicy单测因refresh()返回值新增unsupported字段而失败——补齐断言,并把 device-link 相关用例并入该文件(避免出现第二份同名测试)。新增 / 更新的测试:
collabEntryPolicy.test.ts(新增):五类场景 + 防「两个入口再次各写一份判据」的 drift 守卫;useCollabProjectPolicy.test.ts:device-link 隧道路由、同路径不同设备不串台、CHANNEL_NOT_ALLOWED→unsupported与瞬时失败 →unavailable的分类;newMakerDraft.test.ts:device-link 项目草稿可开协同、对话模式仍关、换设备清workerConfig、同设备换项目保留;newMakerOrcaCreateOrder.test.ts:两条 device-link 分支的enableOrca在交接之前、共用 helper、镜像回流不得被 await、按被控端目录收窄来源;makerTransportRouting.test.ts:pluginEnableStateFor本机 / 远程分流(新 channel 由既有 drift 守卫自动纳入 allowlist 校验)。手工验证
不涉及(未做真机双端实测,原因见下)。
未执行的验证
/cc-agent/new选被控设备的项目 → 协同开关可见可开 → 配置 Worker(模型清单应来自被控端)→ 发送 → 被控端起 Lead + Worker、控制端协同 tab 能看到 Worker;切到另一台设备确认 Worker 配置被清空;被控端项目里关掉 collab 插件后控制端两处入口同时置灰;老版本被控端应给出「暂不支持协同」。collaborationprop 的 disabled/tooltip 态,颜色全部走既有 themed 样式、无硬编码,但未在两种模式下实机目检。useCollabProjectPolicy.ts的prefer-const(requestPromise)在main基线上即已存在(已 stash 到基线复现确认),不属于本 PR 范围,未一并修改。远程连接与手机版适配(
docs/dev-rules/remote-and-mobile-adaptation.md门禁)skipProjectQuery(远端主机路径在本机 fs 上查项目级既无意义又会误判,与 main 侧assertCollabProjectEnabled的 remote 分支同口径),判定逻辑只是从两处内联搬进了共享 helper。maker:plugins:get-state,已按packages/device-link/src/allowlist.ts顶部的三条准入判据登记并写明理由(handler 只读 settings + 项目.cindy/plugins.json,不依赖event.sender、无 UI/shell 副作用,插件启停真相在被控端)。无新增推送事件,无 topic 路由变化。老被控端不在其 allowlist 内 →CHANNEL_NOT_ALLOWED→ 控制端 fail-closed 置灰并说明原因,不会放行到enableOrca才撞错。apps/mobile目前只渲染协同消息卡片(src/session/orcaCollab.ts),没有开启协同的入口;新 channel 对 mobile 无害(同一套 allowlist,需要时可直接复用)。风险
风险分类
影响与回滚
packages/device-link的 invoke 白名单新增一条只读 channel。不涉及服务端、不改cindy-protocolsubmodule。CHANNEL_NOT_ALLOWED,控制端 fail-closed 置灰并提示设备版本过旧;老控制端 → 新被控端:不会发起该调用,行为不变。两个方向都不需要版本协商。assertCollabProjectEnabled仍是唯一权威授权,本 PR 改的全部是 renderer 的入口体验层与查询归属。新增的 channel 只读、不含凭证材料,且 device-link 本身已是同账号 +remoteControlEnabled显式 opt-in 的信任域内(控制端本就能在该 workingDir 跑 agent),不扩大攻击面。路径归一化normalizeWorkingDirForProjectSettings是纯路径形态推导(不依赖process.platform/ 本机 userData),跨 macOS ↔ Windows 控制同样成立。collab.workerConfig只是本地草稿态(本就不跨重启保留initialTask),revert 后按旧规则重新收敛,不留脏状态。单点降级:把resolveCollabEntryPolicy里 device-link 的eligible改回false,即可只关掉 device-link 的入口而保留其余修正。提交前检查
git commit -s,见 DCO)不变量清单(review 收敛锚点)
十一轮 review 反馈全部落在同一族:device-link 草稿开启协同的时序与失败语义。逐条修容易漏掉对称的另一半,所以把不变量显式列出来,后续 review 请对着这张表看,而不是对着 diff 猜。
一句话不变量:device-link 草稿开启协同时,用户的输入必须先被登记并立刻交接出去;首轮必须排在协同定性之后;而「是否就绪」只能由被控端的权威状态回答。
第 4 轮的结论是前三轮那种「在导航前等」的形状本身站不住:它同时被两侧夹击 —— 等得越久,用户输入在内存里裸奔的窗口越长(greptile P1);等得不够久,又永远不是权威终态(codex P2)。所以第 4 轮改了结构:等待整体挪到导航之后,由
CCAgentSessionView在消费 pending 时执行。新建页不再卡住,用户输入已经交到会话视图手里,而首轮仍由同一个 await 串在协同之后。rehomeDraftAttachments之前)。草稿路由登记完setPending/setPendingGoal就立刻navigate,协同意图随 pending 载荷交接出去NewMakerDraftRoute两条 device-link 分支 +PendingRemoteCollabnewMakerOrcaCreateOrder:draft route 不得出现enableRemoteCollabForSession;副本排在提交点之后、附件迁移之前且只落一次sendMessage/setGoal排在其后CCAgentSessionView两处 pending 消费newMakerOrcaCreateOrder:两处消费共用consumePendingRemoteCollabstickySessionOrigin)makerApiForSticky/getStickySessionDeviceIdorcaRemoteRoutingInvariants:enable 与 disable 都不得退回非粘滞版;起目标的订阅判据必须与goalApiFor同口径remoteCollabHandoff的isAmbiguousTimeout/recoverTimedOutTeam/TIMEOUT_RECOVERY_DEADLINE_MSremoteCollabHandoff.test.ts十条路径remoteCollabHandoff的finallynewMakerOrcaCreateOrder:不得await;finally排在return之后workerConfig在换设备时清空;worker类型在发送时按目标设备目录收窄(换掉则一并丢弃属于旧 agent 的 model / providerId)newMakerDraft.patchDraft/draftEnableOrcaOptionsnewMakerDraft.test.ts+newMakerOrcaCreateOrder:agent 收窄守卫_REMOTE文案只讲去那台机器修,用户会以为没发而重复提交getCollaborationStartErrorMessage的remoteContinueNoticenewMakerOrcaCreateOrder:两处消费都带continueAsSingleSessiondeliverRecoverableHandoff(resolvetrue删/false保留/抛错保留并冒泡),forgetRecoverableHandoff不再导出,裸调编译不过。落在消费处等于没落(消费 effect 要等historyLoaded)。恢复只回填输入框、绝不自动补发,输入框已有内容时让路并保留副本;过期项剔除即写回pendingFirstMessage.deliverRecoverableHandoffpendingHandoffRecovery.test.ts18 例 +newMakerOrcaCreateOrder:副本落在setPending*之后navigate之前;SessionView 不得出现forgetRecoverableHandoff(;三处交接都经 deliversetGoal结束),不只是开协同那一段——subscribe与setGoal同样是隧道 invoke、同样可能 30s,锁外的窗口里补发消息会抢在首轮之前remoteHandoffPreparing(刻意不叫remoteCollabPreparing:也覆盖没开协同的远程起目标)newMakerOrcaCreateOrder:lock 早于两处 await、解锁晚于sendMessage/setGoal已枚举的对称路径(每条不变量都按这三个维度检查过):
remoteCollabHandoff,时序约束只有一处可改。enableOrca×disableOrca—— 归属判定同口径。remoteCollabHandoff.test.ts逐条覆盖。已知残差(刻意不做,非疏漏):
newMakerDraft头部已定「附件 → 丢失(产品决策)」)。等待期间 app 被关掉,重开会话能拿回正文,附件需重新添加。orcaRole回流 + external-enable 边沿检测自愈,只影响首轮。