diff --git a/apps/desktop/src/main/maker-ipc/collabProjectPolicy.ts b/apps/desktop/src/main/maker-ipc/collabProjectPolicy.ts index 0d839d189be..830bcb5647d 100644 --- a/apps/desktop/src/main/maker-ipc/collabProjectPolicy.ts +++ b/apps/desktop/src/main/maker-ipc/collabProjectPolicy.ts @@ -14,6 +14,12 @@ export interface CollabProjectPolicyContext { * per-query http 注入 + persistent token 与 ?session= 路由),cindy_orca * 在两端都可用。 * + * device-link(跨设备远程控制)不需要在这里开任何例外:控制端的 enable-orca + * 经隧道路由到**被控端**执行,到了那一侧它就是一个普通本地会话 + * (remoteHostId 为空、workingDir 是被控端真实路径),自然走下面的本机项目级 + * 分支。控制端 renderer 会先隧道读被控端的 collab 开关来置灰入口(见 + * makerTransport.pluginEnableStateFor),那只是体验层的提前告知。 + * * 这是主进程的最终授权边界;Renderer 的入口状态只是用户体验层, * 不能替代这里的校验。 */ diff --git a/apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts b/apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts new file mode 100644 index 00000000000..e2a442149dc --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts @@ -0,0 +1,136 @@ +/** + * collabEntryPolicy 单测:协同入口的**单一判定口径**。 + * + * 存在的理由(issue #1170):新建草稿与已创建会话曾各自写一份 eligible 判据,于是同一个 + * device-link 项目在草稿里没有协同开关、发出第一条消息进会话页后开关又冒出来。判据分叉 + * 没有任何编译/测试信号,所以这里把五类场景钉死,并另有一条守卫防止两个调用点再各写一份。 + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { resolveCollabEntryPolicy } from '@/features/cc-agent/collabEntryPolicy'; + +const R = resolve(__dirname, '..'); +// Windows checkout(core.autocrlf)下源码是 CRLF;统一归一成 LF。 +const read = (rel: string) => readFileSync(resolve(R, rel), 'utf8').replace(/\r\n/g, '\n'); + +describe('resolveCollabEntryPolicy 五类场景', () => { + it('本地项目:可挂入口,查本机项目级', () => { + expect( + resolveCollabEntryPolicy({ workspaceKind: 'project', workingDir: '/Users/me/proj' }), + ).toEqual({ eligible: true, skipProjectQuery: false }); + }); + + it('SSH 远端项目:可挂入口,但跳过项目级(远端路径在本机查无意义)', () => { + const scope = resolveCollabEntryPolicy({ + workspaceKind: 'project', + workingDir: '/home/me/proj', + remoteHostId: 'host-1', + }); + expect(scope.eligible).toBe(true); + expect(scope.skipProjectQuery).toBe(true); + expect(scope.policyDeviceId).toBeUndefined(); + }); + + it('device-link 项目:可挂入口,项目级查询隧道到被控设备', () => { + const scope = resolveCollabEntryPolicy({ + workspaceKind: 'project', + workingDir: '/Users/other/proj', + deviceLinkDeviceId: 'dev-1', + }); + expect(scope.eligible).toBe(true); + expect(scope.policyDeviceId).toBe('dev-1'); + // device-link 的项目级配置在被控端**真实存在**,不能跳过 —— 跳过就退化成只看 + // 用户级,项目里单独关掉 collab 的设置会被无视。 + expect(scope.skipProjectQuery).toBe(false); + }); + + it('对话模式(无项目目录):不挂入口', () => { + // dialogue 会话在 main 侧也会拿到一个自动分配的运行目录,所以必须靠 workspaceKind + // 判定,不能从 workingDir 反推。 + expect( + resolveCollabEntryPolicy({ + workspaceKind: 'dialogue', + workingDir: '/Users/me/Library/.../dialogues/2026-07-31/s1', + }).eligible, + ).toBe(false); + expect( + resolveCollabEntryPolicy({ workspaceKind: 'project', workingDir: null }).eligible, + ).toBe(false); + expect( + resolveCollabEntryPolicy({ workspaceKind: 'project', workingDir: ' ' }).eligible, + ).toBe(false); + }); + + it('Orca Worker 子会话:不挂入口(worker 自己不能再开协同)', () => { + expect( + resolveCollabEntryPolicy({ + workspaceKind: 'project', + workingDir: '/Users/me/proj', + orcaRole: 'worker', + }).eligible, + ).toBe(false); + }); + + it('Orca Lead 会话本身仍 eligible(ON 态的 pill 要能渲染成关闭按钮)', () => { + expect( + resolveCollabEntryPolicy({ + workspaceKind: 'project', + workingDir: '/Users/me/proj', + orcaRole: 'lead', + }).eligible, + ).toBe(true); + }); + + it('被控设备上的 SSH 远端项目:两个维度同时成立,不是二选一', () => { + const scope = resolveCollabEntryPolicy({ + workspaceKind: 'project', + workingDir: '/home/me/proj', + remoteHostId: 'host-1', + deviceLinkDeviceId: 'dev-1', + }); + // 隧道到被控端执行查询,并在被控端那侧也跳过项目级(路径属于再远一层的 SSH 主机)。 + expect(scope).toEqual({ eligible: true, policyDeviceId: 'dev-1', skipProjectQuery: true }); + }); + + it('空串 deviceId / remoteHostId 当作没有(不产生 policyDeviceId 与 skip)', () => { + expect( + resolveCollabEntryPolicy({ + workspaceKind: 'project', + workingDir: '/Users/me/proj', + deviceLinkDeviceId: '', + remoteHostId: '', + }), + ).toEqual({ eligible: true, skipProjectQuery: false }); + }); +}); + +describe('drift 守卫:两个入口共用同一份判定', () => { + it('草稿路由与会话视图都调 resolveCollabEntryPolicy,不再各自内联判据', () => { + for (const f of [ + 'features/cc-agent/NewMakerDraftRoute.tsx', + 'features/cc-agent/CCAgentSessionView.tsx', + ]) { + const src = read(f); + expect(src, f).toContain('resolveCollabEntryPolicy({'); + expect(src, f).toContain('collabEntry.eligible'); + // 内联判据的两个历史形态:草稿的 `effectiveDeviceLinkDeviceId == null`(把 + // device-link 整个排除掉)与会话页的 `orcaRole !== 'worker'` 链。 + expect(src, f).not.toContain('effectiveDeviceLinkDeviceId == null'); + expect(src, f).not.toContain("orcaRole !== 'worker'"); + } + }); + + it('两个入口的项目级查询都按 collabEntry 的归属传参(不再写死本机)', () => { + for (const f of [ + 'features/cc-agent/NewMakerDraftRoute.tsx', + 'features/cc-agent/CCAgentSessionView.tsx', + ]) { + const src = read(f); + expect(src, f).toContain('skipQuery: collabEntry.skipProjectQuery'); + expect(src, f).toContain('deviceId: collabEntry.policyDeviceId ?? null'); + } + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/makerTransportRouting.test.ts b/apps/desktop/src/renderer/__tests__/makerTransportRouting.test.ts index 7abbda8d2de..fbbc0fe05a7 100644 --- a/apps/desktop/src/renderer/__tests__/makerTransportRouting.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerTransportRouting.test.ts @@ -24,6 +24,7 @@ function stubElectron() { closeSession: vi.fn(), enableOrca: vi.fn(), disableOrca: vi.fn(), + plugins: { getState: vi.fn().mockResolvedValue({ effectiveEnabled: true }) }, input: { clearSession: vi.fn(), compact: vi.fn() }, }; const orcaWorkflows = { @@ -201,6 +202,74 @@ describe('makerApiFor 路由(完整对等会话级操作)', () => { expect(localMessages.estimatedSessionValue).toHaveBeenCalledWith('local-sess'); expect(invoke).not.toHaveBeenCalled(); }); + + // issue #1170 codex P2:协同 mutation 在 relay 瞬时重连清空注册表的窗口内若退回本机, + // 会在**控制端**建出或销毁一个 team —— 与「入口按粘滞归属渲染」直接矛盾。 + it('makerApiForSticky:注册表被清空后仍走隧道,不退回本机', async () => { + const { makerSpies, invoke } = stubElectron(); + const { makerApiFor, makerApiForSticky } = await import('@/lib/makerTransport'); + const { remoteProjectsStore } = await import('@/features/device-link/remoteProjectsStore'); + const { getStickySessionDeviceId } = await import( + '@/features/device-link/stickySessionOrigin' + ); + + remoteProjectsStore.setDeviceSessions('dev-1', 'Mac', [sess('lead')]); + // 先解析一次,让粘滞归属记住 dev-1(与真实链路一致:视图渲染时已解析过)。 + expect(getStickySessionDeviceId('lead')).toBe('dev-1'); + + // relay 瞬时重连:注册表被清空,非粘滞判定这一刻会解析成「本机」。 + remoteProjectsStore.setDeviceSessions('dev-1', 'Mac', []); + await makerApiFor('lead').enableOrca('lead', { workerAgent: 'codex' }); + expect(makerSpies.enableOrca).toHaveBeenCalled(); // 非粘滞:确实退回了本机(问题本体) + + invoke.mockClear(); + makerSpies.enableOrca.mockClear(); + await makerApiForSticky('lead').enableOrca('lead', { workerAgent: 'codex' }); + expect(invoke).toHaveBeenCalledWith('dev-1', 'maker:session:enable-orca', [ + 'lead', + { workerAgent: 'codex' }, + ]); + expect(makerSpies.enableOrca).not.toHaveBeenCalled(); + }); + + it('makerApiForSticky:从未解析过归属的本机会话仍走本机(零回归)', async () => { + const { makerSpies, invoke } = stubElectron(); + const { makerApiForSticky } = await import('@/lib/makerTransport'); + + await makerApiForSticky('local-only').disableOrca('local-only'); + expect(makerSpies.disableOrca).toHaveBeenCalledWith('local-only'); + expect(invoke).not.toHaveBeenCalled(); + }); + + // issue #1170:协同入口的项目级 collab 开关此前一律查控制端本机 —— 拿被控端的路径查 + // 自己的 fs,读到的是控制端自己的用户级开关,与被控端 main 的权威授权可能相反。 + it('pluginEnableStateFor:传了 deviceId 就隧道查被控端;没传才查本机', async () => { + const { makerSpies, invoke } = stubElectron(); + invoke.mockResolvedValue({ effectiveEnabled: false }); + const { pluginEnableStateFor } = await import('@/lib/makerTransport'); + + await expect(pluginEnableStateFor('dev-1', 'collab', '/host/proj')).resolves.toEqual({ + effectiveEnabled: false, + }); + expect(invoke).toHaveBeenCalledWith('dev-1', 'maker:plugins:get-state', [ + 'collab', + '/host/proj', + ]); + expect(makerSpies.plugins.getState).not.toHaveBeenCalled(); + + invoke.mockClear(); + await pluginEnableStateFor(null, 'collab', '/local/proj'); + expect(makerSpies.plugins.getState).toHaveBeenCalledWith('collab', '/local/proj'); + expect(invoke).not.toHaveBeenCalled(); + + // skipQuery 档(SSH 远端):不传 workingDir → 落用户级/全局级,两条路由都要保持原样透传。 + invoke.mockClear(); + makerSpies.plugins.getState.mockClear(); + await pluginEnableStateFor('dev-1', 'collab', undefined); + expect(invoke).toHaveBeenCalledWith('dev-1', 'maker:plugins:get-state', ['collab', undefined]); + await pluginEnableStateFor(undefined, 'collab', undefined); + expect(makerSpies.plugins.getState).toHaveBeenCalledWith('collab', undefined); + }); }); describe('drift 守卫:makerTransport 隧道的每个 channel 都在 REMOTE_INVOKE_ALLOWLIST 内', () => { diff --git a/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts b/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts index c8522596bd3..05f1fd0c435 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts @@ -508,11 +508,95 @@ describe('newMakerDraft store', () => { expect(getDraft().deviceLinkDeviceName).toBeNull(); }); - it('device-link 草稿禁用协同的既有不变量不受影响', async () => { + // issue #1170:device-link 草稿曾被硬编码禁用协同,而同一个项目建成会话后入口又出现。 + // 现在草稿与会话页共用同一份判定(resolveCollabEntryPolicy),device-link **项目**可开协同; + // 关掉协同的只剩「对话模式」这一条(workingDir == null)。 + it('选设备但没选项目(对话模式)→ 协同仍然关闭', async () => { const { getDraft, patchDraft } = await loadModule(); + patchDraft({ workingDir: '/local/proj' }); patchDraft({ collab: { enabled: true, worker: 'cc' } }); patchDraft({ deviceLinkDeviceId: 'dev-a', deviceLinkDeviceName: 'Studio Mac', workingDir: null }); expect(getDraft().collab.enabled).toBe(false); }); + + it('选设备上的项目 → 协同开关保留(不再被 device-link 一刀切关掉)', async () => { + const { getDraft, patchDraft } = await loadModule(); + patchDraft({ workingDir: '/local/proj' }); + patchDraft({ collab: { enabled: true, worker: 'cc' } }); + patchDraft({ + deviceLinkDeviceId: 'dev-a', + deviceLinkDeviceName: 'Studio Mac', + workingDir: '/host/proj', + }); + expect(getDraft().collab.enabled).toBe(true); + expect(getDraft().collab.worker).toBe('cc'); + }); + + // model / providerId / effort / fast 都是设备作用域:原样带到另一台机器会撞被控端的 + // 精确 preflight,协同静默降级成单会话 —— 正是 #1170 抱怨的「入口能点但走不完」。 + it('换目标设备 → 清掉 Worker 富配置,但保留协同开关与 worker 类型', async () => { + const { getDraft, patchDraft } = await loadModule(); + patchDraft({ workingDir: '/local/proj' }); + patchDraft({ + collab: { + enabled: true, + worker: 'codex', + workerConfig: { + role: 'developer', + model: 'codex/gpt-5.5', + effort: 'high', + fast: false, + providerId: 'prov-local', + initialTask: '先跑一遍测试', + }, + }, + }); + patchDraft({ + deviceLinkDeviceId: 'dev-a', + deviceLinkDeviceName: 'Studio Mac', + workingDir: '/host/proj', + }); + expect(getDraft().collab.enabled).toBe(true); + expect(getDraft().collab.worker).toBe('codex'); + expect(getDraft().collab.workerConfig).toBeUndefined(); + }); + + it('本机 → 设备 A → 设备 B 的每一跳都清 Worker 配置', async () => { + const { getDraft, patchDraft, patchCollab } = await loadModule(); + patchDraft({ + deviceLinkDeviceId: 'dev-a', + deviceLinkDeviceName: 'Studio Mac', + workingDir: '/host-a/proj', + }); + patchCollab({ + enabled: true, + worker: 'cc', + workerConfig: { role: 'reviewer', model: 'claude-opus-4-7' }, + }); + expect(getDraft().collab.workerConfig?.model).toBe('claude-opus-4-7'); + patchDraft({ + deviceLinkDeviceId: 'dev-b', + deviceLinkDeviceName: 'Laptop', + workingDir: '/host-b/proj', + }); + expect(getDraft().collab.workerConfig).toBeUndefined(); + expect(getDraft().collab.enabled).toBe(true); + }); + + it('同一台设备内换项目 → Worker 配置保留(模型目录没变)', async () => { + const { getDraft, patchDraft, patchCollab } = await loadModule(); + patchDraft({ + deviceLinkDeviceId: 'dev-a', + deviceLinkDeviceName: 'Studio Mac', + workingDir: '/host-a/proj', + }); + patchCollab({ + enabled: true, + worker: 'cc', + workerConfig: { role: 'reviewer', model: 'claude-opus-4-7' }, + }); + patchDraft({ deviceLinkDeviceId: 'dev-a', deviceLinkDeviceName: 'Studio Mac', workingDir: '/host-a/other' }); + expect(getDraft().collab.workerConfig?.model).toBe('claude-opus-4-7'); + }); }); }); diff --git a/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts b/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts index 35f65b8fcf5..8f0134497f4 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts @@ -8,6 +8,21 @@ const source = readFileSync( 'utf8', ); +const sessionViewSource = readFileSync( + resolve(__dirname, '..', 'features', 'cc-agent', 'CCAgentSessionView.tsx'), + 'utf8', +); + +const pendingHandoffSource = readFileSync( + resolve(__dirname, '..', 'state', 'pendingFirstMessage.ts'), + 'utf8', +); + +const remoteCollabHandoffSource = readFileSync( + resolve(__dirname, '..', 'features', 'cc-agent', 'remoteCollabHandoff.ts'), + 'utf8', +); + describe('NewMakerDraftRoute Orca worker create order', () => { it('delegates worker creation to enableOrca and defers tab reveal until the new route is current', () => { const collabBranch = source.indexOf('if (shouldEnableCollab)'); @@ -27,16 +42,143 @@ describe('NewMakerDraftRoute Orca worker create order', () => { expect(source).not.toContain('markOrcaRole(worker.sessionId'); }); - it('uses the shared collaboration error i18n mapper for all four draft enable paths', () => { - // 四条草稿起 Worker 路径都走同一个错误映射器:Send 普通、Send worktree、新建目标 - // (2026-07-23 新增 New Goal 路径也 honor 协同,codex P2)、以及 SSH 添加远程项目 - // (2026-07-28 remote 协同接通, codex-connector P2)。 + it('uses the shared collaboration error i18n mapper for every draft enable path', () => { + // 本机 / SSH 侧四条草稿起 Worker 路径都走同一个错误映射器:Send 普通、Send worktree、 + // 新建目标(2026-07-23 新增 New Goal 路径也 honor 协同,codex P2)、以及 SSH 添加远程 + // 项目(2026-07-28 remote 协同接通, codex-connector P2)。 const mappedFallbacks = source.match(/getCollaborationStartErrorMessage\(err, t, \{ continueAsSingleSession: true \}\)/g) ?? []; - expect(mappedFallbacks).toHaveLength(4); + + // device-link 两条(issue #1170)的失败提示已随「等待挪到导航之后」一起搬进 + // CCAgentSessionView 的 pending 消费,draft route 不再自己 toast。 + expect(source).not.toContain('remoteDevice: true'); expect(source).not.toContain("toast.error(t('newChat.collaboration.startFailed'"); }); + it('hands the collab intent to the session view instead of awaiting it before navigation', () => { + // #1170 三轮 review 的收敛结果:两条约束方向相反 —— + // · 首轮必须排在协同之后(否则 Lead 首个 turn 没有 cindy_orca 工具); + // · 提交点之后不得插入远程等待(隧道往返可能走到 invoke 默认 30s 超时,挡在 navigate + // 前面既让新建页卡住半分钟,又把「对端会话已建好、用户输入还只在内存里」的窗口拉到 + // 同样长度,窗口内应用被关掉就永久丢消息)。 + // 只能靠「登记完立刻导航、等待挪到会话视图」同时满足。所以 draft route 里: + // ① 不得再 await 开协同;② 协同意图随 pending 载荷交出去;③ 导航紧跟登记。 + expect(source).not.toContain('enableRemoteCollabForSession'); + + const sendBranch = source.slice( + source.indexOf('if (isDeviceLinkDraft && effectiveDeviceLinkDeviceId) {'), + ); + const sendPending = sendBranch.indexOf('setPending(remoteSessionId, {'); + const sendCollab = sendBranch.indexOf('remoteCollab: {', sendPending); + const sendNavigate = sendBranch.indexOf('navigate(`/cc-agent/${remoteSessionId}`', sendPending); + expect(sendPending).toBeGreaterThan(-1); + // 协同意图必须在 pending 载荷内(而不是 navigate state):它要被 consumePending 一起取走。 + expect(sendCollab).toBeGreaterThan(sendPending); + expect(sendNavigate).toBeGreaterThan(sendCollab); + + const goalHandler = source.slice(source.indexOf('const handleCreateGoal = useCallback(')); + const goalPending = goalHandler.indexOf('setPendingGoal(remoteSessionId, {'); + const goalCollab = goalHandler.indexOf('remoteCollab: {', goalPending); + expect(goalPending).toBeGreaterThan(-1); + expect(goalCollab).toBeGreaterThan(goalPending); + }); + + it('keeps both device-link enable paths on the shared remote collab helper', () => { + // 两条路径逐字重复这段收尾正是 #807 反复踩的坑(漏改一处没有任何编译/测试信号)。 + // 收敛后:draft route 只登记意图,SessionView 的两处 pending 消费共用同一个入口, + // 时序不变量(等 enableOrca、**不等**镜像回流)住在 remoteCollabHandoff 里一处可改。 + expect(sessionViewSource.match(/consumePendingRemoteCollab\(/g)).toHaveLength(2); + expect(source).not.toContain('refreshRemoteDeviceSessions'); + expect(remoteCollabHandoffSource).toContain('void refreshRemoteDeviceSessions(p.deviceId)'); + expect(remoteCollabHandoffSource).not.toContain('await refreshRemoteDeviceSessions('); + }); + + it('tells the user the turn still goes out when remote collab fails', () => { + // `_REMOTE` 文案只讲「去那台机器修好再重试」,没说这一条仍然会发出去 —— 用户据此 + // 可能以为没发、再提交一次(codex review P2)。两处消费都必须带 continueAsSingleSession。 + expect( + sessionViewSource.match(/remoteDevice: true,\s*\n\s*continueAsSingleSession: true,/g), + ).toHaveLength(2); + }); + + it('locks the composer for the whole remote handoff, not just the collab wait', () => { + // 交接期间(可能数十秒)会话看起来是空的,用户很容易以为没发出去而再打一条 —— 那条会 + // 先进 Lead,草稿提交的首条反而排到它后面,顺序倒置,首轮还可能在协同未就绪时跑掉 + // (codex review P2 ×2)。按 worktree 创建同款处理:交接**全程**锁住发送。 + // + // 名字不叫 remoteCollabPreparing:它现在也覆盖没开协同的远程起目标路径, + // 叫 collab 会误导下一个人以为只在开协同时为真。 + expect(sessionViewSource).toContain( + 'const [remoteHandoffPreparing, setRemoteHandoffPreparing]', + ); + // 两处 pending 消费都要置位,且都用 finally 解锁(任何终态都不能把 composer 锁死)。 + expect(sessionViewSource.match(/setRemoteHandoffPreparing\(true\)/g)).toHaveLength(2); + expect(sessionViewSource.match(/setRemoteHandoffPreparing\(false\)/g)).toHaveLength(2); + const finallyUnlocks = sessionViewSource.match( + /\} finally \{\s*\n\s*(?:if \(holdComposer\) )?setRemoteHandoffPreparing\(false\);/g, + ); + expect(finallyUnlocks).toHaveLength(2); + + // 「会话正在准备」只允许有一个下游判据: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}', + ); + }); + + it('refreshes the remote mirror even when the remote enableOrca reports failure', () => { + // 控制端的 invoke 超时**不会取消**被控端正在跑的 enableOrca,所以「控制端报失败、 + // 对端稍后仍建成 team」是真实终态(codex review P1)。回流放在 finally 里,让 + // orcaRole 尽快回流、由 external-enable 边沿检测把协同 tab 补开,UI 最终与被控端收敛。 + const body = remoteCollabHandoffSource.slice( + remoteCollabHandoffSource.indexOf('export async function enableRemoteCollabForSession('), + ); + const returnAt = body.indexOf('return { focusWorkerSessionId:'); + const finallyAt = body.indexOf('} finally {'); + expect(returnAt).toBeGreaterThan(-1); + expect(finallyAt).toBeGreaterThan(returnAt); + expect(body.indexOf('void refreshRemoteDeviceSessions(')).toBeGreaterThan(finallyAt); + }); + + it('narrows the device-link worker source against the controlled device catalog', () => { + // 草稿里持久化的来源/模型按**目标设备**的目录收窄:device-link 分支必须用 + // deviceProviders,拿控制端的 localProviders 收窄等于用错机器的目录。 + const collapsed = source.replace(/\s+/g, ' '); + const remoteNarrowing = + collapsed.match( + /draftEnableOrcaOptions\( effectiveCollab, deviceProviders, !deviceProvidersLoading, \)/g, + ) ?? []; + expect(remoteNarrowing).toHaveLength(2); + // 本机 / SSH 的四条路径仍按控制端目录收窄,不能被一起改掉。 + expect( + collapsed.match( + /draftEnableOrcaOptions\(effectiveCollab, localProviders, !localProvidersLoading\)/g, + ) ?? [], + ).toHaveLength(4); + }); + + it('re-validates the worker agent against the target device catalog', () => { + // Worker 类型也是设备作用域的(codex review P2):在只连 Codex 的设备 A 选了 Codex + // Worker,切到只连 Claude 的设备 B 时 workerConfig 虽被清空,collab.worker 仍是 codex, + // 透传过去必撞被控端 NO_PROVIDER_FOR_AGENT,协同又静默降级成单会话。 + const fn = source.slice( + source.indexOf('function draftEnableOrcaOptions('), + source.indexOf('const createAgentQuickStarts'), + ); + expect(fn).toContain('const preferredAgent'); + // 按目标设备目录判断:首选 agent 无已连接供应商、另一个有 → 换过去。 + expect(fn).toContain('connectedProvidersForAgent(providers, preferredAgent).length > 0'); + expect(fn).toContain('connectedProvidersForAgent(providers, fallback).length > 0'); + // 目录未就绪时不收窄(空快照会误判成"都没有"),与 providerId 同一条口径。 + expect(fn).toContain('if (!providersReady) return preferredAgent;'); + // 换了 agent 就必须丢掉属于旧 agent 的 model / providerId,否则改撞 INVALID_PARAMS。 + expect(fn).toContain('if (workerAgent !== preferredAgent)'); + }); + it('blocks new-goal creation until a selected collaboration policy is available', () => { const goalHandler = source.slice(source.indexOf('const handleCreateGoal = useCallback(')); expect(goalHandler).toContain("let policyEnabled = collabPolicy.enabled"); @@ -52,14 +194,149 @@ describe('NewMakerDraftRoute Orca worker create order', () => { it('carries a successful policy refresh into all collaboration creation branches', () => { expect(source.match(/const shouldEnableCollab =/g)).toHaveLength(2); + // 3 = Send 普通 + Send worktree + 本机/SSH 新建目标;device-link 两条改为在 pending + // 载荷里按 shouldEnableCollab 决定是否带 remoteCollab,不再有独立分支。 expect(source.match(/if \(shouldEnableCollab\)/g)).toHaveLength(3); + expect(source.match(/\.\.\.\(shouldEnableCollab/g)).toHaveLength(2); expect(source).not.toContain('effectiveCollabEnabled'); }); + it('treats an out-of-date controlled device as a terminal reason, not a retryable one', () => { + // 老被控端没有 maker:plugins:get-state → CHANNEL_NOT_ALLOWED。给「稍后重试」是误导: + // 重试永远不会成功。所以 unsupported 单独分类,且排在 unavailable 之前; + // onDisabledActivate(重试入口)仍然只挂在 unavailable 上。 + expect(source).toContain("t('newChat.collaboration.unsupportedRemoteHint')"); + expect(source).toContain('collabPolicy.unsupported'); + expect(source).not.toContain('onDisabledActivate: collabPolicy.unsupported'); + const disabledReason = source.slice(source.indexOf('disabledReason:')); + expect(disabledReason.indexOf('collabPolicy.unsupported')).toBeLessThan( + disabledReason.indexOf('collabPolicy.unavailable'), + ); + }); + it('surfaces initial policy loading and retries an unavailable draft toggle', () => { expect(source).toContain("toast.warning(t('newChat.collaboration.loadingHint'))"); expect(source).toContain('onDisabledActivate: collabPolicy.unavailable'); expect(source).toContain('void collabPolicy.refresh().then((policy) => {'); expect(source).toContain('if (policy.enabled && !policy.unavailable) {'); }); + + // 远程交接期间用户输入必须有第二份副本(greptile P1 + codex P1)。 + // 关键是副本落在**登记那一刻**,不是消费那一刻:消费 effect 要等 historyLoaded, + // 而被控端离线 / 首次拉历史超过 PENDING_TTL_MS(60s)时它根本轮不到跑, + // 内存项却已被 TTL 删掉 —— 副本落在消费处等于没落。 + describe('远程交接的可恢复副本', () => { + const messageBranch = () => { + const start = sessionViewSource.indexOf('const pending = consumePending(sessionId);'); + const end = sessionViewSource.indexOf('const pendingGoalConsumedRef', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return sessionViewSource.slice(start, end); + }; + const goalBranch = () => { + const start = sessionViewSource.indexOf('const pendingGoal = consumePendingGoal(sessionId);'); + expect(start).toBeGreaterThan(-1); + return sessionViewSource.slice(start, start + 4000); + }; + + it('副本在草稿路由登记 pending 的同一刻落下,不等会话视图消费', () => { + // 两条 device-link 分支都要落,且必须排在各自的 setPending / setPendingGoal 之后、 + // navigate 之前 —— 登记完就有副本,后面无论多久没被消费都捞得回来。 + const sendRemember = source.indexOf( + "rememberRecoverableHandoff(remoteSessionId, 'message', message)", + ); + const goalRemember = source.indexOf( + "rememberRecoverableHandoff(remoteSessionId, 'goal', objective)", + ); + expect(sendRemember).toBeGreaterThan(-1); + expect(goalRemember).toBeGreaterThan(-1); + // 副本要**紧贴提交点**:排在 commitRemoteSessionHandoff 之后,但在附件迁移那次 + // await 之前 —— 提交点之后每多一次 await,「对端会话已建好、正文却还没有第二份」 + // 的窗口就长一分(codex P2 第五轮)。 + const sendCommit = source.lastIndexOf("logTag: 'draft send',", sendRemember); + const rehome = source.indexOf('await rehomeDraftAttachments(', sendCommit); + expect(sendCommit).toBeGreaterThan(-1); + expect(sendCommit).toBeLessThan(sendRemember); + expect(sendRemember).toBeLessThan(rehome); + expect(source.lastIndexOf('setPendingGoal(remoteSessionId, {', goalRemember)).toBeGreaterThan( + -1, + ); + expect(sendRemember).toBeLessThan( + source.indexOf('navigate(`/cc-agent/${remoteSessionId}`', sendRemember), + ); + // 只落一次,别两处都落。 + expect(source.match(/rememberRecoverableHandoff\(remoteSessionId, 'message'/g)).toHaveLength( + 1, + ); + // 会话视图不再自己落副本(落在那里要等 historyLoaded,等于没落)。 + expect(sessionViewSource).not.toContain('rememberRecoverableHandoff('); + }); + + it('首条消息:等协同 → 经 deliver 发送 → 锁覆盖整条交接', () => { + const branch = messageBranch(); + const lock = branch.indexOf('if (holdComposer) setRemoteHandoffPreparing(true)'); + const awaitCollab = branch.indexOf('await consumePendingRemoteCollab(pending.remoteCollab'); + const send = branch.indexOf('sendMessage('); + const unlock = branch.indexOf('if (holdComposer) setRemoteHandoffPreparing(false)'); + + expect(lock).toBeGreaterThan(-1); + expect(awaitCollab).toBeLessThan(send); + // 解锁必须排在 sendMessage **之后**:提前解锁的话,命令派发那次 await 里 + // 用户补发的消息会抢在草稿提交的首条之前。 + expect(lock).toBeLessThan(awaitCollab); + expect(send).toBeLessThan(unlock); + // sendMessage 失败时 resolve false 而不抛错 —— 必须 await 且经 deliver 判定, + // 裸调 + 立刻丢副本会让正文从界面和磁盘上一起消失(codex P1 第五轮)。 + expect(branch).toContain('await deliverRecoverableHandoff(sessionId, () =>'); + expect(branch).toContain('sendMessage('); + }); + + it('新建目标:锁从消费一路盖到 setGoal 结束,setGoal 成功后才清副本', () => { + const branch = goalBranch(); + const lock = branch.indexOf('setRemoteHandoffPreparing(true)'); + const awaitSubscribe = branch.indexOf('await window.electronAPI.deviceLink.subscribe('); + // 归属走粘滞解析:非粘滞版在 relay 瞬断窗口会返回 undefined → 跳过订阅, + // 而 goalApiFor 仍按粘滞归属把 setGoal 发到被控端(greptile P1,不变量 #3)。 + expect(branch).toContain('const deviceId = getStickySessionDeviceId(sessionId);'); + const awaitCollab = branch.indexOf('await consumePendingRemoteCollab(pendingGoal.remoteCollab'); + const setGoal = branch.indexOf('await goalApiFor(sessionId).setGoal('); + const forget = branch.indexOf('deliverRecoverableHandoff(sessionId, async () => {'); + const unlock = branch.indexOf('setRemoteHandoffPreparing(false)'); + + // subscribe 与 setGoal 同样是隧道 invoke、同样可能 30s;锁必须把它们都包住。 + expect(lock).toBeLessThan(awaitSubscribe); + expect(lock).toBeLessThan(awaitCollab); + expect(awaitCollab).toBeLessThan(setGoal); + // setGoal 抛错时副本必须留着 → 它必须包在 deliver 的回调里(抛错就到不了 forget)。 + expect(forget).toBeLessThan(setGoal); + expect(setGoal).toBeLessThan(unlock); + }); + + it('删除副本只有一条路:deliverRecoverableHandoff', () => { + // 本 PR 的 review 里,"这算交付了吧"被三个调用点各自判断、各自判错过一次。 + // 收进 deliver 之后 forgetRecoverableHandoff 不再导出,裸调直接编译不过。 + expect(sessionViewSource).not.toContain('forgetRecoverableHandoff('); + expect(pendingHandoffSource).toContain('function forgetRecoverableHandoff('); + expect(pendingHandoffSource).not.toContain('export function forgetRecoverableHandoff('); + // 三处交接(命令派发 / 首轮发送 / 起目标)都要走它。 + expect(sessionViewSource.match(/deliverRecoverableHandoff\(sessionId,/g)).toHaveLength(3); + }); + + it('内存里没有 pending 时才走恢复,且只回填输入框、不自动补发', () => { + expect(sessionViewSource).toContain("restoreRecoverableHandoff('message')"); + expect(sessionViewSource).toContain("restoreRecoverableHandoff('goal')"); + // 恢复走 composer 草稿的既有外部写入通道,而不是偷偷再 sendMessage 一次。 + const restore = sessionViewSource.slice( + sessionViewSource.indexOf('const restoreRecoverableHandoff = useCallback('), + ); + const body = restore.slice(0, restore.indexOf('[sessionId, t]')); + expect(body).toContain('saveComposerDraft(sessionId,'); + expect(body).not.toContain('sendMessage('); + // 输入框已有内容时先让路,且必须在 take 之前判断 —— 否则副本已被取走, + // 让路就变成了直接丢弃。 + expect(body.indexOf('getComposerDraftPresence(sessionId)')).toBeLessThan( + body.indexOf('takeRecoverableHandoff(sessionId, kind)'), + ); + }); + }); }); diff --git a/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts b/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts index 585b6d3f1eb..d6895de377f 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerProjectPicker.test.ts @@ -36,6 +36,8 @@ const deviceLinkProjectsHookSource = readSource('hooks', 'useDeviceLinkProjects. const remoteSessionHandoffSource = readSource('features', 'cc-agent', 'remoteSessionHandoff.ts'); +const remoteCollabHandoffSource = readSource('features', 'cc-agent', 'remoteCollabHandoff.ts'); + const deviceSwitcherPillSource = readSource('components', 'new-chat', 'DeviceSwitcherPill.tsx'); const controllableDevicesHookSource = readSource('hooks', 'useControllableDevices.ts'); @@ -701,8 +703,11 @@ describe('Shared create project picker', () => { // 手写回流必须彻底消失,否则提交点后仍有可抛的一步。 expect(newMakerDraftRouteSource).not.toContain("'local-db:sessions:list'"); expect(newMakerDraftRouteSource).not.toContain('setDeviceSessions('); - // 组件也不该再自己 import 回流函数 —— 它只经 handoff 使用。 + // 组件也不该再自己 import 回流函数 —— 它只经共享 helper 使用。issue #1170 之后 + // 有第二个回流触发点(device-link 开协同后要把被控端刚建的 worker session 拉进 + // 镜像),它住在 remoteCollabHandoff 里,同样是 fire-and-forget、同样不在组件内联。 expect(newMakerDraftRouteSource).not.toContain('refreshRemoteDeviceSessions'); + expect(remoteCollabHandoffSource).toContain('void refreshRemoteDeviceSessions(p.deviceId)'); }); // #807 review 第十七轮:归属必须在**回流之前**登记。回流失败(gave-up / superseded)时镜像里 diff --git a/apps/desktop/src/renderer/__tests__/newMakerWorktreeSend.test.ts b/apps/desktop/src/renderer/__tests__/newMakerWorktreeSend.test.ts index c62a235730e..b46b5421eeb 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerWorktreeSend.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerWorktreeSend.test.ts @@ -77,11 +77,22 @@ describe('NewMakerDraftRoute worktree send flow', () => { 'const worktreePreparing = smoothedWorktreeCreating', rawDerive, ); - const sendGuard = sessionViewSource.indexOf('if (worktreePreparing) return false', worktreePreparing); + // sendGuard 现在读合并后的 sessionHandoffPreparing —— 「会话正在准备」多了一档 + // (device-link 远程交接,见 remoteHandoffPreparing),两档必须共用同一个 + // 下游判据,否则又是「同一语义两处判定」。worktree 这一档仍是它的组成项。 + const preparingMerge = sessionViewSource.indexOf( + 'const sessionHandoffPreparing = worktreePreparing || remoteHandoffPreparing;', + worktreePreparing, + ); + const sendGuard = sessionViewSource.indexOf( + 'if (sessionHandoffPreparing) return false', + preparingMerge, + ); const overlayLock = sessionViewSource.indexOf('worktreePreparing && smoothedBranchName', sendGuard); expect(hookSubscription).toBeGreaterThan(-1); expect(rawDerive).toBeGreaterThan(hookSubscription); + expect(preparingMerge).toBeGreaterThan(worktreePreparing); expect(worktreePreparing).toBeGreaterThan(rawDerive); expect(sendGuard).toBeGreaterThan(worktreePreparing); expect(overlayLock).toBeGreaterThan(sendGuard); diff --git a/apps/desktop/src/renderer/__tests__/orcaRemoteRoutingInvariants.test.ts b/apps/desktop/src/renderer/__tests__/orcaRemoteRoutingInvariants.test.ts index 2d1feaa8f8e..41553fa2b98 100644 --- a/apps/desktop/src/renderer/__tests__/orcaRemoteRoutingInvariants.test.ts +++ b/apps/desktop/src/renderer/__tests__/orcaRemoteRoutingInvariants.test.ts @@ -52,6 +52,45 @@ describe('orca 远程路由接线不变式', () => { expect(src).not.toContain('onOrcaWorkerChanged'); }); + // issue #1170 codex P2:协同入口与策略查询按**粘滞** remoteDeviceId 指向被控端,而 + // enable/disable 这两个 mutation 曾走非粘滞的 makerApiFor —— relay 瞬时重连清空注册表的 + // 窗口内会退回本机,在**控制端**建出或销毁一个 team(本机恰有同 id 会话时还操作错对象)。 + // 这是「同一语义在对称路径上的缺口」,两条必须同口径,所以一起锁。 + it('协同开关的 enable / disable 都用粘滞归属(makerApiForSticky),不用非粘滞 makerApiFor', () => { + const view = read('features/cc-agent/CCAgentSessionView.tsx'); + expect(view).toContain('makerApiForSticky(collabSessionId).enableOrca('); + expect(view).not.toContain('makerApiFor(collabSessionId).enableOrca('); + // 开启后的镜像回流也取粘滞值,否则瞬断窗口内解析成 undefined 会整段跳过, + // 被控端刚建的 worker 永远进不了控制端注册表。 + expect(view).toContain('getStickySessionDeviceId(collabSessionId)'); + + const stop = read('features/cc-agent/hooks/useStopOrcaCollab.ts'); + expect(stop).toContain('makerApiForSticky(leadSessionId).disableOrca('); + expect(stop).not.toContain('makerApiFor(leadSessionId).disableOrca('); + }); + + // 同一条不变量的第三处漏网(greptile P1 第五轮):远程草稿起目标时,「要不要订阅 + // session:」曾用非粘滞 getSessionDeviceId 判断,而真正发 setGoal 的 goalApiFor + // 走粘滞归属 —— 瞬断窗口内订阅被跳过、setGoal 照样发到被控端,目标首轮的 + // maker:event/status 推送就落在没有订阅者的窗口里。判据必须与执行端归属同口径。 + it('远程起目标的订阅判据与 setGoal 的归属同口径(都用粘滞)', () => { + const view = read('features/cc-agent/CCAgentSessionView.tsx'); + const goalConsumer = view.slice( + view.indexOf('const pendingGoal = consumePendingGoal(sessionId);'), + ); + const branch = goalConsumer.slice(0, goalConsumer.indexOf('const learnCardsRestoredRef')); + expect(branch).toContain('const deviceId = getStickySessionDeviceId(sessionId);'); + expect(branch).not.toContain('const deviceId = getSessionDeviceId(sessionId);'); + expect(branch).toContain('deviceLink.subscribe(deviceId,'); + expect(branch).toContain('goalApiFor(sessionId).setGoal('); + }); + + it('makerApiForSticky 住在传输层(归属判定只有一处可改)', () => { + const src = read('lib/makerTransport.ts'); + expect(src).toContain('export function makerApiForSticky('); + expect(src).toContain('getStickySessionDeviceId(sessionId)'); + }); + it('makerTransport 路由器本体仍持有本机分支(orcaWorkflowsFor 内部允许直连 + onOrcaWorkerChanged)', () => { const src = read('lib/makerTransport.ts'); expect(src).toContain('export function orcaWorkflowsFor('); diff --git a/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts b/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts index 1f77fcb881a..2271dafd229 100644 --- a/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts +++ b/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts @@ -210,11 +210,14 @@ describe('OrcaWorkflowRoute source invariants', () => { }); it('does not subscribe to project policy updates from the legacy Orca route', () => { + // eligible 的判据本体收敛进了 resolveCollabEntryPolicy(issue #1170:草稿与会话视图 + // 曾各写一份,同一个 device-link 项目两边给出相反答案)。这里守的仍是原来那件事 —— + // legacy /orca 路由(orcaMode)必须先被 `!orcaMode &&` 短路掉,否则它也会去跑项目 + // 策略查询并订阅刷新。 expect(sessionViewSource).toContain( - 'const collabPolicyEligible =\n' + - ' !orcaMode &&\n' + - " session?.orcaRole !== 'worker'", + 'const collabPolicyEligible = !orcaMode && collabEntry.eligible;', ); + expect(sessionViewSource).toContain('resolveCollabEntryPolicy({'); }); it('keeps /orca as a legacy compatibility redirect to the plain lead route', () => { @@ -266,10 +269,12 @@ describe('OrcaWorkflowRoute source invariants', () => { it('does not block collaboration tab opening on worker SDK bootstrap', () => { const requestEnable = sessionViewSource.indexOf('const requestEnableCollab = useCallback'); - // device-link:enableOrca 现在按 sessionId 来源路由(本机走本地 maker,远程走隧道), - // 调用形态从 window.electronAPI.maker.enableOrca 改成 makerApiFor(collabSessionId).enableOrca。 + // device-link:enableOrca 按 sessionId 来源路由(本机走本地 maker,远程走隧道), + // 调用形态从 window.electronAPI.maker.enableOrca 改成 makerApiFor*(collabSessionId).enableOrca。 + // 归属用**粘滞**版(makerApiForSticky):瞬断窗口内退回本机会在控制端建出 team, + // 与按粘滞 remoteDeviceId 渲染的入口自相矛盾(见 orcaRemoteRoutingInvariants 的对称守卫)。 const enableCall = sessionViewSource.indexOf( - 'await makerApiFor(collabSessionId).enableOrca', + 'await makerApiForSticky(collabSessionId).enableOrca', requestEnable, ); const openTab = sessionViewSource.indexOf( diff --git a/apps/desktop/src/renderer/__tests__/pendingHandoffRecovery.test.ts b/apps/desktop/src/renderer/__tests__/pendingHandoffRecovery.test.ts new file mode 100644 index 00000000000..6ea355b93b4 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/pendingHandoffRecovery.test.ts @@ -0,0 +1,264 @@ +/** + * pendingHandoffRecovery —— device-link 开协同交接的「可恢复副本」。 + * + * 背景(issue #1170,greptile P1):remoteCollab 这条路径 consumePending 之后还要 + * await 被控端起 Worker(慢设备可到 30s 隧道超时 + 6×3s 回查)。内存 Map 已经删了, + * 这段时间 app 被关掉正文就没有第二份。所以进等待前另存一份到 localStorage。 + * + * 本文件锁住这份副本的语义:只存正文、按 kind 各取各的、按归属人分命名空间、 + * 天级 TTL 兜底、localStorage 不可用时静默降级(绝不能把首轮发送本身弄失败)。 + * + * 项目 vitest env=node,无 window。沿用 newMakerDraft.test.ts 的做法, + * 用 vi.stubGlobal 注入最小 localStorage,而不是为这一个文件切 jsdom。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const STORAGE_KEY = 'xdt:pendingHandoffRecovery:v1'; + +class MemLocalStorage { + store = new Map(); + getItem(k: string): string | null { + return this.store.has(k) ? (this.store.get(k) as string) : null; + } + setItem(k: string, v: string): void { + this.store.set(k, v); + } + removeItem(k: string): void { + this.store.delete(k); + } + clear(): void { + this.store.clear(); + } +} + +let memStorage: MemLocalStorage; + +beforeEach(() => { + memStorage = new MemLocalStorage(); + vi.stubGlobal('window', { localStorage: memStorage }); + vi.stubGlobal('localStorage', memStorage); + vi.resetModules(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +async function loadModule() { + return await import('@/state/pendingFirstMessage'); +} + +describe('可恢复副本的基本语义', () => { + it('remember 后能 take 回来,且 take 即清除(不会重复恢复)', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', '写个登录页'); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBe('写个登录页'); + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + }); + + it('kind 不匹配时不取走 —— 首条消息与目标各自恢复各自的', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'goal', '把测试补全'); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + // 没被上一步顺手清掉。 + expect(m.takeRecoverableHandoff('s1', 'goal')).toBe('把测试补全'); + }); + + it('交付成功才丢副本,且只影响目标会话', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', 'a'); + m.rememberRecoverableHandoff('s2', 'message', 'b'); + + await expect(m.deliverRecoverableHandoff('s1', () => true)).resolves.toBe(true); + // 幂等:再交付一次不会误伤别的会话。 + await expect(m.deliverRecoverableHandoff('s1', () => true)).resolves.toBe(true); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + expect(m.takeRecoverableHandoff('s2', 'message')).toBe('b'); + }); + + it('空正文不落盘 —— 免得回填出一个空输入框还弹个提示', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', ''); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + expect(memStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('全部取走后不留空对象残留在 localStorage', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', 'a'); + m.takeRecoverableHandoff('s1', 'message'); + + expect(memStorage.getItem(STORAGE_KEY)).toBeNull(); + }); +}); + +describe('交付语义:只有确认交付成功才丢副本', () => { + it('deliver resolve false(设备离线 / 访问被撤销 / 远端 enqueue 拒绝)→ 保留副本', async () => { + // 这是 codex P1 的要害:sendMessage 这类失败**不抛错**,而是 resolve false, + // 并且对远程会话还会把乐观气泡从 transcript 里撤掉。不看返回值就丢副本, + // 等于正文从界面和磁盘上同时消失。 + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', '没发出去的话'); + + await expect(m.deliverRecoverableHandoff('s1', () => false)).resolves.toBe(false); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBe('没发出去的话'); + }); + + it('deliver 抛错 → 保留副本,且错误照常向上冒泡', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'goal', '没起成的目标'); + + await expect( + m.deliverRecoverableHandoff('s1', async () => { + throw new Error('[DEVICE_LINK_NOT_CONNECTED] link down'); + }), + ).rejects.toThrow('DEVICE_LINK_NOT_CONNECTED'); + + // 调用方的 catch 负责提示;副本留着,下次进这个会话回填。 + expect(m.takeRecoverableHandoff('s1', 'goal')).toBe('没起成的目标'); + }); + + it('deliver resolve true → 丢副本', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', '发出去了'); + + await expect(m.deliverRecoverableHandoff('s1', async () => true)).resolves.toBe(true); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + }); +}); + +describe('跨重启与命名空间', () => { + it('副本活在 localStorage 里 —— 重置模块(模拟重启)后仍读得到', async () => { + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', '重启前没发出去的话'); + + // 重置模块内存态,memStorage 不清:等价于渲染进程重新起来。 + vi.resetModules(); + const fresh = await loadModule(); + + expect(fresh.takeRecoverableHandoff('s1', 'message')).toBe('重启前没发出去的话'); + }); + + it('按数据归属人分命名空间 —— 换账号读不到上一个账号的正文', async () => { + const m = await loadModule(); + m.setPendingHandoffOwner('owner-a'); + m.rememberRecoverableHandoff('s1', 'message', 'A 的内容'); + + m.setPendingHandoffOwner('owner-b'); + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + + m.setPendingHandoffOwner('owner-a'); + expect(m.takeRecoverableHandoff('s1', 'message')).toBe('A 的内容'); + }); + + it('超过 TTL 的残留在下次读取时被丢弃(导航失败的孤儿不无限堆积)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', '很久以前'); + + vi.setSystemTime(new Date('2026-01-09T00:00:00Z')); // +8 天 > 7 天 TTL + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + }); + + it('过期项必须真的从磁盘上消失,而不只是读的时候过滤掉', async () => { + // 只过滤不写回的话,这个账号只要不再写新交接项,正文就永远赖在 localStorage 里, + // 与声明的 TTL 和「持久数据要有明确生命周期」不符。 + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', '敏感的半句话'); + expect(memStorage.getItem(STORAGE_KEY)).toContain('敏感的半句话'); + + vi.setSystemTime(new Date('2026-01-09T00:00:00Z')); + // 只是读一次(哪怕读的是别的会话),过期正文就该被清出磁盘。 + m.takeRecoverableHandoff('other-session', 'message'); + + expect(memStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('过期项被清掉时不牵连同表里仍在有效期内的条目', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const m = await loadModule(); + m.rememberRecoverableHandoff('old', 'message', '旧的'); + + vi.setSystemTime(new Date('2026-01-09T00:00:00Z')); + m.rememberRecoverableHandoff('fresh', 'message', '新的'); + + const raw = memStorage.getItem(STORAGE_KEY) ?? ''; + expect(raw).not.toContain('旧的'); + expect(raw).toContain('新的'); + expect(m.takeRecoverableHandoff('fresh', 'message')).toBe('新的'); + }); + + it('损坏的整份 JSON 会被覆盖掉,不留在磁盘上', async () => { + const m = await loadModule(); + memStorage.setItem(STORAGE_KEY, '{ not json'); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + expect(memStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('TTL 之内的正常恢复', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + const m = await loadModule(); + m.rememberRecoverableHandoff('s1', 'message', '昨天的'); + + vi.setSystemTime(new Date('2026-01-02T00:00:00Z')); + expect(m.takeRecoverableHandoff('s1', 'message')).toBe('昨天的'); + }); +}); + +describe('降级:副本是尽力而为,不能反过来弄坏发送', () => { + it('localStorage 写失败(配额满 / 私密窗口)不抛错', async () => { + const m = await loadModule(); + const spy = vi.spyOn(memStorage, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + + expect(() => m.rememberRecoverableHandoff('s1', 'message', 'x')).not.toThrow(); + + spy.mockRestore(); + }); + + it('localStorage 读失败不抛错,按"没有副本"处理', async () => { + const m = await loadModule(); + const spy = vi.spyOn(memStorage, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + + spy.mockRestore(); + }); + + it('schema 损坏(手改 / 老版本)静默回退,不炸也不返回垃圾', async () => { + const m = await loadModule(); + + memStorage.setItem(STORAGE_KEY, '{ not json'); + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + + memStorage.setItem(STORAGE_KEY, JSON.stringify(['not', 'an', 'object'])); + expect(m.takeRecoverableHandoff('s1', 'message')).toBeNull(); + + // 字段类型不对的条目被逐条剔除,不影响同表里的合法条目。 + memStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + bad: { kind: 'message', text: 42, createdAt: Date.now() }, + good: { kind: 'message', text: 'ok', createdAt: Date.now() }, + }), + ); + expect(m.takeRecoverableHandoff('bad', 'message')).toBeNull(); + expect(m.takeRecoverableHandoff('good', 'message')).toBe('ok'); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/remoteCollabHandoff.test.ts b/apps/desktop/src/renderer/__tests__/remoteCollabHandoff.test.ts new file mode 100644 index 00000000000..126a74f9859 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/remoteCollabHandoff.test.ts @@ -0,0 +1,170 @@ +/** + * remoteCollabHandoff:device-link 远程开启协同的收尾语义。 + * + * 核心不变量(issue #1170 codex 两轮 P1): + * · 隧道超时**不是**权威失败 —— 超时只删掉控制端的等待项,被控端那次 enableOrca 仍在跑。 + * 把它当失败直接放行,会让「被控端起 Worker 慢了几秒」变成「用户明确开了协同,首轮却以 + * 普通单会话跑」。所以超时后要回查被控端 DB 的权威终态再定性。 + * · 回查查不到就 fail-closed 抛原始超时,绝不把「没建成」猜成「建成了」。 + * · 镜像回流始终 fire-and-forget,且排在定性之后。 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const refreshRemoteDeviceSessions = vi.fn().mockResolvedValue('ok'); +vi.mock('@/features/device-link/refreshRemoteSessions', async (importOriginal) => ({ + // 只桩掉会真的发 IPC 的回流函数;瞬态判据用**真实实现** —— 回查的重试口径 + // 复用的就是 device-link 那份判据,桩一个等价物等于测了个假的。 + ...(await importOriginal()), + refreshRemoteDeviceSessions: (...args: unknown[]) => refreshRemoteDeviceSessions(...args), +})); +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})); + +const enableOrca = vi.fn(); +const listWorkersByLead = vi.fn(); +vi.mock('@/lib/makerTransport', () => ({ + makerApiForDevice: () => ({ enableOrca: (...a: unknown[]) => enableOrca(...a) }), + orcaWorkflowsForDevice: () => ({ listWorkersByLead: (...a: unknown[]) => listWorkersByLead(...a) }), +})); + +import { enableRemoteCollabForSession } from '@/features/cc-agent/remoteCollabHandoff'; + +const params = { + deviceId: 'dev-1', + leadSessionId: 'lead-1', + options: { workerAgent: 'codex' as const }, + logTag: 'test', +}; + +const timeoutError = () => new Error('[DEVICE_LINK_TIMEOUT] waiting for remote response'); + +beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('enableRemoteCollabForSession', () => { + it('成功路径:回传 worker session,并 fire-and-forget 刷镜像', async () => { + enableOrca.mockResolvedValue({ workerSessionId: 'worker-1' }); + + await expect(enableRemoteCollabForSession(params)).resolves.toEqual({ + focusWorkerSessionId: 'worker-1', + }); + expect(refreshRemoteDeviceSessions).toHaveBeenCalledWith('dev-1'); + // 成功路径不该去回查:enableOrca 返回即代表被控端 DB 已提交。 + expect(listWorkersByLead).not.toHaveBeenCalled(); + }); + + it('权威失败(如 PRECONDITION_FAILED):原样抛出,不回查', async () => { + enableOrca.mockRejectedValue(new Error('[PRECONDITION_FAILED] collaboration is disabled')); + + await expect(enableRemoteCollabForSession(params)).rejects.toThrow('PRECONDITION_FAILED'); + // 被控端明确拒绝了,回查毫无意义 —— 也不能因为回查恰好读到别的 team 就翻成成功。 + expect(listWorkersByLead).not.toHaveBeenCalled(); + expect(refreshRemoteDeviceSessions).toHaveBeenCalledWith('dev-1'); + }); + + it('隧道超时 + 被控端其实已建成:回查到 worker 后照成功返回,不误报失败', async () => { + enableOrca.mockRejectedValue(timeoutError()); + listWorkersByLead.mockResolvedValue([{ sessionId: 'worker-late', id: 'w1' }]); + + await expect(enableRemoteCollabForSession(params)).resolves.toEqual({ + focusWorkerSessionId: 'worker-late', + }); + expect(listWorkersByLead).toHaveBeenCalledWith('lead-1'); + }); + + it('隧道超时 + 被控端确实没建成:fail-closed 抛原始超时', async () => { + enableOrca.mockRejectedValue(timeoutError()); + listWorkersByLead.mockResolvedValue([]); + + // 回查之间有真实退避。用 fake timers 快进,而不是让用例真睡满 —— 真睡会逼近 + // vitest 默认 5s 超时,在慢 runner 上变成 flake(本 PR 已被同类超时 flake 咬过一次)。 + vi.useFakeTimers(); + const pending = enableRemoteCollabForSession(params); + const assertion = expect(pending).rejects.toThrow('DEVICE_LINK_TIMEOUT'); + await vi.advanceTimersByTimeAsync(30_000); + await assertion; + // 有限次回查后放弃,不无限等待(被控端可能永远不返回,无界等待会把首轮永久挂起)。 + expect(listWorkersByLead).toHaveBeenCalledTimes(6); + }); + + it('回查撞上瞬态错误:用完剩余重试预算,不因链路抖一下就判定没建成', async () => { + // 触发回查的前提就是链路刚抖过(enableOrca 超时),第一次回查撞上同一段抖动是常态。 + // 头两次瞬态失败,第三次读到 worker —— 必须能恢复成功,不能在第一次就放弃。 + enableOrca.mockRejectedValue(timeoutError()); + listWorkersByLead + .mockRejectedValueOnce(new Error('[DEVICE_LINK_NOT_CONNECTED] link down')) + .mockRejectedValueOnce(new Error('[DEVICE_LINK_TIMEOUT] probe timed out')) + .mockResolvedValue([{ sessionId: 'worker-late', id: 'w1' }]); + + vi.useFakeTimers(); + const pending = enableRemoteCollabForSession(params); + const assertion = expect(pending).resolves.toEqual({ focusWorkerSessionId: 'worker-late' }); + await vi.advanceTimersByTimeAsync(30_000); + await assertion; + expect(listWorkersByLead).toHaveBeenCalledTimes(3); + }); + + it('探针自身黑洞时按总 deadline 收尾,不把 composer 锁上几分钟', async () => { + // 链路「可连但每个 invoke 都黑洞」:每次回查自己要走满 30s 隧道超时。只限次数的话 + // 6 次串行 ≈ 3 分钟,而这段时间 composer 是锁住的、首轮压着不发(codex P2 第五轮)。 + enableOrca.mockRejectedValue(timeoutError()); + listWorkersByLead.mockImplementation( + () => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('[DEVICE_LINK_TIMEOUT] probe black-holed')), 30_000); + }), + ); + + vi.useFakeTimers(); + const pending = enableRemoteCollabForSession(params); + const assertion = expect(pending).rejects.toThrow('DEVICE_LINK_TIMEOUT'); + // 推进远超 6×(30s+3s) 的时间,确认它早就按 deadline 停了而不是跑满次数。 + await vi.advanceTimersByTimeAsync(200_000); + await assertion; + // 30s deadline 内只来得及发出一次探针(它自己就耗满 30s),第二轮进不去。 + expect(listWorkersByLead).toHaveBeenCalledTimes(1); + }); + + it('回查全程瞬态失败:预算用尽后仍 fail-closed 抛原始超时', async () => { + enableOrca.mockRejectedValue(timeoutError()); + listWorkersByLead.mockRejectedValue(new Error('[DEVICE_LINK_NOT_CONNECTED] link down')); + + vi.useFakeTimers(); + const pending = enableRemoteCollabForSession(params); + const assertion = expect(pending).rejects.toThrow('DEVICE_LINK_TIMEOUT'); + await vi.advanceTimersByTimeAsync(30_000); + await assertion; + expect(listWorkersByLead).toHaveBeenCalledTimes(6); + }); + + it('回查撞上永久错误(老被控端没有该 channel):立即降级,不空转剩余轮次', async () => { + enableOrca.mockRejectedValue(timeoutError()); + listWorkersByLead.mockRejectedValue( + new Error('[DEVICE_LINK_CHANNEL_NOT_ALLOWED] channel not allowed'), + ); + + await expect(enableRemoteCollabForSession(params)).rejects.toThrow('DEVICE_LINK_TIMEOUT'); + // 重试多少次都是同一个结果,再等 18 秒毫无意义。 + expect(listWorkersByLead).toHaveBeenCalledTimes(1); + }); + + it('回查撞上未知错误:按不可重试处理,不空转', async () => { + enableOrca.mockRejectedValue(timeoutError()); + listWorkersByLead.mockRejectedValue(new Error('something entirely unexpected')); + + await expect(enableRemoteCollabForSession(params)).rejects.toThrow('DEVICE_LINK_TIMEOUT'); + expect(listWorkersByLead).toHaveBeenCalledTimes(1); + }); + + it('镜像回流失败不影响返回值(fire-and-forget,不 await)', async () => { + enableOrca.mockResolvedValue({ workerSessionId: 'worker-1' }); + refreshRemoteDeviceSessions.mockRejectedValueOnce(new Error('tunnel closed')); + + await expect(enableRemoteCollabForSession(params)).resolves.toEqual({ + focusWorkerSessionId: 'worker-1', + }); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts b/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts index c9fa74b4275..3971fd3e5dc 100644 --- a/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts +++ b/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts @@ -69,7 +69,11 @@ describe('CCAgentSessionView 接线不变式', () => { it('断线缓存的远程 session 可打开查看,但禁用 composer 并拦截发送', () => { expect(sessionViewSrc).toContain("const remoteSessionUnavailable = remoteConn === 'reconnecting' || remoteConn === 'host-offline'"); expect(sessionViewSrc).toContain('if (remoteSessionUnavailable) return false'); - expect(sessionViewSrc).toContain('disabled={remoteSessionUnavailable}'); + // device-link 远程交接期间也要禁用(见 remoteHandoffPreparing):那几段 await + // 可能数十秒,不禁用的话用户补发的消息会插到草稿提交的首条之前。断线这一档不变。 + expect(sessionViewSrc).toContain( + 'disabled={remoteSessionUnavailable || remoteHandoffPreparing}', + ); }); it('断线缓存的远程 session 可查看,但生命周期/元数据写操作必须走统一 gate', () => { expect(sessionHeaderSrc).toContain('remoteSessionUnavailable || isRemoteSessionWriteBlocked(session)'); diff --git a/apps/desktop/src/renderer/contexts/AuthContext.tsx b/apps/desktop/src/renderer/contexts/AuthContext.tsx index 2fa1776c653..89a38c8dc8c 100644 --- a/apps/desktop/src/renderer/contexts/AuthContext.tsx +++ b/apps/desktop/src/renderer/contexts/AuthContext.tsx @@ -32,6 +32,7 @@ import { isSidebarWindow } from '@/lib/sidebarWindow'; import { isGhostPanelWindow } from '@/lib/ghostPanelWindow'; import { setNewMakerDraftOwner } from '@/state/newMakerDraft'; import { setComposerDraftOwner } from '@/lib/composerDraftStore'; +import { setPendingHandoffOwner } from '@/state/pendingFirstMessage'; /** * 登录态上下文:user / isAuthenticated / isCanary / deviceId 全部来自 main 的 @@ -119,6 +120,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { activeDataOwnerIdRef.current = state.dataOwnerId; setNewMakerDraftOwner(state.dataOwnerId); setComposerDraftOwner(state.dataOwnerId); + setPendingHandoffOwner(state.dataOwnerId); setUserPromptOwner(state.dataOwnerId); if (ownerChanged) { setMemorySettingsOwner(state.dataOwnerId); @@ -277,6 +279,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const enterLocalMode = useCallback(async () => { const state = await authServiceRef.current!.enterLocalMode(); setComposerDraftOwner(state.dataOwnerId); + setPendingHandoffOwner(state.dataOwnerId); setMode(state.mode); setDataOwnerId(state.dataOwnerId); setCanEnterApp(state.canEnterApp); @@ -287,6 +290,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const exitLocalMode = useCallback(async () => { const state = await authServiceRef.current!.exitLocalMode(); setComposerDraftOwner(state.dataOwnerId); + setPendingHandoffOwner(state.dataOwnerId); setMode(state.mode); setDataOwnerId(state.dataOwnerId); setCanEnterApp(state.canEnterApp); diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index 9615354eee4..553ddb43070 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -144,7 +144,18 @@ import { extractIpcError } from '@/utils/ipcError'; import { listActiveRunsForSession } from '@/features/learn/useLearnRun'; import { subscribeLearnEvents } from '@/features/learn/learnTransport'; import { getUserPrompt } from '@/lib/userPromptStore'; -import { consumePending, consumePendingGoal } from '@/state/pendingFirstMessage'; +import { + consumePending, + consumePendingGoal, + deliverRecoverableHandoff, + takeRecoverableHandoff, + type RecoverableHandoffKind, +} from '@/state/pendingFirstMessage'; +import { + saveDraft as saveComposerDraft, + getDraftPresence as getComposerDraftPresence, + plainTextToTiptapDoc, +} from '@/lib/composerDraftStore'; import { setLastWorkingDir } from '@/state/lastWorkingDir'; import { consumeComposerMentionDrop } from '@/lib/composerDrop'; import { @@ -159,6 +170,8 @@ import { } from '@/lib/fileDrop'; import { getCollaborationStartErrorMessage } from './collaborationErrors'; import { useCollabProjectPolicy } from './hooks/useCollabProjectPolicy'; +import { resolveCollabEntryPolicy } from './collabEntryPolicy'; +import { consumePendingRemoteCollab } from './remoteCollabHandoff'; import { shouldFallbackVendorModel } from './lib/vendorModelFallback'; import { localizeAgentStatus } from './lib/localizeAgentStatus'; import { createSessionRefreshSequence } from './lib/sessionRefreshSequence'; @@ -202,8 +215,12 @@ import { ackInterruptedTurnFor, goalApiFor, makerApiFor, + makerApiForSticky, orcaWorkflowsFor, } from '@/lib/makerTransport'; +// 协同 mutation 的归属取粘滞值(见 makerApiForSticky):瞬断窗口内误判本机会在控制端 +// 建出/销毁 team,而入口本身是按粘滞 remoteDeviceId 渲染的。 +import { getStickySessionDeviceId } from '@/features/device-link/stickySessionOrigin'; // fork / orca 在被控端建新 session 后,navigate 前先把该设备会话列表重拉进 store(避免 404 破窗)。 import { refreshRemoteDeviceSessions } from '@/features/device-link/refreshRemoteSessions'; import { @@ -645,23 +662,28 @@ export function CCAgentSessionView({ // 解析当前会话所属远程设备 id。store 有 origin 时取 store 值(随 store 变化更新,useMemo 让同设备 // 返回同一字符串,避免下游 effect 无谓重跑)。 const wasRemoteSessionRef = useRef(false); - const lastRemoteDeviceIdRef = useRef(undefined); const lastRemoteSessionIdRef = useRef(undefined); const storeRemoteDeviceId = useMemo( () => (sessionId ? getSessionDeviceId(sessionId) : undefined), [sessionId, remoteProjectSessions], ); - // 切会话:在 render 阶段同步重置粘滞值(不靠 effect,避免新会话首帧误用上个会话的归属)。 + // 切会话:在 render 阶段同步重置"曾是远程会话"标记(不靠 effect,避免新会话首帧误判)。 if (lastRemoteSessionIdRef.current !== sessionId) { lastRemoteSessionIdRef.current = sessionId; - lastRemoteDeviceIdRef.current = undefined; wasRemoteSessionRef.current = false; } - if (storeRemoteDeviceId !== undefined) lastRemoteDeviceIdRef.current = storeRemoteDeviceId; // 粘滞:本机 relay 瞬时重连会 clear() 掉镜像(含当前会话 origin)。此时回退到最后已知 deviceId, // 让当前远程会话在重连窗口内不被判成"已结束"——视图保留、RemoteSessionBanner 显示「重连中」、 // 同步引擎仍绑定该设备(relay 回 online 自动重订阅 + 对账),子组件继续按远程处理。 - const remoteDeviceId = storeRemoteDeviceId ?? lastRemoteDeviceIdRef.current; + // + // 走共享的 stickySessionOrigin,而不是本视图自己记一份 ref(greptile P1):那份模块级缓存 + // 正是 makerApiForSticky 等消费方读的同一份,而它只在被查询时预热。视图各记各的,会出现 + // 「视图这份热了、模块那份还是冷的」—— 用户在**首次开启协同之前**撞上 relay 瞬断, + // makerApiForSticky 就会退回本机,在控制端建出 team。一份缓存,不会有两份各自预热的问题。 + const remoteDeviceId = useMemo( + () => (sessionId ? getStickySessionDeviceId(sessionId) : undefined), + [sessionId, remoteProjectSessions], + ); // device-link 远程会话:重 topic 订阅(含 WS 重连 / 被控端回在线时重建)+ 消息对账触发 // (重连 / presence / turn 结束 / 窗口聚焦 / 手动)。修「控制端丢消息」—— 以被控端为准重新同步。 // 本机会话(remoteDeviceId 为 undefined)整体 no-op。resync 供连接 banner 的「重新同步」按钮用。 @@ -757,7 +779,7 @@ export function CCAgentSessionView({ wasRemoteSessionRef.current = true; return; } - const dev0 = lastRemoteDeviceIdRef.current; + const dev0 = getStickySessionDeviceId(sessionId); const decision = decideRemoteSessionExit({ hasOrigin: false, wasRemote: wasRemoteSessionRef.current, @@ -1539,6 +1561,19 @@ export function CCAgentSessionView({ // overlay 渲染) 全部统一从这一个值读, 保证语义一致 (overlay 在 = 输入禁用)。 const worktreePreparing = smoothedWorktreeCreating; + // device-link 远程草稿的交接要等隧道往返(见下方 pending 消费):开协同要等被控端起 + // Worker,起目标另有 subscribe 与 setGoal —— 每一段都可能走到 30s 超时。期间会话看起来 + // 是空的,用户很容易以为没发出去而再打一条:那条会先进 Lead,草稿提交的首条反而排到它 + // 后面,消息顺序倒置,首轮还可能在协同尚未就绪时跑掉(codex review P2 ×2)。 + // 所以按 worktree 创建同款处理:交接**全程**锁住发送,而不只是开协同那一段。 + // + // 命名刻意不叫 remoteCollabPreparing —— 它现在覆盖的是整条远程交接(含没开协同的 + // 起目标路径),叫 collab 会让下一个人以为只在开协同时为真。 + // 与 worktreePreparing 合成一个 sessionHandoffPreparing,下游只读这一个值,避免两个 + // "会话正在准备"的判据各自接一半闸门。 + const [remoteHandoffPreparing, setRemoteHandoffPreparing] = useState(false); + const sessionHandoffPreparing = worktreePreparing || remoteHandoffPreparing; + // --------------------------------------------------------------------------- // F-AUQ-MIN-1 / F-AUQ-MIN-5 验收第 4 条:会话切换后 askUserViewerState 重置为 'expanded'。 // makerChatStore 按 sessionId 分片存储,A(minimized)→B→A 回到 A 时如果不重置, @@ -1811,25 +1846,35 @@ export function CCAgentSessionView({ sessionId, shouldFirstFrameRevealOrcaWorkers, ]); - // Lead 允许 Claude / Codex 本地项目会话走 toggle。Codex 的 MCP bridge 通过 - // threadId -> business sessionId 映射在工具调用时恢复 per-session ctx; - // 远端会话 (codex / cc) 经 SSH remote-forward 直连本机 MCP bridge,worker - // 创建继承 remoteHostId,两端协同均已接通。 + // Lead 允许 Claude / Codex 项目会话走 toggle,判定与新建草稿共用 + // resolveCollabEntryPolicy(issue #1170:两处各写一份判据,同一个 device-link 项目在 + // 草稿里没入口、进会话页又冒出来)。Codex 的 MCP bridge 通过 threadId -> business + // sessionId 映射在工具调用时恢复 per-session ctx;SSH 远端会话 (codex / cc) 经 + // remote-forward 直连本机 MCP bridge、worker 创建继承 remoteHostId;device-link 会话的 + // Lead / Worker / team 真身都在被控端,enableOrca 与团队读写经隧道路由过去 —— + // 三类都已接通,不再按 agent 或远端形态限流。 // 注意:doc rail (isCompactRail) 也允许显示 toggle —— WorkdirBrowseRoute 已经 // 针对 Lead session 接入了 OrcaSplitView toggle 布局,普通 session 必须能从 // ChatInput 工具行启用协同变成 Lead,否则 doc 模式下首次开启入口完全没有。 // 工具行同时传 denseToolbar=true,协同 pill 自动收成 icon-only,窄 rail 视觉 OK。 - const collabPolicyEligible = - !orcaMode && - session?.orcaRole !== 'worker' && - // 远端会话 codex 与 cc 都已接通协同(worker 创建继承 remoteHostId, - // 远端 agent 经 SSH remote-forward 直连本机 MCP bridge),不再按 agent 限流。 - session?.workspaceKind === 'project' && - !!session?.workingDir; + const collabEntry = resolveCollabEntryPolicy({ + workspaceKind: session?.workspaceKind, + workingDir: session?.workingDir, + orcaRole: session?.orcaRole, + remoteHostId: session?.remoteHostId, + // 粘滞归属:relay 瞬时重连清空注册表的窗口内不把远程会话误判成本机 —— 误判会让 + // 协同策略退回查控制端本机,读到的是另一台机器的开关。 + deviceLinkDeviceId: remoteDeviceId, + }); + const collabPolicyEligible = !orcaMode && collabEntry.eligible; const collabPolicy = useCollabProjectPolicy(session?.workingDir, collabPolicyEligible, { - // 远端会话的 workingDir 是远端路径, 跳过项目级查询; 用户级/全局级 collab + // SSH 远端会话的 workingDir 是远端主机路径, 跳过项目级查询; 用户级/全局级 collab // 开关仍生效 (与 main 侧 remote 分支同口径)。 - skipQuery: !!session?.remoteHostId, + skipQuery: collabEntry.skipProjectQuery, + // device-link 会话:项目级开关的真相在被控端(那里 enable-orca 是本地会话, 走的正是 + // 本机项目级分支)。控制端拿被控端的路径查自己本机只会读到自己的用户级开关, 可能与 + // 被控端 main 的授权相反 —— 于是入口看着能点、真开时被拒(issue #1170)。 + deviceId: collabEntry.policyDeviceId ?? null, }); const allowCollabToggle = !orcaMode && collabPolicyEligible; // 把 sessionId 抽出来给 useEffect 用 (linter 偏好稳定的标量依赖) @@ -1936,7 +1981,10 @@ export function CCAgentSessionView({ const worker: 'cc' | 'codex' = workerAgent === 'codex' ? 'codex' : 'cc'; setCollabWorker(worker); setCreateWorkerOpen(false); - await makerApiFor(collabSessionId).enableOrca(collabSessionId, { + // 粘滞归属(codex review P2):入口与协同策略查询都按粘滞 remoteDeviceId 指向被控端, + // mutation 必须同口径 —— 非粘滞的 makerApiFor 在 relay 瞬断窗口内会退回本机 + // enableOrca,在**控制端**建出一个 team(本机恰有同 id 会话时还会操作错对象)。 + await makerApiForSticky(collabSessionId).enableOrca(collabSessionId, { workerAgent, role: form.role, label: createWorkerLabel(form.role, []), @@ -1951,7 +1999,9 @@ export function CCAgentSessionView({ void sessionsStore.forceRefresh('active'); // 远程会话:enableOrca 在被控端起了 worker session,先把该设备会话列表重拉进 store // (注册 worker sessionId),否则 orca split 视图按 ?worker= 加载会 404。 - const orcaDeviceId = getSessionDeviceId(collabSessionId); + // 归属同样取粘滞值:上面这次 enableOrca 已经按粘滞路由发到了被控端,这里若用非粘滞 + // 判定会在瞬断窗口内解析成 undefined、跳过回流,worker 永远进不了控制端注册表。 + const orcaDeviceId = getStickySessionDeviceId(collabSessionId); if (orcaDeviceId) await refreshRemoteDeviceSessions(orcaDeviceId); await revealWorkersTab; } catch (err) { @@ -2319,7 +2369,9 @@ export function CCAgentSessionView({ // Popover open → prevent re-entry if (folderPickerOpen) return false; - if (worktreePreparing) return false; + // 会话交接尚未完成(建 worktree / 远程开协同)时不放行:否则新输入会插到 + // 草稿提交的首条之前,顺序倒置。 + if (sessionHandoffPreparing) return false; // ② Working directory check if (!session?.workingDir) { @@ -2396,7 +2448,7 @@ export function CCAgentSessionView({ vendorAuthGate, remoteDeviceId, remoteSessionUnavailable, - worktreePreparing, + sessionHandoffPreparing, ], ); @@ -2573,6 +2625,34 @@ export function CCAgentSessionView({ .catch((err) => log.warn('vendor fallback patch failed:', err)); }, [isCodex, providers, refreshServerSession, sessionAgentKind, sessionId, sessionModel]); + // 远程协同交接被 app 关闭打断时的兜底:把上次没能发出去的正文回填到输入框。 + // 只回填、不自动补发(理由见 pendingFirstMessage 的「可恢复副本」注释)。 + // 内存里还有 pending 时不该走这里 —— 那是正常交接,由下面的消费逻辑负责。 + const handoffRestoredRef = useRef(null); + const restoreRecoverableHandoff = useCallback( + (kind: RecoverableHandoffKind) => { + if (!sessionId) return; + const restoreKey = `${sessionId}:${kind}`; + if (handoffRestoredRef.current === restoreKey) return; + // 输入框已经有内容时不动它:用户自己敲的东西优先级永远高于恢复。 + // 此时**不取走**副本,留给下一次输入框为空时再回填 —— 宁可晚一点恢复, + // 也不能为了恢复把用户正在写的东西覆盖掉。 + if (getComposerDraftPresence(sessionId)) return; + const text = takeRecoverableHandoff(sessionId, kind); + if (text === null) return; + handoffRestoredRef.current = restoreKey; + // 非 silent:挂载中的 ChatInput 要靠这次 notify 把正文 setContent 进编辑器 + // (与 rewind / fork 预填同一条既有通道)。 + saveComposerDraft(sessionId, { text: plainTextToTiptapDoc(text), attachments: [] }); + toast.info( + kind === 'goal' + ? t('newChat.collaboration.handoffRecoveredGoal') + : t('newChat.collaboration.handoffRecoveredMessage'), + ); + }, + [sessionId, t], + ); + // delayed-create:从 NewMakerDraftRoute 经 navigate 进来的首条消息,在 session // 完全 hydrate(historyLoaded + workingDir 就位)后自动 sendMessage。 // 一次性消费 + ref guard,防 StrictMode 双 mount / 重渲染时重复发送。 @@ -2583,42 +2663,95 @@ export function CCAgentSessionView({ if (!workingDir) return; if (pendingConsumedRef.current) return; const pending = consumePending(sessionId); - if (!pending) return; + if (!pending) { + // 内存里没有 pending:可能本来就没有,也可能上次协同等待期间 app 被关掉了。 + restoreRecoverableHandoff('message'); + return; + } pendingConsumedRef.current = true; void (async () => { - if (await maybeDispatchDesktopSlashCommand(pending.text, pending.files)) { - return; + // device-link 草稿开了协同:先把协同开起来,再发首轮 —— 否则 Lead 的第一个 turn + // 拿不到 cindy_orca 工具。等待放在这里而不是 draft route,是为了不让「对端会话已 + // 建好、用户输入还只在内存里」的窗口跟着一次可能 30s 的隧道往返一起变长(见 + // remoteCollabHandoff 文件头)。开不起来时如实提示并照单会话继续。 + // 副本已在草稿路由登记 pending 的同一刻落下(见那里的注释),这里不再重复落 —— + // 落在这里等于要求 effect 先跑起来,而这条 effect 要等 historyLoaded。 + // + // 锁要覆盖**整条交接**(消费 pending → 首轮发出),不能只包住开协同那段 await: + // 解锁后到 sendMessage 之间还有一次 await(命令派发),那个窗口里用户补发的消息 + // 会抢在草稿提交的首条之前。远程交接才上锁 —— 本机交接没有远程等待。 + const holdComposer = !!pending.remoteCollab; + if (holdComposer) setRemoteHandoffPreparing(true); + try { + if (pending.remoteCollab) { + const ok = await consumePendingRemoteCollab(pending.remoteCollab, { + leadSessionId: sessionId, + logTag: 'pending first message', + onFailed: (err) => + toast.error( + getCollaborationStartErrorMessage(err, t, { + remoteDevice: true, + continueAsSingleSession: true, + }), + ), + }); + if (ok) { + void sessionsStore.forceRefresh('active'); + void revealOrcaWorkersTab(sessionId).catch((revealErr) => { + log.warn('revealOrcaWorkersTab after pending collab failed', revealErr); + }); + } + } + // 三处交接统一走 deliverRecoverableHandoff:交付成功才丢副本, + // resolve false / 抛错都保留(见该函数注释)。 + const dispatched = await deliverRecoverableHandoff(sessionId, () => + maybeDispatchDesktopSlashCommand(pending.text, pending.files), + ); + if (dispatched) return; + // 必须 await:sendMessage 在设备离线 / 访问被撤销 / 远端 enqueue 拒绝时不抛错, + // 而是 resolve false —— 不等它就丢副本,正文会从界面和磁盘上一起消失(codex P1)。 + await deliverRecoverableHandoff(sessionId, () => + sendMessage( + pending.text, + session.model, + session.effort as Effort, + session.permissionMode as PermissionMode, + workingDir, + pending.files, + pending.mentions, + pending.vendorOptions || + pending.quotesEncoded || + pending.agentReferences?.length || + pending.pastedTextRanges?.length || + pending.slashCommandRanges !== undefined + ? { + ...(pending.vendorOptions ? { vendorOptions: pending.vendorOptions } : {}), + ...(pending.quotesEncoded ? { quotesEncoded: true } : {}), + ...(pending.agentReferences?.length + ? { agentReferences: pending.agentReferences } + : {}), + ...(pending.pastedTextRanges?.length + ? { pastedTextRanges: pending.pastedTextRanges } + : {}), + ...(pending.slashCommandRanges !== undefined + ? { slashCommandRanges: pending.slashCommandRanges } + : {}), + } + : undefined, + ), + ); + } finally { + if (holdComposer) setRemoteHandoffPreparing(false); } - sendMessage( - pending.text, - session.model, - session.effort as Effort, - session.permissionMode as PermissionMode, - workingDir, - pending.files, - pending.mentions, - pending.vendorOptions || - pending.quotesEncoded || - pending.agentReferences?.length || - pending.pastedTextRanges?.length || - pending.slashCommandRanges !== undefined - ? { - ...(pending.vendorOptions ? { vendorOptions: pending.vendorOptions } : {}), - ...(pending.quotesEncoded ? { quotesEncoded: true } : {}), - ...(pending.agentReferences?.length - ? { agentReferences: pending.agentReferences } - : {}), - ...(pending.pastedTextRanges?.length - ? { pastedTextRanges: pending.pastedTextRanges } - : {}), - ...(pending.slashCommandRanges !== undefined - ? { slashCommandRanges: pending.slashCommandRanges } - : {}), - } - : undefined, - ); })(); - }, [historyLoaded, maybeDispatchDesktopSlashCommand, sendMessage, session, sessionId]); + }, [ + historyLoaded, + maybeDispatchDesktopSlashCommand, + restoreRecoverableHandoff, + sendMessage, + session, + sessionId, + ]); // 远程草稿「新建目标」交接:draft route 只建会话 + 登记 pendingGoal,goal 首轮 // 在这里起(机制说明见 pendingFirstMessage.ts)。视图引擎的 subscribeHeavy 是 @@ -2633,26 +2766,67 @@ export function CCAgentSessionView({ if (!sessionId || !historyLoaded) return; if (pendingGoalConsumedRef.current) return; const pendingGoal = consumePendingGoal(sessionId); - if (!pendingGoal) return; + if (!pendingGoal) { + // 与首条消息同款兜底:上次目标没起成(app 被关 / setGoal 失败)时把目标正文捞回来。 + restoreRecoverableHandoff('goal'); + return; + } pendingGoalConsumedRef.current = true; void (async () => { + // 锁必须覆盖**从消费 pendingGoal 到 setGoal 结束**的全程,不能只包住开协同那段: + // 前面的 subscribe 与后面的 setGoal 同样是隧道 invoke、同样可能走到 30s 超时, + // 锁在它们之外的话,这两个窗口里用户补发的消息会抢在目标首轮之前跑 + // (codex P2 第四轮)。pendingGoal 只有远程草稿会登记,所以无条件上锁。 + setRemoteHandoffPreparing(true); try { - const deviceId = getSessionDeviceId(sessionId); + // 归属必须走**粘滞**解析:relay 瞬断会 clear 掉 remoteProjectsStore 的注册表, + // 非粘滞版此刻返回 undefined → 跳过订阅;而下面的 goalApiFor 走的是粘滞归属, + // setGoal 照样发到被控端 —— 于是目标首轮的 maker:event/status 推送落在 + // 没有订阅者的窗口里(greptile P1)。这正是本 PR 不变量 #3 的一处漏网。 + const deviceId = getStickySessionDeviceId(sessionId); if (deviceId) { await window.electronAPI.deviceLink.subscribe(deviceId, [`session:${sessionId}`]); } - await goalApiFor(sessionId).setGoal({ - sessionId, - objective: pendingGoal.objective, - limits: pendingGoal.limits, + // 与首条消息同款:目标首轮同样要排在协同之后(见上方 pending 消费的注释)。 + if (pendingGoal.remoteCollab) { + const ok = await consumePendingRemoteCollab(pendingGoal.remoteCollab, { + leadSessionId: sessionId, + logTag: 'pending goal', + onFailed: (err) => + toast.error( + getCollaborationStartErrorMessage(err, t, { + remoteDevice: true, + continueAsSingleSession: true, + }), + ), + }); + if (ok) { + void sessionsStore.forceRefresh('active'); + void revealOrcaWorkersTab(sessionId).catch((revealErr) => { + log.warn('revealOrcaWorkersTab after pending goal collab failed', revealErr); + }); + } + } + // 与首条消息同一条路:交付成功才丢副本。setGoal 失败会抛错, + // deliver 里 forget 根本执行不到,副本自然保留 —— 目标正文是用户敲的, + // 留着下次进本会话回填,比只弹一句"失败"更有用。 + await deliverRecoverableHandoff(sessionId, async () => { + await goalApiFor(sessionId).setGoal({ + sessionId, + objective: pendingGoal.objective, + limits: pendingGoal.limits, + }); + return true; }); toast.success(t('goal.toast.set')); } catch (err) { log.warn('pending goal setGoal failed:', err); toast.error(t('goal.toast.failed')); + } finally { + setRemoteHandoffPreparing(false); } })(); - }, [historyLoaded, sessionId, t]); + }, [historyLoaded, restoreRecoverableHandoff, sessionId, t]); // learn 状态卡恢复:卡片是 ephemeral(不落库),app 重启后从 learn:list-runs // 把仍活跃(进行中 / 待审查)且与本会话相关的 run 重新插卡。注意 makerChatStore @@ -3298,7 +3472,7 @@ export function CCAgentSessionView({ isAgentBusy={isAgentBusy} onStop={handleStopSession} pendingQueue={pendingQueue} - disabled={remoteSessionUnavailable} + disabled={remoteSessionUnavailable || remoteHandoffPreparing} queuePaused={queuePaused} queueExpanded={queueExpanded} onQueueExpandedChange={setQueueExpanded} @@ -3334,8 +3508,9 @@ export function CCAgentSessionView({ disableAutofocus={isCompactRail} focusOnStorageKeyChange={ownsRoute} // F-COLLAB: 协同模式 toggle。在以下场景渲染: - // - 普通主会话视图 (含 doc rail) 的本地 Claude / Codex 项目会话 - // 排除 worker 子会话(worker 自己不能再开协同)和远端会话。 + // - 普通主会话视图 (含 doc rail) 的 Claude / Codex 项目会话,本地 / + // SSH 远端 / device-link 被控端三类都算(判定见 collabEntry)。 + // 排除 worker 子会话(worker 自己不能再开协同)与对话模式(无项目目录)。 // orcaMode 路由下 toggle 也保留显示 — ON 态的 orange pill 本身就是 // 关闭按钮 (点击触发 onChange({enabled:false}),走 requestStopCollab)。 // doc rail 的 denseToolbar=true 会把 pill 收成 icon-only 形态。 @@ -3376,17 +3551,22 @@ export function CCAgentSessionView({ disabled: !collabEnabled && (collabPolicy.loading || !collabPolicy.enabled), + // unsupported(被控端版本过旧、没有 maker:plugins:get-state) + // 排在 unavailable 之前:它是确定性的不支持,给「稍后重试」是 + // 误导,上面的 onDisabledActivate 也只挂在 unavailable 上。 disabledReason: !collabEnabled ? collabPolicy.loading ? t('newChat.collaboration.loadingHint') - : collabPolicy.unavailable || !collabPolicy.enabled - ? t( - collabPolicy.unavailable - ? 'newChat.collaboration.unavailableHint' - : 'newChat.collaboration.disabledHint', - ) - : undefined + : collabPolicy.unsupported + ? t('newChat.collaboration.unsupportedRemoteHint') + : collabPolicy.unavailable || !collabPolicy.enabled + ? t( + collabPolicy.unavailable + ? 'newChat.collaboration.unavailableHint' + : 'newChat.collaboration.disabledHint', + ) + : undefined : undefined, } : undefined diff --git a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx index 12c5d153f80..f96606873e5 100644 --- a/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/NewMakerDraftRoute.tsx @@ -88,7 +88,11 @@ import { setProviderModelFast, useProviderModelMemoryVersion, } from '@/state/providerModelMemory'; -import { setPending, setPendingGoal } from '@/state/pendingFirstMessage'; +import { + rememberRecoverableHandoff, + setPending, + setPendingGoal, +} from '@/state/pendingFirstMessage'; import { clearDraftAndNotify as clearComposerDraftAndNotify, getDraft as getComposerDraft, @@ -115,6 +119,7 @@ import { useRefreshWorktrees } from '@/contexts/WorktreeContext'; import { crossAgentConvertService } from '@/lib/crossAgentConvertService'; import { useCrossAgentMigrationDialog } from '@/hooks/useCrossAgentConvertPrompt'; import { getCollaborationStartErrorMessage } from './collaborationErrors'; +import { resolveCollabEntryPolicy } from './collabEntryPolicy'; import { useCollabProjectPolicy } from './hooks/useCollabProjectPolicy'; import { CrossAgentConvertDialog } from '@/components/ui/cross-agent-convert-dialog'; import type { MakerVendor } from '@/lib/ccAgent.types'; @@ -277,9 +282,34 @@ function draftEnableOrcaOptions( providers: ProviderView[], providersReady: boolean, ) { - const workerAgent: 'claude-code' | 'codex' = collab.worker === 'codex' ? 'codex' : 'claude-code'; + const preferredAgent: 'claude-code' | 'codex' = + collab.worker === 'codex' ? 'codex' : 'claude-code'; + // Worker 类型也是**设备作用域**的(codex review P2):在只连了 Codex 的设备 A 选了 Codex + // Worker,切到只连 Claude 的设备 B 时,workerConfig 虽然被清了,collab.worker 仍是 codex, + // 透传过去必撞被控端的 NO_PROVIDER_FOR_AGENT 预检,协同又静默降级成单会话。 + // 与 providerId 同一条思路:按**目标设备**的 live 目录收窄 —— 首选 agent 在那台机器上 + // 没有已连接供应商、而另一个有,就改用另一个;两个都没有则原样透传,由 main 的精确 + // preflight 报可操作错误(不在这里编一个同样跑不起来的值)。 + // 仅在目录就绪时收窄,理由同下方 providerId:未就绪的空快照会误判成"都没有"。 + const workerAgent: 'claude-code' | 'codex' = (() => { + if (!providersReady) return preferredAgent; + if (connectedProvidersForAgent(providers, preferredAgent).length > 0) return preferredAgent; + const fallback: 'claude-code' | 'codex' = + preferredAgent === 'codex' ? 'claude-code' : 'codex'; + return connectedProvidersForAgent(providers, fallback).length > 0 ? fallback : preferredAgent; + })(); const cfg = collab.workerConfig; if (!cfg) return { workerAgent }; + // 首选 agent 被目标设备目录换掉时,配置里的 model / providerId 属于旧 agent,一并丢弃 —— + // 留着只会撞 INVALID_PARAMS。让被控端按新 agent 的默认值起 Worker。 + if (workerAgent !== preferredAgent) { + return { + workerAgent, + role: cfg.role, + label: createWorkerLabel(cfg.role, []), + delegateTask: cfg.initialTask || undefined, + }; + } // 草稿里持久化的来源在发送时按 live 目录重新收窄(已连接 + 提供该模型 + 未被可见性 // 隐藏,与 CreateWorkerPopover.narrowProviderSource 同规则):草稿可跨重启存活,来源 // 可能已断开/掉模型 —— 直接透传会撞 main 的 PROVIDER_ROUTE_UNAVAILABLE 精确 preflight, @@ -698,13 +728,23 @@ export function NewMakerDraftRoute() { const showProviderOnboardingCard = providerOnboarding.visible && !isDeviceLinkDraft; const effectiveExtraDirs = draft.extraDirs; const effectiveCollab = collab; - const collabPolicyEligible = - effectiveWorkingDir != null && - effectiveDeviceLinkDeviceId == null; + // 协同入口判定与会话视图共用同一个 helper(issue #1170:两处各写一份判据,于是同一个 + // device-link 项目在草稿里没入口、进会话页又有)。草稿的 workspaceKind 显式按 + // "有没有选项目目录" 给出 —— 与它提交给 createSession 的值同源,不让 helper 反推。 + const collabEntry = resolveCollabEntryPolicy({ + workspaceKind: effectiveWorkingDir ? 'project' : 'dialogue', + workingDir: effectiveWorkingDir, + remoteHostId: effectiveRemoteHostId, + deviceLinkDeviceId: effectiveDeviceLinkDeviceId, + }); + const collabPolicyEligible = collabEntry.eligible; const collabPolicy = useCollabProjectPolicy(effectiveWorkingDir, collabPolicyEligible, { - // 远端 draft 的 workingDir 是远端路径, 本机项目级查询无意义, 跳过; + // SSH 远端 draft 的 workingDir 是远端主机路径, 本机项目级查询无意义, 跳过; // 用户级/全局级 collab 开关仍生效 (与 main 侧 remote 分支同口径)。 - skipQuery: effectiveRemoteHostId != null, + skipQuery: collabEntry.skipProjectQuery, + // device-link draft:项目级开关的真相在被控端, 隧道过去查(控制端本机查那条远端 + // 路径只会读到自己的用户级开关, 可能与被控端 main 的授权相反)。 + deviceId: collabEntry.policyDeviceId ?? null, }); const projectPickerOptions = useProjectPickerOptions(); /** @@ -1935,6 +1975,8 @@ export function NewMakerDraftRoute() { } let policyEnabled = collabPolicy.enabled; let policyUnavailable = collabPolicy.unavailable; + // 被控端版本过旧 → 确定性不支持,重取毫无意义(下面的 refresh 只对 unavailable 触发)。 + let policyUnsupported = collabPolicy.unsupported; if (effectiveCollab.enabled && policyUnavailable) { // 这是 handleSend 里**第一个** await,必须先上在途锁(Codex review P1):不上锁的话,协同策略 // 重取期间设备 pill / 工作区 pill 仍可点,而本次调用的闭包持有的是旧设备与旧工作区 —— @@ -1949,16 +1991,19 @@ export function NewMakerDraftRoute() { const refreshed = await collabPolicy.refresh(); policyEnabled = refreshed.enabled; policyUnavailable = refreshed.unavailable; + policyUnsupported = refreshed.unsupported; } finally { markSendInFlight(false); } } - if (effectiveCollab.enabled && (policyUnavailable || !policyEnabled)) { + if (effectiveCollab.enabled && (policyUnavailable || policyUnsupported || !policyEnabled)) { toast.warning( t( - policyUnavailable - ? 'newChat.collaboration.unavailableHint' - : 'newChat.collaboration.disabledHint', + policyUnsupported + ? 'newChat.collaboration.unsupportedRemoteHint' + : policyUnavailable + ? 'newChat.collaboration.unavailableHint' + : 'newChat.collaboration.disabledHint', ), ); return false; @@ -2145,11 +2190,39 @@ export function NewMakerDraftRoute() { nowIso: new Date().toISOString(), logTag: 'draft send', }); + // 可恢复副本紧贴提交点落下,**排在下面的附件迁移 await 之前**(codex P2 第五轮)。 + // 提交点之后每多一次 await,「对端会话已建好、正文却还没有第二份」的窗口就长一分; + // rehomeDraftAttachments 是本机 IPC,但含 base64 / 草稿缓存图片时并不快,期间 + // 应用退出或崩溃,正文就没了。副本只存正文、不依赖附件迁移结果,所以可以先落。 + rememberRecoverableHandoff(remoteSessionId, 'message', message); + // F-COLLAB / device-link:草稿开了协同 → **不在这里 await**,把「开协同」连同 + // 首条消息一起交接给 SessionView(见 pendingFirstMessage.PendingRemoteCollab)。 + // + // 两条约束只能这样同时满足(greptile P1 + codex P1/P2 三轮收敛的结论): + // · 首轮必须排在协同之后 —— 否则用户开了协同,首轮 Lead 却没有 cindy_orca 工具; + // · 提交点之后不得插入远程等待 —— 被控端起 Worker 是一次隧道往返,可能一路走到 + // invoke 默认 30s 超时。挡在 navigate 前面,既让新建页凭空卡住半分钟,又把 + // 「对端会话已建好、用户输入还只在内存 pending Map 里」的窗口拉到同样长度, + // 窗口内应用被关掉就永久丢消息、对端留下空会话。 + // 交接出去之后,等待发生在**已经导航到的**会话视图里:UI 不卡,输入已在视图手里, + // 而首轮仍然由同一个 await 串在协同之后。 const rehydratedFiles = await rehomeDraftAttachments(files, remoteSessionId); setPending(remoteSessionId, { text: message, files: rehydratedFiles, mentions, + ...(shouldEnableCollab + ? { + remoteCollab: { + deviceId, + options: draftEnableOrcaOptions( + effectiveCollab, + deviceProviders, + !deviceProvidersLoading, + ), + }, + } + : {}), ...(opts?.quotesEncoded ? { quotesEncoded: true } : {}), ...(opts?.agentReferences?.length ? { agentReferences: opts.agentReferences } : {}), ...(opts?.pastedTextRanges?.length @@ -2163,6 +2236,7 @@ export function NewMakerDraftRoute() { clearComposerDraftAndNotify(NEW_MAKER_DRAFT_KEY); attachmentState.clearFiles(); resetDraftWorkspaceAfterSend(); + // 立刻导航:开协同的等待与协同 tab 的展开都由 SessionView 在消费 pending 时处理。 navigate(`/cc-agent/${remoteSessionId}`, { replace: true }); return; } @@ -2537,6 +2611,7 @@ export function NewMakerDraftRoute() { isDeviceLinkDraft, capabilitiesLoading, remoteDraftState.loaded, + deviceProviders, deviceProvidersLoading, effectiveDeviceLinkDeviceId, effectiveDeviceLinkDeviceName, @@ -2554,12 +2629,14 @@ export function NewMakerDraftRoute() { collabPolicy.loading, collabPolicy.refresh, collabPolicy.unavailable, + collabPolicy.unsupported, effectiveCollab.worker, // workerConfig 也要进依赖:只改角色/模型/effort/初始任务(worker 类型不变)时, // 少了它 handleSend 会闭包吃旧的 effectiveCollab,起 Worker 用错配置(codex P2)。 effectiveCollab.workerConfig, // draftEnableOrcaOptions 现按 live 目录收窄草稿来源:快照与 loading 都要进 // 依赖,否则闭包吃旧快照,来源连/断后仍按陈旧目录收窄(codex review)。 + // device-link 分支按**被控端**目录收窄,同理两项都要进。 localProviders, localProvidersLoading, vendorAuthGate, @@ -2602,8 +2679,15 @@ export function NewMakerDraftRoute() { if (collabPolicy.loading) { throw new Error(t('newChat.collaboration.loadingHint')); } + // 被控端版本过旧:确定性不支持,不走重取(与 handleSend 同口径)。 + if (collabPolicy.unsupported) { + throw new Error(t('newChat.collaboration.unsupportedRemoteHint')); + } if (collabPolicy.unavailable) { const refreshed = await collabPolicy.refresh(); + if (refreshed.unsupported) { + throw new Error(t('newChat.collaboration.unsupportedRemoteHint')); + } if (refreshed.unavailable) { throw new Error(t('newChat.collaboration.unavailableHint')); } @@ -2689,7 +2773,27 @@ export function NewMakerDraftRoute() { // mount 才建立,在 /cc-agent/new 就起 goal 首轮会让 maker:event/status 推送 // 掉在订阅建立前的窗口里(Codex review #548)。与首条消息同款交接 —— // setPendingGoal → navigate → SessionView 消费时订阅已就绪再 setGoal。 - setPendingGoal(remoteSessionId, { objective, limits }); + // 协同同样随交接一起交出去(与发送分支同口径,理由见那段注释):在这里 await + // 开协同会把目标文案压在内存里等一次可能 30s 的隧道往返。 + setPendingGoal(remoteSessionId, { + objective, + limits, + ...(shouldEnableCollab + ? { + remoteCollab: { + deviceId, + options: draftEnableOrcaOptions( + effectiveCollab, + deviceProviders, + !deviceProvidersLoading, + ), + }, + } + : {}), + }); + // 与发送分支同口径:副本紧贴提交点落下,不等消费(理由见那段注释)。 + // 这条分支的提交点与 setPendingGoal 之间没有 await,所以落在这里即等价于贴着提交点。 + rememberRecoverableHandoff(remoteSessionId, 'goal', objective); // 自动起名:goal 首轮走 GoalController 的 session.send、不经 maker:input:enqueue, // 被控端 deviceLinkAutoTitle 不会触发(Codex review #548)—— 与本地分支的 // autoNameSession 对位:先立即用目标文案截断占位(Codex 式,侧边栏不停留在 @@ -2766,6 +2870,7 @@ export function NewMakerDraftRoute() { })(); clearComposerDraftAndNotify(NEW_MAKER_DRAFT_KEY); resetDraftWorkspaceAfterSend(); + // 立刻导航:开协同的等待与协同 tab 的展开都由 SessionView 在消费 pendingGoal 时处理。 navigate(`/cc-agent/${remoteSessionId}`, { replace: true }); return; } @@ -2796,7 +2901,8 @@ export function NewMakerDraftRoute() { } // 草稿开了协同 → 新建目标路径也要拉起 Worker(与 Send 路径同口径);否则用户开了协同 // 却走「新建目标」会得到一个没有 Worker 的 lead session(codex P2)。失败 toast + 降级 - // 单会话,不阻断目标创建。仅本地项目 draft 可达(device-link 分支上面已 return)。 + // 单会话,不阻断目标创建。本机 / SSH 项目 draft 走这里;device-link 在上面的分支里用 + // 同口径隧道到被控端 enableOrca 后已 return。 // reveal 不在此处直接 dispatch:当前路由还在 /cc-agent/new,分离侧栏控制器会因 // session 不匹配返回 stale-context(codex P2)——与 Send 路径同口径,把 reveal // 塞进 navigate state,由 CCAgentSessionView mount 后消费。 @@ -2843,6 +2949,7 @@ export function NewMakerDraftRoute() { isDeviceLinkDraft, capabilitiesLoading, remoteDraftState.loaded, + deviceProviders, deviceProvidersLoading, vendorAuthGate, authVendor, @@ -2863,6 +2970,7 @@ export function NewMakerDraftRoute() { collabPolicy.loading, collabPolicy.refresh, collabPolicy.unavailable, + collabPolicy.unsupported, collabPolicy.enabled, // 同 handleSend:草稿来源收窄依赖 live 目录快照。 localProviders, @@ -3163,9 +3271,10 @@ export function NewMakerDraftRoute() { disabled={wtCreating} /> } - // 协同 toggle(与对话界面同一控件):本地与 SSH 远端项目 draft 均可用 - // (远端 worker 创建继承 remoteHostId, 两端 MCP 注入已接通); 对话模式(无 - // workingDir)/ device-link 不支持(state 层 normalize + patchDraft 同口径)。Lead = 当前 + // 协同 toggle(与对话界面同一控件):本地 / SSH 远端 / device-link 项目 draft + // 均可用 —— eligible 由 resolveCollabEntryPolicy 单点判定,与会话视图同一份 + // (issue #1170:两处各写一份判据造成入口前后不一致)。仍然不支持的只有对话 + // 模式(无 workingDir)。Lead = 当前 // vendor(上方 VendorSegmentedSwitcher)。onOpenDetails 打开「开启协同」富弹窗 // (CreateWorkerPopover:role/agent/model/初始任务),与会话内完全一致;OFF 态点击 // 走它而非简单 worker popover。ON 态点击 onChange(enabled:false) 关闭协同。 @@ -3191,14 +3300,19 @@ export function NewMakerDraftRoute() { (collabPolicy.loading || collabPolicy.unavailable || !collabPolicy.enabled), + // unsupported(被控端版本过旧、没有 maker:plugins:get-state)排在 + // unavailable 之前:它是确定性的不支持,给「稍后重试」是误导, + // 上面的 onDisabledActivate 也只挂在 unavailable 上。 disabledReason: collabPolicy.loading ? t('newChat.collaboration.loadingHint') - : collabPolicy.unavailable - ? t('newChat.collaboration.unavailableHint') - : !collabPolicy.enabled - ? t('newChat.collaboration.disabledHint') - : undefined, + : collabPolicy.unsupported + ? t('newChat.collaboration.unsupportedRemoteHint') + : collabPolicy.unavailable + ? t('newChat.collaboration.unavailableHint') + : !collabPolicy.enabled + ? t('newChat.collaboration.disabledHint') + : undefined, } : undefined } @@ -3362,7 +3476,9 @@ export function NewMakerDraftRoute() { {/* 「开启协同」富弹窗:与会话内 CCAgentSessionView 同一组件/标题。草稿态没有 sessionId, 不立刻 enableOrca —— onCreate 把 Worker 富配置写进 draft.collab,createSession 后由 - draftEnableOrcaOptions 透传给 enableOrca(见本文件 F-COLLAB 段)。deviceId 省略(协同仅本地)。 */} + draftEnableOrcaOptions 透传给 enableOrca(见本文件 F-COLLAB 段)。 + device-link 草稿传 deviceId:Worker 在**被控端** spawn,模型 / 来源清单必须来自那台 + 设备,拿控制端的目录配出来的模型在被控端多半不存在(issue #1170)。 */} setCreateWorkerOpen(false)} @@ -3383,6 +3499,7 @@ export function NewMakerDraftRoute() { }} title={t('orca.createWorker.enableCollabTitle')} submitLabel={t('orca.createWorker.enableCollabSubmit')} + deviceId={effectiveDeviceLinkDeviceId ?? undefined} // SSH 远程草稿(draft.remoteHostId):worker 在远端 spawn,模型清单按 SSH // 口径过滤,与本路由 ChatInput 候选及 main 侧 remote-worker guard 同口径。 sshRemote={!!effectiveRemoteHostId} diff --git a/apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts b/apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts new file mode 100644 index 00000000000..5a06cb00e46 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts @@ -0,0 +1,80 @@ +/** + * 协同(Orca)入口的**单一判定口径** —— 新建草稿与已创建会话共用。 + * + * 存在的理由:这两条路径曾各自写一份 eligible 判据,于是同一个 device-link 项目在草稿里 + * 看不到协同开关、发出第一条消息后开关又冒出来(issue #1170)。判据一旦分叉就没有任何 + * 编译或测试信号,只有用户实际走完两条路才会撞上,所以统一到这里,新增场景只改一处。 + * + * 注意边界:这里只回答「**该不该挂入口**」和「项目级 collab 开关该向**谁**查」。 + * 「查出来是开还是关」由 useCollabProjectPolicy 负责,而最终授权始终是被控/执行端 main 的 + * `assertCollabProjectEnabled`(apps/desktop/src/main/maker-ipc/collabProjectPolicy.ts)—— + * renderer 这一层只是体验层,不构成权限边界。 + */ + +export interface CollabEntryTarget { + /** + * 会话/草稿的 workspace 形态。**必须由调用方显式给出,这里不从 workingDir 反推** —— + * dialogue 会话也有 workingDir(main 按 workspaceKind='dialogue' 自动分配 + * `/dialogues///` 作运行目录),反推会把纯对话误判成项目。 + * 草稿侧传 `workingDir ? 'project' : 'dialogue'`,与它提交给 createSession 的值同源。 + */ + workspaceKind?: string | null; + workingDir?: string | null; + /** sessions.orca_role。'worker' = Orca Worker 子会话,自己不能再开协同。草稿恒为空。 */ + orcaRole?: string | null; + /** SSH 远端工作区所属 host。非空 = workingDir 是那台远端主机上的路径。 */ + remoteHostId?: string | null; + /** device-link 被控设备 deviceId。非空 = 这个项目和会话的真身都在那台被控设备上。 */ + deviceLinkDeviceId?: string | null; +} + +export interface CollabEntryPolicyScope { + /** 该目标能否挂协同入口(不含插件开关本身,那由 policy 查询回答)。 */ + eligible: boolean; + /** + * 非空 = 项目级 collab 开关要**隧道到这台被控设备**去查。 + * 控制端本机查 `.cindy/plugins.json` 只会读到自己的用户级开关,与被控端 main 的 + * 权威授权可能相反 —— 那正是 #1170 里「入口能点但走不完」的来源之一。 + */ + policyDeviceId?: string; + /** + * true = 跳过项目级查询,只落用户级/全局级。 + * SSH 远端会话的 workingDir 是远端机器上的路径,拿它在**执行查询的那台机器**的 fs 上 + * 找项目配置既无意义又会误判(命中同路径的本地目录会误开,查不到又会误拒);远端项目级 + * collab 配置机制尚未存在(main 侧 assertCollabProjectEnabled 的 remote 分支同口径)。 + */ + skipProjectQuery: boolean; +} + +const NOT_ELIGIBLE: CollabEntryPolicyScope = { eligible: false, skipProjectQuery: false }; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +/** + * 五类场景一次说清: + * + * | 场景 | eligible | 策略查询 | + * |-----------------------------------|----------|---------------------------------| + * | 本地项目 | ✅ | 本机项目级 | + * | SSH 远端项目(remoteHostId) | ✅ | skipProjectQuery(仅用户/全局级) | + * | device-link 项目(deviceLinkDeviceId) | ✅ | 隧道到该被控设备查项目级 | + * | dialogue(无项目目录) | ❌ | — | + * | Orca Worker 子会话 | ❌ | — | + * + * 两个远端维度**可以同时成立**(在被控设备上打开的 SSH 远端项目):此时既要隧道到被控端, + * 又要在被控端跳过项目级 —— 两个字段互相独立,不是二选一。 + */ +export function resolveCollabEntryPolicy(target: CollabEntryTarget): CollabEntryPolicyScope { + if (target.orcaRole === 'worker') return NOT_ELIGIBLE; + if (target.workspaceKind !== 'project') return NOT_ELIGIBLE; + if (nonEmpty(target.workingDir) === null) return NOT_ELIGIBLE; + + const deviceId = nonEmpty(target.deviceLinkDeviceId); + return { + eligible: true, + ...(deviceId ? { policyDeviceId: deviceId } : {}), + skipProjectQuery: nonEmpty(target.remoteHostId) !== null, + }; +} diff --git a/apps/desktop/src/renderer/features/cc-agent/collaborationErrors.ts b/apps/desktop/src/renderer/features/cc-agent/collaborationErrors.ts index 8f466cd9fa2..379c756c869 100644 --- a/apps/desktop/src/renderer/features/cc-agent/collaborationErrors.ts +++ b/apps/desktop/src/renderer/features/cc-agent/collaborationErrors.ts @@ -25,17 +25,27 @@ export function getCollaborationStartErrorMessage( options: CollaborationStartErrorOptions = {}, ): string { const ipcError = extractIpcError(err); - if (ipcError && ACTIONABLE_COLLABORATION_ERROR_CODES.has(ipcError.code)) { - const suffix = options.remoteDevice - ? '_REMOTE' - : options.continueAsSingleSession - ? '_CONTINUE' - : ''; - return t(`newChat.collaboration.errors.${ipcError.code}${suffix}`); + const base = (() => { + if (ipcError && ACTIONABLE_COLLABORATION_ERROR_CODES.has(ipcError.code)) { + const suffix = options.remoteDevice + ? '_REMOTE' + : options.continueAsSingleSession + ? '_CONTINUE' + : ''; + return t(`newChat.collaboration.errors.${ipcError.code}${suffix}`); + } + return t( + options.continueAsSingleSession + ? 'newChat.collaboration.startFailedContinue' + : 'newChat.collaboration.startFailed', + ); + })(); + // 被控端出错时,`_REMOTE` 文案只讲「去那台机器修好再重试」,没说这一条**仍然会发出去**。 + // 用户据此可能以为没发、再提交一次,或在不知情下让首轮脱离协同运行(codex review P2)。 + // remoteDevice 已经占掉了 `_CONTINUE` 后缀那一档,所以用一句独立的补充说明,而不是再 + // 铺一套 `_REMOTE_CONTINUE` 文案 —— 两句都是完整句子,拼接不会产生语法碎片。 + if (options.remoteDevice && options.continueAsSingleSession) { + return `${base}${t('newChat.collaboration.remoteContinueNotice')}`; } - return t( - options.continueAsSingleSession - ? 'newChat.collaboration.startFailedContinue' - : 'newChat.collaboration.startFailed', - ); + return base; } diff --git a/apps/desktop/src/renderer/features/cc-agent/hooks/__tests__/useCollabProjectPolicy.test.ts b/apps/desktop/src/renderer/features/cc-agent/hooks/__tests__/useCollabProjectPolicy.test.ts index 8475d2eddfc..5f6b962d5ca 100644 --- a/apps/desktop/src/renderer/features/cc-agent/hooks/__tests__/useCollabProjectPolicy.test.ts +++ b/apps/desktop/src/renderer/features/cc-agent/hooks/__tests__/useCollabProjectPolicy.test.ts @@ -6,6 +6,7 @@ import { useCollabProjectPolicy } from '../useCollabProjectPolicy'; vi.mock('@/lib/logger', () => ({ createLogger: () => ({ + info: vi.fn(), warn: vi.fn(), }), })); @@ -151,14 +152,14 @@ describe('useCollabProjectPolicy', () => { latest.resolve({ effectiveEnabled: false }); latestResult = await latestRefresh; }); - expect(latestResult).toEqual({ enabled: false, unavailable: false }); + expect(latestResult).toEqual({ enabled: false, unavailable: false, unsupported: false }); let olderResult!: Awaited; await act(async () => { older.resolve({ effectiveEnabled: true }); olderResult = await olderRefresh; }); - expect(olderResult).toEqual({ enabled: false, unavailable: false }); + expect(olderResult).toEqual({ enabled: false, unavailable: false, unsupported: false }); expect(result.current.enabled).toBe(false); }); @@ -194,7 +195,7 @@ describe('useCollabProjectPolicy', () => { projectAResult = await projectARefresh; }); - expect(projectAResult).toEqual({ enabled: false, unavailable: false }); + expect(projectAResult).toEqual({ enabled: false, unavailable: false, unsupported: false }); expect(result.current.enabled).toBe(true); expect(getState).toHaveBeenNthCalledWith(2, 'collab', 'C:/projects/project-a'); expect(getState).toHaveBeenNthCalledWith(3, 'collab', 'C:/projects/project-b'); @@ -260,4 +261,102 @@ describe('useCollabProjectPolicy', () => { await waitFor(() => expect(result.current.enabled).toBe(true)); expect(getState).toHaveBeenCalledWith('collab', undefined); }); + + // ── device-link:项目级开关的真相在被控端(issue #1170)──────────────────────── + // 此前一律查控制端本机:拿被控端的路径在自己的 fs 上找 `.cindy/plugins.json` 必然落空, + // 于是读到的是控制端自己的用户级开关,与被控端 main 的 assertCollabProjectEnabled 可能 + // 相反 —— 入口据此置灰或放行都可能是错的,用户点下去才撞 PRECONDITION_FAILED。 + + function stubDeviceLink(invoke: ReturnType, getState = vi.fn()) { + (window as unknown as { electronAPI: unknown }).electronAPI = { + maker: { plugins: { getState } }, + deviceLink: { invoke }, + }; + return { invoke, getState }; + } + + it('device-link 会话:隧道到被控端查项目级,不碰控制端本机状态', async () => { + const invoke = vi.fn().mockResolvedValue({ effectiveEnabled: false }); + const { getState } = stubDeviceLink(invoke); + + const { result } = renderHook(() => + useCollabProjectPolicy('/host/proj', true, { deviceId: 'dev-1' }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(invoke).toHaveBeenCalledWith('dev-1', 'maker:plugins:get-state', [ + 'collab', + '/host/proj', + ]); + expect(getState).not.toHaveBeenCalled(); + // 被控端说这个项目关了协同 → 入口置灰,而不是照控制端自己的开关放行。 + expect(result.current.enabled).toBe(false); + }); + + it('同一路径串在两台设备上不串台(查询键含 deviceId)', async () => { + // 两台机器上完全可能出现同一个路径 —— 只按路径做查询键会把 A 的答案当成 B 的。 + const invoke = vi + .fn() + .mockResolvedValueOnce({ effectiveEnabled: true }) + .mockResolvedValueOnce({ effectiveEnabled: false }); + stubDeviceLink(invoke); + + const { result, rerender } = renderHook( + ({ deviceId }: { deviceId: string }) => + useCollabProjectPolicy('/Users/me/proj', true, { deviceId }), + { initialProps: { deviceId: 'dev-a' } }, + ); + await waitFor(() => expect(result.current.enabled).toBe(true)); + + rerender({ deviceId: 'dev-b' }); + await waitFor(() => expect(result.current.enabled).toBe(false)); + expect(invoke).toHaveBeenNthCalledWith(2, 'dev-b', 'maker:plugins:get-state', [ + 'collab', + '/Users/me/proj', + ]); + }); + + it('被控端版本过旧(CHANNEL_NOT_ALLOWED)→ unsupported,而不是可重试的 unavailable', async () => { + const invoke = vi + .fn() + .mockRejectedValue(new Error('[DEVICE_LINK_CHANNEL_NOT_ALLOWED] channel not allowed')); + stubDeviceLink(invoke); + + const { result } = renderHook(() => + useCollabProjectPolicy('/host/proj', true, { deviceId: 'dev-old' }), + ); + + await waitFor(() => expect(result.current.unsupported).toBe(true)); + // 重试永远不会成功,所以不该落进 unavailable 那条「稍后重试」提示 / onDisabledActivate。 + expect(result.current.unavailable).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.enabled).toBe(false); + }); + + it('隧道瞬时失败 → 仍是 unavailable(值得重试),不误判成版本过旧', async () => { + const invoke = vi.fn().mockRejectedValue(new Error('tunnel closed')); + stubDeviceLink(invoke); + + const { result } = renderHook(() => + useCollabProjectPolicy('/host/proj', true, { deviceId: 'dev-1' }), + ); + + await waitFor(() => expect(result.current.unavailable).toBe(true)); + expect(result.current.unsupported).toBe(false); + expect(result.current.loading).toBe(false); + }); + + it('不 eligible 时既不查本机也不走隧道', async () => { + const invoke = vi.fn(); + const { getState } = stubDeviceLink(invoke); + + const { result } = renderHook(() => + useCollabProjectPolicy('/host/proj', false, { deviceId: 'dev-1' }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.enabled).toBe(false); + expect(getState).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/features/cc-agent/hooks/useCollabProjectPolicy.ts b/apps/desktop/src/renderer/features/cc-agent/hooks/useCollabProjectPolicy.ts index f95d12d2525..541ad88c806 100644 --- a/apps/desktop/src/renderer/features/cc-agent/hooks/useCollabProjectPolicy.ts +++ b/apps/desktop/src/renderer/features/cc-agent/hooks/useCollabProjectPolicy.ts @@ -1,29 +1,34 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { createLogger } from '@/lib/logger'; +import { pluginEnableStateFor } from '@/lib/makerTransport'; +import { extractIpcError } from '@/utils/ipcError'; import { normalizeWorkingDirForProjectSettings } from '../../../../shared/workingDir'; const log = createLogger('useCollabProjectPolicy'); interface PolicyState { - workingDir: string | null; + /** 本次结果对应的查询键(设备 + 目录);换设备或换目录都换键,不复用上一格的答案。 */ + queryKey: string | null; enabled: boolean | null; unavailable: boolean; + unsupported: boolean; } export interface CollabProjectPolicy { enabled: boolean; loading: boolean; + /** 查询失败(瞬时):入口置灰,但值得给一次重试。 */ unavailable: boolean; - refresh: () => Promise<{ - enabled: boolean; - unavailable: boolean; - }>; + /** 被控端根本不支持该查询(老版本):入口置灰且**不该**给重试,重试永远不会成功。 */ + unsupported: boolean; + refresh: () => Promise; } type PolicyResult = { enabled: boolean; unavailable: boolean; + unsupported: boolean; }; type ProjectRefreshTracker = { @@ -32,106 +37,126 @@ type ProjectRefreshTracker = { }; /** - * Reads the effective project-scoped collab plugin state for renderer gating. + * Reads the effective collab plugin state for renderer gating. * Main IPC authorization remains authoritative for every create request. * - * `skipQuery`: 远端 (SSH) 会话的 workingDir 是远端路径, 本机 fs 的项目级 - * 查询既无意义又会误拒 — 跳过项目级覆盖, 但仍查用户级/全局级 collab 开关 - * (与 main 侧 assertCollabProjectEnabled 的 remote 分支同口径): 用户全局 - * 禁用 Collab 时 UI toggle 同样置灰, 而不是放行到 enableOrca 才撞 - * PRECONDITION_FAILED。 + * 两个「查哪台机器、查到哪一层」的维度由 `resolveCollabEntryPolicy` 决定,这里只执行: + * + * `deviceId`(device-link):项目级开关的真相在**被控端**。控制端拿被控端的路径查自己本机 + * 只会读到自己的用户级开关,可能与被控端 main 的 assertCollabProjectEnabled 相反 —— + * 那正是 #1170 里「草稿没入口 / 会话有入口但走不完」的第二层来源。 + * + * `skipQuery`(SSH 远端):workingDir 是远端主机上的路径, 在执行查询的那台机器的 fs 上查 + * 项目级既无意义又会误判 — 跳过项目级覆盖, 但仍查用户级/全局级 collab 开关 + * (与 main 侧 assertCollabProjectEnabled 的 remote 分支同口径): 用户全局禁用 Collab 时 + * UI toggle 同样置灰, 而不是放行到 enableOrca 才撞 PRECONDITION_FAILED。 */ export function useCollabProjectPolicy( workingDir: string | null | undefined, eligible: boolean, - opts?: { skipQuery?: boolean }, + opts?: { skipQuery?: boolean; deviceId?: string | null }, ): CollabProjectPolicy { const skipQuery = opts?.skipQuery === true; - // skipQuery 用 '' 作查询键:state 机围绕 workingDir key 构造, '' 占位表示 - // "跳过项目级、只查用户级" 那一档。 + // skipQuery 用 '' 作查询参数:'' 占位表示 "跳过项目级、只查用户级" 那一档。 const requestedWorkingDir = eligible && typeof workingDir === 'string' ? skipQuery ? '' : normalizeWorkingDirForProjectSettings(workingDir) : null; + const requestedDeviceId = + eligible && typeof opts?.deviceId === 'string' && opts.deviceId.trim() !== '' + ? opts.deviceId + : null; + // 键含 deviceId:两台被控设备完全可能出现同一个路径串(`/Users/me/proj`),只按路径 + // 做键会让 A 设备的答案被当成 B 设备的,入口据此置灰/放行都是错的。 + const requestKey = + requestedWorkingDir == null ? null : `${requestedDeviceId ?? ''}\u0000${requestedWorkingDir}`; const [state, setState] = useState({ - workingDir: null, - enabled: requestedWorkingDir == null ? false : null, + queryKey: null, + enabled: requestKey == null ? false : null, unavailable: false, + unsupported: false, }); const requestIdRef = useRef(0); - const refreshTrackersByWorkingDirRef = useRef( - new Map(), - ); + const refreshTrackersByKeyRef = useRef(new Map()); const refresh = useCallback((): Promise => { const requestId = ++requestIdRef.current; - // 注意用 == null 而非 falsy 判断:skipQuery 的 '' 哨兵是合法查询键 + // 注意用 == null 而非 falsy 判断:skipQuery 的 '' 哨兵是合法查询参数 // (跳过项目级、只查用户级), 不能落进"无 workingDir"早退。 - if (requestedWorkingDir == null) { - setState({ workingDir: null, enabled: false, unavailable: false }); - return Promise.resolve({ enabled: false, unavailable: false }); + if (requestKey == null || requestedWorkingDir == null) { + setState({ queryKey: null, enabled: false, unavailable: false, unsupported: false }); + return Promise.resolve({ enabled: false, unavailable: false, unsupported: false }); } let requestPromise!: Promise; requestPromise = (async () => { setState((previous) => - previous.workingDir === requestedWorkingDir - ? { ...previous, unavailable: false } - : { workingDir: requestedWorkingDir, enabled: null, unavailable: false }, + previous.queryKey === requestKey + ? { ...previous, unavailable: false, unsupported: false } + : { queryKey: requestKey, enabled: null, unavailable: false, unsupported: false }, ); try { // '' (skipQuery) → 不传 workingDir: getEnableState 跳过项目级覆盖, // 落用户级/全局级 — 与 main 侧 remote 分支同语义。 - const next = await window.electronAPI.maker.plugins.getState( + // deviceId 非空 → 隧道到被控端读它自己的项目级真相(见 pluginEnableStateFor)。 + const next = await pluginEnableStateFor( + requestedDeviceId, 'collab', requestedWorkingDir === '' ? undefined : requestedWorkingDir, ); - const result = { enabled: next.effectiveEnabled, unavailable: false }; + const result = { enabled: next.effectiveEnabled, unavailable: false, unsupported: false }; if (requestId !== requestIdRef.current) { - const latest = - refreshTrackersByWorkingDirRef.current.get(requestedWorkingDir)?.latestPromise; + const latest = refreshTrackersByKeyRef.current.get(requestKey)?.latestPromise; return latest && latest !== requestPromise ? latest : result; } setState({ - workingDir: requestedWorkingDir, + queryKey: requestKey, enabled: result.enabled, unavailable: false, + unsupported: false, }); return result; } catch (err) { - log.warn('failed to read project collab policy', { + // 老被控端没收录 maker:plugins:get-state → 隧道回 CHANNEL_NOT_ALLOWED。这是 + // **确定性**的不支持,不是瞬时故障:同 getWorkflowProgressFor 的判别方式,单独 + // 分类,让 UI 不去挂一个永远不会成功的重试。 + const unsupported = extractIpcError(err)?.code === 'DEVICE_LINK_CHANNEL_NOT_ALLOWED'; + log[unsupported ? 'info' : 'warn']('failed to read collab policy', { workingDir: requestedWorkingDir, + deviceId: requestedDeviceId, + unsupported, err, }); - const result = { enabled: false, unavailable: true }; + const result = { enabled: false, unavailable: !unsupported, unsupported }; if (requestId !== requestIdRef.current) { - const latest = - refreshTrackersByWorkingDirRef.current.get(requestedWorkingDir)?.latestPromise; + const latest = refreshTrackersByKeyRef.current.get(requestKey)?.latestPromise; return latest && latest !== requestPromise ? latest : result; } - setState({ workingDir: requestedWorkingDir, enabled: null, unavailable: true }); + setState({ + queryKey: requestKey, + enabled: null, + unavailable: result.unavailable, + unsupported, + }); return result; } })(); - const tracker = refreshTrackersByWorkingDirRef.current.get(requestedWorkingDir) ?? { + const tracker = refreshTrackersByKeyRef.current.get(requestKey) ?? { latestPromise: requestPromise, inFlight: 0, }; tracker.latestPromise = requestPromise; tracker.inFlight += 1; - refreshTrackersByWorkingDirRef.current.set(requestedWorkingDir, tracker); + refreshTrackersByKeyRef.current.set(requestKey, tracker); void requestPromise.finally(() => { tracker.inFlight -= 1; - if ( - tracker.inFlight === 0 && - refreshTrackersByWorkingDirRef.current.get(requestedWorkingDir) === tracker - ) { - refreshTrackersByWorkingDirRef.current.delete(requestedWorkingDir); + if (tracker.inFlight === 0 && refreshTrackersByKeyRef.current.get(requestKey) === tracker) { + refreshTrackersByKeyRef.current.delete(requestKey); } }); return requestPromise; - }, [requestedWorkingDir]); + }, [requestKey, requestedDeviceId, requestedWorkingDir]); useEffect(() => { if (!eligible) return; @@ -156,20 +181,15 @@ export function useCollabProjectPolicy( }, [refresh]); const current = - requestedWorkingDir == null - ? false - : state.workingDir === requestedWorkingDir - ? state.enabled - : null; - const unavailable = - requestedWorkingDir != null && - state.workingDir === requestedWorkingDir && - current === null && - state.unavailable; + requestKey == null ? false : state.queryKey === requestKey ? state.enabled : null; + const settledForKey = requestKey != null && state.queryKey === requestKey && current === null; + const unavailable = settledForKey && state.unavailable; + const unsupported = settledForKey && state.unsupported; return { enabled: current === true, - loading: current === null && !unavailable, + loading: current === null && !unavailable && !unsupported, unavailable, + unsupported, refresh, }; } diff --git a/apps/desktop/src/renderer/features/cc-agent/hooks/useStopOrcaCollab.ts b/apps/desktop/src/renderer/features/cc-agent/hooks/useStopOrcaCollab.ts index a137d305f24..da17ac191ab 100644 --- a/apps/desktop/src/renderer/features/cc-agent/hooks/useStopOrcaCollab.ts +++ b/apps/desktop/src/renderer/features/cc-agent/hooks/useStopOrcaCollab.ts @@ -18,7 +18,7 @@ import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; import { sessionsStore } from '@/lib/sessionsStore'; import { toast } from '@/lib/toast'; import { createLogger } from '@/lib/logger'; -import { makerApiFor } from '@/lib/makerTransport'; +import { makerApiForSticky } from '@/lib/makerTransport'; const log = createLogger('useStopOrcaCollab'); @@ -59,7 +59,9 @@ export function useStopOrcaCollab(opts: UseStopOrcaCollabOptions): StopOrcaColla if (!ok) return false; setBusy(true); try { - await makerApiFor(leadSessionId).disableOrca(leadSessionId); + // 粘滞归属(与 requestEnableCollab 对称):relay 瞬断窗口内误判成本机会在**控制端** + // 销毁一个不存在的 team、或撞上同 id 的本机会话,而远端的协同其实还开着。 + await makerApiForSticky(leadSessionId).disableOrca(leadSessionId); void sessionsStore.forceRefresh('active'); if (navigateOnSuccess) { navigate(`/cc-agent/${leadSessionId}`, { replace: true }); diff --git a/apps/desktop/src/renderer/features/cc-agent/remoteCollabHandoff.ts b/apps/desktop/src/renderer/features/cc-agent/remoteCollabHandoff.ts new file mode 100644 index 00000000000..ceecbc36963 --- /dev/null +++ b/apps/desktop/src/renderer/features/cc-agent/remoteCollabHandoff.ts @@ -0,0 +1,218 @@ +/** + * remoteCollabHandoff —— device-link 远程会话**开启协同**的执行与终态判定(issue #1170)。 + * --------------------------------------------------------------------------- + * device-link 项目的 Lead / Worker / team 真身都在被控端,控制端只是镜像。围绕 + * 「草稿开了协同」这件事,三轮 review 收敛出两条方向相反的时序约束: + * + * ① **首轮必须排在协同之后** —— 否则用户开了协同,首轮 Lead 却没有 cindy_orca 工具。 + * ② **提交点之后不得插入远程等待** —— 被控端 `maker:create-session` 返回 sessionId + * 那一刻就是提交点;此后每多一步 await,「对端会话已建好、用户输入还只在内存里」的 + * 窗口就长一分。而本函数是一次隧道往返,可能一路走到 invoke 默认 30s 超时。 + * + * 两者只能靠**把等待挪到导航之后**同时满足:草稿路由登记完 `setPending` / + * `setPendingGoal`(载荷带上 `remoteCollab`)就立刻 navigate;`CCAgentSessionView` + * 消费 pending 时先 await 本函数、再发首轮 / 起目标。于是新建页不卡、用户输入已经交到 + * 会话视图手里,而首轮仍由同一个 await 串在协同之后。 + * + * 另外两条不变量: + * · **隧道超时不是权威失败** —— 超时只删掉控制端的等待项,被控端那次 enableOrca 仍在跑 + * (device-link ipc.ts 对 INVOKE_TIMEOUT 的既有判定就是「远端仍存活」)。所以超时后要 + * 回查被控端 DB 的权威终态再定性,查不到才 fail-closed 降级。 + * · **镜像回流绝不能 await** —— `refreshRemoteDeviceSessions` 对瞬态错误有最长约 6.75 秒 + * 的退避重试。协同 tab 解析不到 worker 时会 fallback `listWorkersByLead`,worker 变更 + * 另有 ORCA_WORKER_CHANGED 推送兜底,镜像慢一拍能自愈 —— 本来就不值得等。 + * + * 抽成一处的原因与 `remoteSessionHandoff` 相同:草稿的「发送」与「新建目标」两条路径 + * 逐字重复这段收尾,而 #807 的 review 反复证明**两处逐字重复漏改一处不会有任何编译或 + * 测试信号**。顺带满足 newMakerProjectPicker 那条守卫:组件不再自己 import 回流函数, + * 回流只经共享 helper 使用。 + */ +import { + isTransientRemoteError, + refreshRemoteDeviceSessions, +} from '@/features/device-link/refreshRemoteSessions'; +import { createLogger } from '@/lib/logger'; +import { makerApiForDevice, orcaWorkflowsForDevice } from '@/lib/makerTransport'; +import { extractIpcError } from '@/utils/ipcError'; + +const log = createLogger('remoteCollabHandoff'); + +/** + * 超时后回查被控端权威终态的次数与间隔。 + * + * 等待已经挪到导航之后(见文件头),不再占着新建页、也不再压着用户的输入,所以这里可以 + * 比「挡在导航前」时期给得从容一些。但仍然**有界** —— 被控端可能永远不返回,无界等待只会 + * 把首轮永久挂起;查不到就 fail-closed 按失败降级,绝不把「没建成」猜成「建成了」。 + */ +const TIMEOUT_RECOVERY_ATTEMPTS = 6; +const TIMEOUT_RECOVERY_DELAY_MS = 3000; +/** + * 整段回查的总时限。 + * + * 只限次数不限时间是不够的:链路「可连但每个 invoke 都黑洞」时,每次 listWorkersByLead + * 自己就要走满 30s 隧道超时,6 次串行 + 5×3s 退避 ≈ 3 分钟 —— 而这段时间 composer 是 + * 锁住的、首轮压着不发。次数上限只约束了退避总和(15s),没约束探针自身的耗时 + * (codex P2 第五轮)。 + * + * 所以再加一道总 deadline:两者谁先到都停,回查最坏 30s 收尾,叠加最初 enableOrca 的 + * 30s 超时,composer 最坏锁约 1 分钟而不是 3 分钟。超时后仍走 fail-closed 降级, + * 并由 finally 的镜像回流让被控端稍后提交的情况自愈。 + */ +const TIMEOUT_RECOVERY_DEADLINE_MS = 30_000; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * `DEVICE_LINK_TIMEOUT` **不是权威失败** —— 隧道超时只删掉控制端的等待项,被控端那次 + * enableOrca 仍在继续跑(device-link ipc.ts 对 INVOKE_TIMEOUT 的判定就是「远端仍存活」)。 + * 把它和 PRECONDITION_FAILED / INVALID_PARAMS 这类权威拒绝混为一谈,会让「被控端起 Worker + * 慢了一点」被当成「协同没开起来」:首轮照普通单会话发出,而团队其实马上就建好了 + * (issue #1170 codex P1 第二轮)。 + */ +function isAmbiguousTimeout(err: unknown): boolean { + return extractIpcError(err)?.code === 'DEVICE_LINK_TIMEOUT'; +} + +/** + * 超时后回查被控端 DB 的权威终态:worker 已落库 = 团队真的建成了。 + * + * 用 `listWorkersByLead` 而不是 `getByLeadSession`:非空 worker 列表同时证明 team 与首个 + * Worker 都已提交(team 建了但 worker 还没落库的中间态按未完成处理,fail-closed),而且 + * 顺带拿到 reveal 需要的 workerSessionId。 + * + * 归属用调用方手里的 deviceId(`orcaWorkflowsForDevice`),不重新解析易失的 session origin。 + */ +async function recoverTimedOutTeam( + p: RemoteCollabEnableParams, +): Promise<{ focusWorkerSessionId: string } | null> { + const deadline = Date.now() + TIMEOUT_RECOVERY_DEADLINE_MS; + for (let attempt = 0; attempt < TIMEOUT_RECOVERY_ATTEMPTS; attempt += 1) { + // 次数与时间两道闸,谁先到都停 —— 探针自身可能各走满 30s 隧道超时(见常量注释)。 + if (Date.now() >= deadline) { + log.warn(`[${p.logTag}] remote team probe hit the overall deadline`, { + leadSessionId: p.leadSessionId, + attempt, + }); + return null; + } + if (attempt > 0) await delay(TIMEOUT_RECOVERY_DELAY_MS); + try { + const workers = await orcaWorkflowsForDevice(p.deviceId).listWorkersByLead(p.leadSessionId); + const workerSessionId = workers?.[0]?.sessionId; + if (workerSessionId) { + log.info(`[${p.logTag}] remote enableOrca timed out but the team is committed`, { + leadSessionId: p.leadSessionId, + attempt, + }); + return { focusWorkerSessionId: workerSessionId }; + } + } catch (probeErr) { + // 回查本身失败要**分性质**,不能一律放弃剩余预算(codex P2 第三轮): + // 触发回查的前提就是链路刚抖过(enableOrca 超时),第一次回查撞上同一段抖动 + // 是常态。此时直接 return null,等于「因为链路抖所以判定协同没建成」—— + // 而被控端那次 enableOrca 很可能正在跑完。 + // + // 判据复用 device-link 既有的 isTransientRemoteError(同一条传输、同一类判断, + // 永久错误优先、未知错误不重试)。为它另写一份等价判据,正是本 PR 一路在消灭的形状。 + const transient = isTransientRemoteError( + probeErr instanceof Error ? probeErr.message : String(probeErr), + ); + log.warn(`[${p.logTag}] probing remote team after timeout failed`, { + attempt, + transient, + error: probeErr, + }); + // 永久错误(被控端版本过旧 / 远程已禁用)重试多少次都是同一个结果 → 立即降级。 + if (!transient) return null; + // 瞬态错误:继续用完剩余轮次;全部用尽后走循环外的 fail-closed。 + } + } + return null; +} + +type EnableOrcaOptions = Parameters[1]; + +export interface RemoteCollabEnableParams { + /** 被控设备 deviceId —— Worker 在这台机器上 spawn。 */ + deviceId: string; + /** 刚在被控端建出的 Lead 会话 id。 */ + leadSessionId: string; + /** + * enableOrca 入参。**必须按被控端的模型 / 供应商目录收窄**(草稿侧走 + * `draftEnableOrcaOptions(collab, deviceProviders, …)`)—— 拿控制端目录配出来的模型 + * 在被控端多半不存在,会撞它的精确 preflight。 + */ + options: EnableOrcaOptions; + /** 日志前缀,用于区分是哪条创建路径(如 'draft send' / 'draft goal')。 */ + logTag: string; +} + +/** + * 隧道到被控端 enableOrca,回传可用于 navigate state 的 reveal 载荷。 + * + * **权威失败按原样抛出**:调用方各自决定降级语气(草稿两条路径都是 + * `getCollaborationStartErrorMessage(err, t, { remoteDevice: true })` + 继续单会话)。 + * 这里不吞错 —— 吞掉就没人告诉用户「协同没开起来」,而会话已经建好了。 + * + * **隧道超时先回查再定性**(见 `isAmbiguousTimeout`):超时只说明响应没回来,不说明被控端 + * 没建成。直接当失败放行,会让「被控端起 Worker 慢了几秒」变成「用户明确开了协同,首轮却以 + * 普通单会话跑」。回查到 worker 已落库就照成功返回;查不到才把原始超时抛出去降级。 + */ +export async function enableRemoteCollabForSession( + p: RemoteCollabEnableParams, +): Promise<{ focusWorkerSessionId: string }> { + try { + const result = await makerApiForDevice(p.deviceId).enableOrca(p.leadSessionId, p.options); + return { focusWorkerSessionId: result.workerSessionId }; + } catch (err) { + if (!isAmbiguousTimeout(err)) throw err; + const recovered = await recoverTimedOutTeam(p); + if (recovered) return recovered; + throw err; + } finally { + // 被控端刚建出的 worker session 还没进控制端注册表。fire-and-forget(见文件头 ②)。 + // + // 放在 finally 而不是成功分支里,拿到两件事: + // · 超时回查判定为「没建成」后仍刷一次 —— 被控端可能在回查窗口之后才提交,刷新让 + // orcaRole 回流,CCAgentSessionView 的 external-enable 边沿检测据此补开协同 tab, + // UI 最终与被控端的真实状态收敛(降级只影响首轮,不会永久错判)。 + // · finally 在 catch(含回查)之后才执行,所以这次刷新不会早于被控端提交的判定结果 —— + // 成功路径上 enableOrca 返回即代表 DB 已提交,回查路径上则已经确认过 worker 落库。 + void refreshRemoteDeviceSessions(p.deviceId).catch((err) => { + log.warn(`[${p.logTag}] refresh remote sessions after enableOrca failed`, err); + }); + } +} + +/** + * SessionView 侧的消费入口:发首轮 / 起目标之前先把协同开起来。 + * + * 两处 pending 消费(首条消息、新建目标)共用这一份 —— 逐字重复两遍正是本 PR 反复踩过的 + * 形状。**永不抛出**:协同开不起来时会话本身仍然可用,首轮照单会话继续,只是要如实告诉 + * 用户「这条仍会发出去」(否则用户会以为没发,再提交一次)。 + * + * 返回 true 表示协同已就绪(调用方据此展开协同 tab)。 + */ +export async function consumePendingRemoteCollab( + pending: { deviceId: string; options: Record }, + ctx: { + leadSessionId: string; + logTag: string; + /** 失败时的用户可见提示;调用方注入以复用视图的 i18n / toast。 */ + onFailed: (err: unknown) => void; + }, +): Promise { + try { + await enableRemoteCollabForSession({ + deviceId: pending.deviceId, + leadSessionId: ctx.leadSessionId, + options: pending.options as Parameters[1], + logTag: ctx.logTag, + }); + return true; + } catch (err) { + log.error(`[${ctx.logTag}] remote enableOrca failed (continuing as single session)`, err); + ctx.onFailed(err); + return false; + } +} diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 34783ea3960..0839fa46bbf 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -4644,6 +4644,10 @@ "openHint": "Bring in a Worker to help", "disabledHint": "Collaboration is disabled for this project. Enable it in project settings.", "unavailableHint": "Unable to verify this project's collaboration setting. Try again shortly.", + "unsupportedRemoteHint": "The controlled device is too old to support collaboration.", + "remoteContinueNotice": " This message is still being sent as a single conversation.", + "handoffRecoveredMessage": "Collaboration startup was interrupted last time. Your unsent message has been restored to the composer.", + "handoffRecoveredGoal": "Collaboration startup was interrupted last time. The goal was not started; its text has been restored to the composer.", "loadingHint": "Still checking this project's collaboration setting. Try again shortly.", "stopHint": "Stop collaboration", "stopAria": "Stop collaboration", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 659570bf86a..5c1b6580b27 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -4643,6 +4643,10 @@ "openHint": "Worker を呼んで協同する", "disabledHint": "このプロジェクトでは協同モードが無効です。プロジェクト設定で有効にしてください。", "unavailableHint": "このプロジェクトの協同設定を確認できません。しばらくしてから再試行してください。", + "unsupportedRemoteHint": "被操作デバイスのバージョンが古いため、協同を利用できません。", + "remoteContinueNotice": "このメッセージは単一の対話として送信されます。", + "handoffRecoveredMessage": "前回は協同の開始が中断されました。送信されなかったメッセージを入力欄に復元しました。", + "handoffRecoveredGoal": "前回は協同の開始が中断されました。ゴールは開始されておらず、その内容を入力欄に復元しました。", "loadingHint": "このプロジェクトの協同設定を確認中です。しばらくしてから再試行してください。", "stopHint": "協同を停止", "stopAria": "協同を停止", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 32d118195c8..87a2729c590 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -4643,6 +4643,10 @@ "openHint": "Worker 를 불러 함께 작업", "disabledHint": "이 프로젝트에서는 협업 모드가 비활성화되어 있습니다. 프로젝트 설정에서 활성화하세요.", "unavailableHint": "이 프로젝트의 협업 설정을 확인할 수 없습니다. 잠시 후 다시 시도하세요.", + "unsupportedRemoteHint": "제어 대상 기기의 버전이 오래되어 협업을 사용할 수 없습니다.", + "remoteContinueNotice": " 이 메시지는 단일 대화로 계속 전송됩니다.", + "handoffRecoveredMessage": "지난번 협업 시작이 중단되었습니다. 전송되지 않은 메시지를 입력창에 복원했습니다.", + "handoffRecoveredGoal": "지난번 협업 시작이 중단되었습니다. 목표가 시작되지 않아 내용을 입력창에 복원했습니다.", "loadingHint": "이 프로젝트의 협업 설정을 확인하는 중입니다. 잠시 후 다시 시도하세요.", "stopHint": "협업 중지", "stopAria": "협업 중지", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 9bc12531ad9..6369b696673 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -4643,6 +4643,10 @@ "openHint": "召集 Worker 协作", "disabledHint": "当前项目已关闭协同模式,请在项目设置中重新启用", "unavailableHint": "暂时无法确认当前项目的协同设置,请稍后重试", + "unsupportedRemoteHint": "被控设备的版本过旧,暂不支持协同", + "remoteContinueNotice": "本条消息仍会以单对话继续发送。", + "handoffRecoveredMessage": "上次开启协同时被中断,未发出的消息已恢复到输入框", + "handoffRecoveredGoal": "上次开启协同时被中断,目标未启动,内容已恢复到输入框", "loadingHint": "正在确认当前项目的协同设置,请稍后重试", "stopHint": "停止协同", "stopAria": "停止协同", diff --git a/apps/desktop/src/renderer/lib/makerTransport.ts b/apps/desktop/src/renderer/lib/makerTransport.ts index c243a454baf..73d19446b3c 100644 --- a/apps/desktop/src/renderer/lib/makerTransport.ts +++ b/apps/desktop/src/renderer/lib/makerTransport.ts @@ -151,6 +151,23 @@ export function makerApiFor(sessionId: string): RoutableMaker { return deviceId ? makerApiForDevice(deviceId) : window.electronAPI.maker; } +/** + * 粘滞归属版 maker 入口:曾解析到 deviceId 的会话,在 relay 瞬时重连清空注册表的窗口内 + * 仍走隧道,不会退回本机。 + * + * 用于「误判本机会产生副作用」的 **mutation**(与 isRemoteSessionSticky 同一判据,只是那条 + * 服务于 gating、这条服务于调用)。协同开关就是典型:enableOrca / disableOrca 在瞬断窗口内 + * 被误判成本机,会在**控制端本机**建出或销毁一个 team —— 本机恰好存在同 id 会话时还会操作 + * 错对象,而用户看到的入口(按粘滞 remoteDeviceId 渲染)分明指向被控端(issue #1170 codex P2)。 + * + * 普通高频操作(send / setModel / …)仍用 makerApiFor:它们本就跟随会话来源的实时判定, + * 且误判的代价是一次失败重试,不是在错误的机器上留下持久状态。 + */ +export function makerApiForSticky(sessionId: string): RoutableMaker { + const deviceId = getStickySessionDeviceId(sessionId); + return deviceId ? makerApiForDevice(deviceId) : window.electronAPI.maker; +} + /** 是否远程(device-link)会话。 */ export function isRemoteSession(sessionId: string): boolean { return getSessionDeviceId(sessionId) !== undefined; @@ -323,6 +340,31 @@ export function estimatedSessionValueFor(sessionId: string): Promise<{ ]) as ReturnType; } +/** + * 插件启停状态(只读):**按目标设备**读项目级 / 用户级 collab 等开关。 + * + * device-link 会话与草稿的 workingDir 是**被控端**机器上的路径,拿它在控制端本机查 + * `.cindy/plugins.json` 读到的是控制端自己的用户级开关 —— 与被控端 main 的权威授权 + * (assertCollabProjectEnabled)可能相反,于是入口看得见却开不起来(issue #1170)。 + * 所以这里按 deviceId 分流:本机 → 真 IPC;远程 → 隧道到被控端读它自己的真相。 + * + * 路径归一化由调用方在控制端完成:normalizeWorkingDirForProjectSettings 是纯路径形态 + * 推导(不依赖 process.platform / 本机 userData),跨 macOS ↔ Windows 控制同样成立。 + * + * 老被控端未收录该 channel 时隧道回 DEVICE_LINK_CHANNEL_NOT_ALLOWED,调用方据此 + * fail-closed 置灰入口并说明「设备版本过旧」,不会放行到 enableOrca 才撞错。 + */ +export function pluginEnableStateFor( + deviceId: string | null | undefined, + pluginId: string, + workingDir?: string, +): ReturnType { + if (!deviceId) return window.electronAPI.maker.plugins.getState(pluginId, workingDir); + return invokeRemote(deviceId, 'maker:plugins:get-state', [pluginId, workingDir]) as ReturnType< + typeof window.electronAPI.maker.plugins.getState + >; +} + /** 会话内搜索跳转定位:远程走隧道 local-db:messages:around(否则查控制端空库,跳转必失败)。 */ export function aroundMessagesFor( sessionId: string, @@ -574,6 +616,15 @@ export function orcaWorkflowsFor(contextSessionId: string): RoutableOrcaWorkflow return deviceId ? remoteOrcaWorkflows(deviceId) : window.electronAPI.localDb.orcaWorkflows; } +/** + * 已知稳定 deviceId 时直接返回远程 orca 适配器,不重新读取易失的 session origin + * (与 makerApiForDevice 同款)。用于「调用方手里已经握着权威 deviceId」的场景 —— + * 例如刚在该被控端建出会话、要回查它的权威团队终态。 + */ +export function orcaWorkflowsForDevice(deviceId: string): RoutableOrcaWorkflows { + return remoteOrcaWorkflows(deviceId); +} + /** * 订阅某 lead 的 orca worker 变更并在变更时回调: * - 本机 lead → 本机 `onOrcaWorkerChanged` IPC(按 leadSessionId 过滤)。 diff --git a/apps/desktop/src/renderer/state/newMakerDraft.ts b/apps/desktop/src/renderer/state/newMakerDraft.ts index ac6dde9840d..6f54901d415 100644 --- a/apps/desktop/src/renderer/state/newMakerDraft.ts +++ b/apps/desktop/src/renderer/state/newMakerDraft.ts @@ -414,12 +414,24 @@ export function patchDraft(patch: Partial): void { next.deviceLinkDeviceId = null; next.deviceLinkDeviceName = null; } - // device-link 草稿同样禁用协同(同 remoteHostId:worker 创建拿不到 deviceId)。 - if (next.deviceLinkDeviceId != null && next.collab.enabled) { - next.collab = { ...next.collab, enabled: false }; + // 换目标设备(含本机 ↔ 被控设备、被控设备 A ↔ B)→ 丢掉 Worker 富配置,只留 + // enabled + worker。model / providerId / effort / fast 都是**设备作用域**的:被控端 + // 装的模型目录、连的供应商都是它自己那一套,原样透传过去会撞被控端 main 的精确 + // preflight(INVALID_PARAMS / PROVIDER_ROUTE_UNAVAILABLE),协同静默降级成单会话 —— + // 正是 issue #1170 抱怨的「入口能点但走不完」。清掉后由被控端按自己的默认值起 + // Worker,与草稿里模型 pill 换设备重新校准的既有行为一致。 + // + // 放在 store 层而不是 applyDraftTarget:换设备有四条路径(设备 pill、设备域浏览器、 + // 工作区 picker、所选设备失效后的自动回落),挂在这里全部自动覆盖,新增第五条也不用 + // 再对齐一格(与 NewMakerDraftRoute.applyDraftTarget 的「按什么变了而不是走了哪条 + // 路径」同一思路)。 + if (currentDraft.deviceLinkDeviceId !== next.deviceLinkDeviceId && next.collab.workerConfig) { + next.collab = { ...next.collab, workerConfig: undefined }; } - // remote 项目 draft 的协同 codex / cc 均已接通(worker 创建已继承 - // remoteHostId,远端 MCP 注入两端落地),不再按 vendor 强制关闭。 + // device-link 与 SSH remote 项目 draft 的协同都已接通(device-link 的 enableOrca / + // worker 创建 / 团队读写经隧道在被控端执行;SSH 的 worker 创建继承 remoteHostId、 + // 远端 MCP 注入两端落地),不再按目标或 vendor 强制关闭。仍然关闭的只有「对话模式」 + // (workingDir == null,见上方级联)与 Orca Worker 子会话(会话侧判定)。 currentDraft = next; scheduleWrite(currentDraft); emit(); diff --git a/apps/desktop/src/renderer/state/pendingFirstMessage.ts b/apps/desktop/src/renderer/state/pendingFirstMessage.ts index 5ad062de7a7..4657dccb17d 100644 --- a/apps/desktop/src/renderer/state/pendingFirstMessage.ts +++ b/apps/desktop/src/renderer/state/pendingFirstMessage.ts @@ -11,12 +11,31 @@ * * 仅内存(模块级 Map):app 重启意味着发送链路被打断,丢弃不持久化。 * 一次性消费(consume 即删):防止重复发送。 + * + * 例外见文件末尾「远程协同交接的可恢复副本」:device-link 开协同那条路径在 consume + * 之后还要等被控端起 Worker,等待期间正文另存一份到 localStorage,重启后回填输入框。 */ import type { AttachedFile, MentionedResource } from '@/lib/fileTypes'; import type { PastedTextRange, SlashCommandRange } from '@/lib/imageRef'; import type { AgentInputReference } from '@cindy/maker-shared/agent-input-projection'; +/** + * device-link 草稿开了协同时,把「开协同」这件事一起交接给 SessionView(issue #1170)。 + * + * 为什么不在 draft route 就 await 掉:被控端起 Worker 是一次隧道往返,可能一路走到 + * invoke 默认 30s 超时。把它挡在 navigate 前面,既让新建页凭空卡住半分钟,又把 + * 「对端会话已建好、用户输入还只在内存里」的窗口拉到同样长度(greptile P1)。 + * 而首轮必须排在协同之后又是硬要求 —— 两者只能靠「导航后再协调」同时满足: + * draft route 登记完就立刻 navigate,SessionView 消费时先 await 开协同、再发首轮。 + */ +export interface PendingRemoteCollab { + /** 被控设备 deviceId —— Worker 在这台机器上 spawn。 */ + deviceId: string; + /** enableOrca 入参,已按被控端的模型 / 供应商目录收窄。 */ + options: Record; +} + export interface PendingPayload { text: string; files?: AttachedFile[]; @@ -26,6 +45,8 @@ export interface PendingPayload { agentReferences?: AgentInputReference[]; pastedTextRanges?: PastedTextRange[]; slashCommandRanges?: SlashCommandRange[]; + /** 非空 = 发首轮之前先在被控端开协同(见 PendingRemoteCollab)。 */ + remoteCollab?: PendingRemoteCollab; /** 调试用——createPending 时刻,过期清理时可参考(目前未做 GC,实际场景 navigate 立即消费)。 */ createdAt: number; } @@ -66,6 +87,8 @@ export function hasPending(sessionId: string): boolean { export interface PendingGoalPayload { objective: string; limits: { maxTurns: number | null; budgetTokens: number | null; noProgressLimit: number | null }; + /** 与首条消息同款:非空 = 起目标首轮之前先在被控端开协同。 */ + remoteCollab?: PendingRemoteCollab; createdAt: number; } @@ -88,8 +111,203 @@ export function consumePendingGoal(sessionId: string): PendingGoalPayload | null return v; } +// ─── 远程协同交接的可恢复副本 ────────────────────────────────────────────────── +// +// 为什么只有 remoteCollab 这条路径要落盘(greptile P1): +// 其余创建路径的 setPending → navigate → mount → consume → sendMessage 是一串毫秒级 +// 步骤,内存 Map 与渲染进程同生共死不构成实际风险。而 **device-link 远程交接**这条不同 —— +// consume 之后还要 await 隧道往返:开协同要等被控端起 Worker(正常一两秒,慢设备会一路走到 +// 30s 隧道超时再加 6×3s 回查),起目标之前还要先 await 一次 `deviceLink.subscribe` +// (同样是 invoke,同样可能走到 30s)。这段时间用户输入只存在于渲染进程内存里, +// app 被关掉就永久消失,被控端还留着一个没有首轮的空会话。 +// +// 所以判据是「**这是不是一次远程交接**」,不是「开没开协同」:只挡在开协同那一段前面, +// 非协同的 device-link 起目标照样会在 subscribe 那次 await 里丢掉目标正文。 +// +// 取舍: +// · 只存**正文**,不存附件 —— 与本仓既有取舍一致(newMakerDraft 头部:「附件 → 丢失 +// (产品决策)」),也避免把附件字节写进 localStorage。 +// · 恢复时**只回填输入框,绝不自动补发**。用户上次按下发送时的意图未必还成立, +// 重启后凭空冒出一条已发消息比丢失更难解释;回填后由用户自己决定。 +// · 与 PENDING_TTL_MS(60s,针对内存 payload)不同,这份副本要跨重启活着, +// 用天级 TTL 兜底,防止导航失败的残留无限堆积。 + +const RECOVERY_STORAGE_KEY = 'xdt:pendingHandoffRecovery:v1'; +/** 跨重启保留,但不无限堆积:超过此年龄的残留在下次读写时清掉。 */ +const RECOVERY_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export type RecoverableHandoffKind = 'message' | 'goal'; + +interface RecoverableHandoff { + kind: RecoverableHandoffKind; + text: string; + createdAt: number; +} + +let activeDataOwnerId: string | null = null; + +function recoveryStorageKey(): string { + return activeDataOwnerId + ? `${RECOVERY_STORAGE_KEY}:${encodeURIComponent(activeDataOwnerId)}` + : RECOVERY_STORAGE_KEY; +} + +/** 与 composerDraftStore / newMakerDraft 同款:按数据归属人分命名空间,换账号不串台。 */ +export function setPendingHandoffOwner(ownerId: string | null): void { + activeDataOwnerId = ownerId; +} + +/** + * 读全表并剔除过期 / 损坏项。localStorage 不可用或 schema 损坏时静默回退空表。 + * + * **剔除必须落盘**:只从返回值里过滤掉,原始条目会一直留在磁盘上 —— 之后如果这个账号 + * 再没写过新的交接项,就永远没人重写那份 JSON,用户的正文实际上被无限期保存, + * 与声明的 TTL 和「持久数据要有明确生命周期」不符(codex P2 第三轮)。 + * 所以这里回传是否发生过剔除,由 `loadRecoveryTable` 立刻写回清理后的表。 + */ +function parseRecoveryTable(raw: string): { + table: Record; + pruned: boolean; +} { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // 整份 JSON 都读不懂:当空表处理,并按"有剔除"落盘覆盖掉这份垃圾。 + return { table: {}, pruned: true }; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { table: {}, pruned: true }; + } + const now = Date.now(); + const table: Record = {}; + let pruned = false; + for (const [sessionId, value] of Object.entries(parsed as Record)) { + const entry = (value ?? {}) as Partial; + const usable = + !!value && + typeof value === 'object' && + typeof entry.text === 'string' && + entry.text !== '' && + (entry.kind === 'message' || entry.kind === 'goal') && + typeof entry.createdAt === 'number' && + now - entry.createdAt <= RECOVERY_TTL_MS; + if (!usable) { + pruned = true; + continue; + } + table[sessionId] = { + kind: entry.kind as RecoverableHandoffKind, + text: entry.text as string, + createdAt: entry.createdAt as number, + }; + } + return { table, pruned }; +} + +/** 读全表;发生过剔除就立刻把清理后的表写回,不让过期正文赖在磁盘上。 */ +function readRecoveryTable(): Record { + let raw: string | null; + try { + raw = window.localStorage.getItem(recoveryStorageKey()); + } catch { + return {}; + } + if (!raw) return {}; + const { table, pruned } = parseRecoveryTable(raw); + if (pruned) writeRecoveryTable(table); + return table; +} + +function writeRecoveryTable(table: Record): void { + try { + if (Object.keys(table).length === 0) { + window.localStorage.removeItem(recoveryStorageKey()); + return; + } + window.localStorage.setItem(recoveryStorageKey(), JSON.stringify(table)); + } catch { + // localStorage 满 / 私密窗口禁写 —— 忽略。副本是尽力而为的兜底, + // 写不进去也不该把首轮发送本身弄失败。 + } +} + +/** 进入远程协同等待**之前**调用:留下一份正文,等待期间 app 被关掉也能捞回来。 */ +export function rememberRecoverableHandoff( + sessionId: string, + kind: RecoverableHandoffKind, + text: string, +): void { + if (!sessionId || text === '') return; + const table = readRecoveryTable(); + table[sessionId] = { kind, text, createdAt: Date.now() }; + writeRecoveryTable(table); +} + +/** + * 丢掉副本。**模块私有** —— 唯一的公开入口是 `deliverRecoverableHandoff`。 + * + * 不导出是刻意的:副本的删除条件("正文已经有了新的归宿")在本 PR 的 review 里被反复 + * 判错,每次都是某个调用点自己就地判断"这算交付了吧"。只要还能裸调它,下一处就还会 + * 再判错一次。收进 deliver 之后,"什么时候可以删"只剩一处可改。 + */ +function forgetRecoverableHandoff(sessionId: string): void { + if (!sessionId) return; + const table = readRecoveryTable(); + if (!(sessionId in table)) return; + delete table[sessionId]; + writeRecoveryTable(table); +} + +/** + * 把正文交出去,**只有确认交付成功才丢副本**。 + * + * 这是删除副本的唯一途径。三处交接(命令派发 / 首轮 sendMessage / 起目标 setGoal) + * 全部走这里,于是"交付成功"只有一个判据: + * + * · `deliver()` resolve `true` → 确实交出去了 → 丢副本; + * · `deliver()` resolve `false` → **没交出去**,副本必须留着; + * · `deliver()` 抛错 → 同样没交出去 → 保留副本,错误照常向上冒泡。 + * + * 第二条是本轮 codex P1 的要害:`sendMessage` 在设备离线 / 访问被撤销 / 远端 + * `maker:input:enqueue` 拒绝时**不抛错**,而是 resolve `false`,并且对远程会话还会 + * 把那条乐观气泡从 transcript 里撤掉 —— 不 await 就删副本,等于正文从界面和磁盘上 + * 同时消失,而内存 pending 早已消费、新建页草稿也已清空。 + */ +export async function deliverRecoverableHandoff( + sessionId: string, + deliver: () => boolean | Promise, +): Promise { + const delivered = await deliver(); + if (delivered) forgetRecoverableHandoff(sessionId); + return delivered; +} + +/** + * 取出并清除某会话的可恢复正文;kind 不匹配时不动它(首条消息与目标各自恢复各自的)。 + * 返回 null = 没有待恢复内容(绝大多数情况)。 + */ +export function takeRecoverableHandoff( + sessionId: string, + kind: RecoverableHandoffKind, +): string | null { + if (!sessionId) return null; + const table = readRecoveryTable(); + const entry = table[sessionId]; + if (!entry || entry.kind !== kind) return null; + delete table[sessionId]; + writeRecoveryTable(table); + return entry.text; +} + /** 测试用。 */ export function __clearAllForTest(): void { map.clear(); goalMap.clear(); + activeDataOwnerId = null; + try { + window.localStorage.removeItem(RECOVERY_STORAGE_KEY); + } catch { + // ignore + } } diff --git a/docs/dev-rules/orca-team-architecture.md b/docs/dev-rules/orca-team-architecture.md index 91bf13d2d4b..9ccfa5784bc 100644 --- a/docs/dev-rules/orca-team-architecture.md +++ b/docs/dev-rules/orca-team-architecture.md @@ -38,7 +38,7 @@ Orca 是 Cindy Desktop 内的多 agent 协同能力:一个 **Lead session** - 多 worker:同一个 active team 下可创建多个 worker,支持 role、label、focused worker 切换、soft/hard limit 与归档。 - Split view:Lead 与 focused Worker 共用 `OrcaSplitView` pane 外壳,宽屏为左右 split,doc rail 为 Lead/Worker toggle。见 `apps/desktop/src/renderer/features/cc-agent/OrcaSplitView.tsx` 的 `OrcaSplitView`、`OrcaPaneShell`。 -- Claude Code 与 Codex 都可作为本地项目 Lead;SSH 远端会话两端均可作 Lead(远端 agent 经 SSH remote-forward 直连本机 HTTP MCP bridge,`cindy_orca` 在两端都可用:codex 走 daemon config 注入,cc 走 per-query http 注入)。renderer gate 排除 worker,remote session 两端都显示入口,见 `apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx` 的 `allowCollabToggle`。 +- Claude Code 与 Codex 都可作为本地项目 Lead;SSH 远端会话两端均可作 Lead(远端 agent 经 SSH remote-forward 直连本机 HTTP MCP bridge,`cindy_orca` 在两端都可用:codex 走 daemon config 注入,cc 走 per-query http 注入);device-link 被控端项目同样可作 Lead(Lead / Worker / team 的真身都在被控端,控制端只是镜像)。renderer 的入口判定收敛在 `apps/desktop/src/renderer/features/cc-agent/collabEntryPolicy.ts` 的 `resolveCollabEntryPolicy`,新建草稿(`NewMakerDraftRoute`)与会话视图(`CCAgentSessionView` 的 `allowCollabToggle`)共用同一份:排除 Orca Worker 子会话与对话模式(无项目目录),本地 / SSH / device-link 三类项目都显示入口。 - Codex Lead 使用全局注册的 `cindy_orca`,调用时通过 context 恢复身份并在 handler 内拒绝越权。远端 Codex 同样走 `params._meta.threadId` 路由——remote thread 与本地 thread 一样注册进 `CodexMcpThreadContextStore`(`packages/maker-core/src/agents/codex/index.ts` 的 `registerCodexMcpContext` 不再跳过 remoteHostId)。远端 cc 没有 threadId,身份走持久 bearer token + URL `?session=` 路由(`codexHttpBridge.registerSessionCtx`),审批归属快照在 `remoteCcQueryFactory` 注入后按 `startParams.mcpServers` 最终清单定稿。 - SSH 远端 Codex 的 MCP 桥接:本机 `codexHttpBridge` 在原有 per-run 主 token 之外接受一个 persistent bearer token(safeStorage);`remote-ssh/codex-remote-mcp.ts` 在 session start/resume 前置完成 per-host 固定端口 remote-forward(`RemoteHost.openRemoteForward`,重连自动 rebind)、远端 `$CODEX_HOME/config.toml` 的 `mcp_servers` 管理段漂移检测(行级 marker + 剥离用户同名 table)与 daemon 幂等 bootstrap(token 只经 stdin 的 KEY=value 块注入,不进 argv);config 漂移需要重启 daemon 时若同 host 有 live turn 则本次降级(留待下次 ensure)。worker 创建经 `OrcaLeadSessionSnapshot.remoteHostId` 继承在同一台远端主机 spawn,创建前走 `ensureRemoteReadyForSessionStart`(SSH 重连 / agent 安装 / MCP 注入);lazy resume 与 Orca worker 唤醒路径同样先 ensure 再 bootstrap。 - PR #107 提供 side_chat 的底层 fork 数据动作:user/assistant 消息都能 fork,assistant 按 turn 粒度复制,Claude 用 uuid 锚点,Codex 用 ThreadFork + ThreadRollback。当前作为普通 session 跳转;未来要登记为 side activity 并挂入统一 pane。 @@ -50,6 +50,7 @@ Orca 是 Cindy Desktop 内的多 agent 协同能力:一个 **Lead session** - Worker archive 后 UI 内没有 unarchive 入口;DB 记录保留,用户需要新建 worker 继续。 - workflow_run / CC Workflow 编排还未纳入当前实现。 - side_chat 尚未登记为 side activity 对象,也未挂进 pane;PR #107 只是 fork 数据动作。 +- device-link 协同的 Lead / Worker / team 全部在被控端进程内编排,控制端只按 session 来源经隧道路由(`makerTransport` 的 `makerApiFor` / `orcaWorkflowsFor` / `subscribeOrcaWorkerChanged`,channel 见 `packages/device-link/src/allowlist.ts` 的 Orca 段)。项目级 collab 开关同样查被控端(`maker:plugins:get-state` 经 `pluginEnableStateFor`)——控制端拿被控端的路径查自己本机只会读到自己的用户级开关,判定可能与被控端 main 的 `assertCollabProjectEnabled` 相反。老被控端没有该 channel 时回 `CHANNEL_NOT_ALLOWED`,控制端 fail-closed 置灰入口并提示设备版本过旧,而不是放行到 `enableOrca` 才撞错。 - SSH 远端协同支持 codex 与 claude-code 两类 lead + 远端 worker(继承 remoteHostId)。cc 远端经 `cc-remote-mcp.ts` 把 `cindy_orca` / `orca_worker_bridge` 以 http 形态追加进 `startParams.mcpServers`(persistent token + `?session=` 路由,白名单仅此两个 server)。远端 worker 手动 `send_to_lead` 依赖 daemon 侧 `orca_worker_bridge` 经同一 bridge 可达;auto-bridge 回报不依赖 worker 侧 MCP,天然可用。共享 userData 多实例连同一远端 host 时,只有先建立 SSH 转发的实例能持有该 host 的 MCP bridge 端口,其余实例按“远端无 MCP”降级(与历史行为一致)。远端会话的项目级 collab 开关不查本机 fs(`assertCollabProjectEnabled` 对 remote 跳过 `isPluginEnabled`;远端项目级配置机制是 follow-up)。 ### 核心概念与数据模型 diff --git a/packages/device-link/src/allowlist.ts b/packages/device-link/src/allowlist.ts index 1036e6746b8..f2512802de8 100644 --- a/packages/device-link/src/allowlist.ts +++ b/packages/device-link/src/allowlist.ts @@ -355,6 +355,14 @@ const EXTENDED_INVOKE_CHANNELS: readonly string[] = [ 'maker:scan-at-resources', // —— 插件列表(只读)—— 'maker:plugins:list', + // 单个插件的启停状态(只读)。与 maker:plugins:list 同类,差别只在它不跳过 + // HOSTED_ELSEWHERE 插件、且按 id 精确查。准入三条:handler 只读 settings + 项目 + // `.cindy/plugins.json`,不依赖 event.sender、无 UI/shell 副作用;插件启停真相在 + // 被控端(控制端拿被控端的路径查自己本机只会读到自己的用户级开关,判定可能与被控端 + // main 的 assertCollabProjectEnabled 相反 —— issue #1170 的「入口能点但走不完」)。 + // 用途:device-link 项目的协同入口按被控端的项目级 collab 开关置灰。老被控端无此 + // channel → CHANNEL_NOT_ALLOWED → 控制端 fail-closed 置灰并提示设备版本过旧。 + 'maker:plugins:get-state', // —— 路径解析(被控端解析语义正确;新建会话选目录用)—— 'fs:resolve-path', 'fs:resolve-path-batch',