diff --git a/apps/desktop/src/main/__tests__/relaunchBusyActivity.test.ts b/apps/desktop/src/main/__tests__/relaunchBusyActivity.test.ts new file mode 100644 index 00000000000..b90e2262f95 --- /dev/null +++ b/apps/desktop/src/main/__tests__/relaunchBusyActivity.test.ts @@ -0,0 +1,162 @@ +/** + * 手动更新重启的阻断判定 —— 六个活动来源的聚合与 fail-closed。 + * + * 这个判定服务的是不可撤销的破坏性动作(forceQuit → process.exit(0)),所以两条不变量: + * 1. **任一来源报忙就是忙**(六源等价,没有主次); + * 2. **任一来源读不出来也算忙**(「无法确认」不等于「确认没有」)。 + * 每个来源各有一条独立用例 —— 少一条就意味着少覆盖一个真实的静默中断入口。 + * + * 有两个来源特别容易被漏,各自都有独立证据:scheduler 的 script 模式 / pre-run hook 阶段不 + * 创建 session;run_in_background 的 Bash 不调模型(点不亮 loopback 信号)也不折算 running。 + * 两者都只能单独查。 + */ + +import { describe, expect, it } from 'vitest'; + +import { evaluateRelaunchBusyActivity } from '../relaunchBusyActivity.js'; + +const idle = { + anySessionInTurn: () => false, + listClaudeBackgroundSessions: () => [] as readonly string[], + anyGhostSessionBusy: () => false, + anyBackgroundBashRunning: () => false, + anyCindySlotJobRunning: () => false, + anySchedulerRunRunning: async () => false, +}; + +describe('evaluateRelaunchBusyActivity', () => { + it('全部空闲时不阻断', async () => { + await expect(evaluateRelaunchBusyActivity(idle)).resolves.toEqual({ busy: false, reasons: [] }); + }); + + it('逻辑 turn 在跑时阻断', async () => { + const r = await evaluateRelaunchBusyActivity({ ...idle, anySessionInTurn: () => true }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['session-in-turn']); + }); + + it('Claude 后台活动(turn 已结束但仍在调模型)时阻断', async () => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + listClaudeBackgroundSessions: () => ['sess-a'], + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['claude-background-activity']); + }); + + it('Ghost card-action 后台活动时阻断(它完全不经 LLM turn)', async () => { + const r = await evaluateRelaunchBusyActivity({ ...idle, anyGhostSessionBusy: () => true }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['ghost-background-activity']); + }); + + it('多个来源同时命中时全部记进 reasons(不短路,便于诊断)', async () => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + anySessionInTurn: () => true, + listClaudeBackgroundSessions: () => ['sess-a'], + anyGhostSessionBusy: () => true, + anyBackgroundBashRunning: () => true, + anyCindySlotJobRunning: () => true, + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual([ + 'session-in-turn', + 'claude-background-activity', + 'ghost-background-activity', + 'background-bash', + 'cindy-slot-async-job', + ]); + }); + + it.each([ + ['anySessionInTurn', 'session-in-turn'], + ['listClaudeBackgroundSessions', 'claude-background-activity'], + ['anyGhostSessionBusy', 'ghost-background-activity'], + ['anyBackgroundBashRunning', 'background-bash'], + ['anyCindySlotJobRunning', 'cindy-slot-async-job'], + ] as const)('%s 抛错时 fail closed 并标记探针失败', async (key, label) => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + [key]: () => { throw new Error('probe exploded'); }, + }); + expect(r.busy).toBe(true); + // 标签区分「真的有活动」与「探针坏了」—— 两者都拦,但排查方向完全不同。 + expect(r.reasons).toEqual([`${label}-probe-failed`]); + }); + + it('一个来源抛错不影响其它来源继续被读到', async () => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + anySessionInTurn: () => { throw new Error('probe exploded'); }, + anyGhostSessionBusy: () => true, + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['session-in-turn-probe-failed', 'ghost-background-activity']); + }); + + // 后台 Bash(run_in_background):不调模型 → 点不亮 Claude 后台活动信号;不折算 running → + // 逻辑 turn 也看不到。重启会直接杀掉 dev server / 长跑脚本这类子进程。 + it('后台 Bash 任务在跑时阻断(其它内存源全空闲也要拦)', async () => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + anyBackgroundBashRunning: () => true, + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['background-bash']); + }); + + // Cindy slot 异步代办(mode:'submit' 的图片 / 视频生成):void runExec() 脱链执行,只记在 + // GhostCindySlot 私有 jobs Map,发起 turn 结束后其它来源全看不到。 + it('Cindy slot 异步代办在途时阻断', async () => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + anyCindySlotJobRunning: () => true, + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['cindy-slot-async-job']); + }); + + // scheduler:script 模式与 pre-run hook 阶段的 run 都不创建 session,内存探针看不到 —— + // 漏掉它意味着重启会让 run 来不及落终态、脚本子进程变成失联进程。 + it('scheduler 有 run 在跑时阻断(内存源全空闲也要拦)', async () => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + anySchedulerRunRunning: async () => true, + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['scheduler-run-running']); + }); + + it('scheduler 查询 reject 时 fail closed', async () => { + const r = await evaluateRelaunchBusyActivity({ + ...idle, + anySchedulerRunRunning: async () => { throw new Error('sqlite is gone'); }, + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['scheduler-run-probe-failed']); + }); + + it('内存源已命中时不再查 scheduler(省一次 SQLite 往返)', async () => { + let called = 0; + const r = await evaluateRelaunchBusyActivity({ + ...idle, + anySessionInTurn: () => true, + anySchedulerRunRunning: async () => { called += 1; return false; }, + }); + expect(r.busy).toBe(true); + expect(called).toBe(0); + }); + + it('查库期间新起的 turn 会被二次采样抓到', async () => { + let turnRunning = false; + const r = await evaluateRelaunchBusyActivity({ + ...idle, + // 第一次读为空闲;scheduler 查询期间 turn 起来,复采时才为 true。 + anySessionInTurn: () => turnRunning, + anySchedulerRunRunning: async () => { turnRunning = true; return false; }, + }); + expect(r.busy).toBe(true); + expect(r.reasons).toEqual(['session-in-turn']); + }); +}); diff --git a/apps/desktop/src/main/__tests__/relaunchBusyActivityIpcBoundary.test.ts b/apps/desktop/src/main/__tests__/relaunchBusyActivityIpcBoundary.test.ts new file mode 100644 index 00000000000..a07d5939519 --- /dev/null +++ b/apps/desktop/src/main/__tests__/relaunchBusyActivityIpcBoundary.test.ts @@ -0,0 +1,106 @@ +/** + * 手动重启阻断查询的授权边界。 + * + * 这个 handler 读的是**全局**会话 / Claude / Ghost / scheduler 活动态。带 preload 的窗口被 + * 导航到不可信内容、WebView、子 frame 都能发 Electron IPC,不校验 sender 就等于把「本机现在 + * 在跑什么」暴露给它们。按 docs/dev-rules/electron-security-and-process-boundaries.md §5, + * 新增 handler 不得以「旧代码没校验」为由省略 sender 验证 —— 这里把它钉住。 + * + * 另一条要钉的:**断言必须发生在读取任何跟踪器之前**。先读后拦仍然会碰全局状态(也可能被 + * 时序侧信道观察到),所以拒绝路径下的来源读取次数必须是 0。 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + handlers: new Map unknown>(), + trusted: true, + reads: 0, + removed: [] as string[], +})); + +// 仿 Electron 的真实行为:同一 channel 第二次 handle 直接抛。幂等注册要靠 removeHandler, +// 用一个只会 set 的假 Map 是测不出来的。 +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + if (h.handlers.has(channel)) { + throw new Error(`Attempted to register a second handler for '${channel}'`); + } + h.handlers.set(channel, handler); + }), + removeHandler: vi.fn((channel: string) => { + h.removed.push(channel); + h.handlers.delete(channel); + }), + }, +})); +vi.mock('../logger', () => ({ + createLogger: () => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})); +vi.mock('../security/trustedAppRenderer', () => ({ + assertTrustedAppRendererEvent: () => { + if (!h.trusted) throw new Error('[PERMISSION_DENIED] 此操作只能从 Cindy 主页面发起'); + }, +})); + +import { + RELAUNCH_BLOCKING_ACTIVITY_CHANNEL, + registerRelaunchBusyActivityIpc, +} from '../relaunchBusyActivityIpc.js'; + +/** 每个来源都记一次读取,用来证明拒绝路径下一个都没被碰。 */ +function countingSources(busy: boolean) { + return () => ({ + anySessionInTurn: (): boolean => { h.reads += 1; return busy; }, + listClaudeBackgroundSessions: (): readonly string[] => { h.reads += 1; return []; }, + anyGhostSessionBusy: (): boolean => { h.reads += 1; return false; }, + anyBackgroundBashRunning: (): boolean => { h.reads += 1; return false; }, + anyCindySlotJobRunning: (): boolean => { h.reads += 1; return false; }, + anySchedulerRunRunning: async (): Promise => { h.reads += 1; return false; }, + }); +} + +/** handler 只把 event 交给 sender 断言(已被 mock),不读它的字段。 */ +const fakeEvent = {} as never; + +beforeEach(() => { + h.handlers.clear(); + h.trusted = true; + h.reads = 0; + h.removed = []; +}); + +describe('registerRelaunchBusyActivityIpc', () => { + it('注册在约定的 channel 上', () => { + registerRelaunchBusyActivityIpc(countingSources(false)); + expect(h.handlers.has(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)).toBe(true); + }); + + it('可信 sender:正常返回判定结果', async () => { + registerRelaunchBusyActivityIpc(countingSources(true)); + const handler = h.handlers.get(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)!; + await expect(handler(fakeEvent)).resolves.toBe(true); + expect(h.reads).toBeGreaterThan(0); + }); + + // splash 首次失败会整段重试注册(bootstrap-electron 那个 catch 明写「下次 splash retry + // 再尝试」),此时 makerIpcsRegistered 仍是 false。若不幂等,第二次 handle 抛出的异常会 + // 把排在后面的全部 maker IPC 注册一起掀掉,且每次重试都卡在同一行。 + it('重复注册不抛错(splash 重试路径),且 handler 仍然可用', async () => { + registerRelaunchBusyActivityIpc(countingSources(true)); + expect(() => registerRelaunchBusyActivityIpc(countingSources(true))).not.toThrow(); + expect(h.removed).toContain(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL); + + const handler = h.handlers.get(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)!; + await expect(handler(fakeEvent)).resolves.toBe(true); + }); + + it('不可信 sender(WebView / 子 frame / 未登记窗口):拒绝,且一个来源都不读', async () => { + h.trusted = false; + registerRelaunchBusyActivityIpc(countingSources(true)); + const handler = h.handlers.get(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL)!; + await expect(handler(fakeEvent)).rejects.toThrow('PERMISSION_DENIED'); + // 断言在读取之前 —— 拒绝路径下不该碰到任何全局跟踪器。 + expect(h.reads).toBe(0); + }); +}); diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index 4a4f1f4e0c4..b6ffb0d2557 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -610,7 +610,9 @@ import { import { installNewMakerWindowShortcut } from './app-shortcuts/new-maker-window-shortcut.js'; import { registerLayoutIpc } from './layout/index.js'; import { + getGhostCindySlot, getGhostManager, + getGhostSessionActivityTracker, isGhostAvailableForActiveSession, refreshGhostLocalization, registerGhostIpc, @@ -618,6 +620,8 @@ import { suspendAllGhosts, waitForGhostMutations, } from './cindy-brain/index.js'; +import { listActiveClaudeBackgroundActivitySessions } from './maker-host/claude-session-background-activity.js'; +import { registerRelaunchBusyActivityIpc } from './relaunchBusyActivityIpc.js'; import { getGhostSetupChangeBus } from './cindy-brain/ghostSetupChangeBus.js'; import { getGhostSetupInteractionBridge } from './cindy-brain/ghostSetupInteractionBridge.js'; import { registerPluginMarketIpc } from './plugin-market/registerIpc.js'; @@ -3811,6 +3815,30 @@ const registerIpcHandlers = () => { readScheduleBusy: () => readUpdateRelaunchScheduleBusy(getScheduleStorageIfInitialized()), }); }); + // 手动更新重启(侧栏 UpdateBanner)的阻断判定。与上面那个**无人值守**探针刻意分开: + // 无人值守要连「有远程设备在看会话」都让路,手动重启是用户主动发起的,只该关心 + // 「这一下会打断哪些正在跑的活」。四个活动来源的聚合与 fail-closed 口径见 + // relaunchBusyActivity.ts,handler 与 sender 断言见 relaunchBusyActivityIpc.ts; + // 这里只提供来源 —— 本进程唯一能同时看到 maker、cindy-brain 与 scheduler 三侧的位置。 + // + // 不进 device-link allowlist:updater 类 channel 按 allowlist 顶部注释属「永不放行」, + // 且远程控制端不会代替用户点被控端的更新重启。 + registerRelaunchBusyActivityIpc(() => ({ + anySessionInTurn: () => anySessionInTurn(getMakerCore()), + listClaudeBackgroundSessions: () => listActiveClaudeBackgroundActivitySessions(), + anyGhostSessionBusy: () => getGhostSessionActivityTracker().anySessionBusy(), + // run_in_background 的 Bash 不调模型、也不折算 running,前两个来源都看不到它。 + anyBackgroundBashRunning: () => + getMakerCore() + .listActiveSessions() + .some((session) => session.listBackgroundTasks().length > 0), + // Cindy slot 的全部在途工作:异步(mode:'submit' 的图 / 视频)与同步代办各自独立记账, + // 都可能不伴随任何 turn 或 card-action,只查一半就漏一半。 + anyCindySlotJobRunning: () => getGhostCindySlot().anyInflightWork(), + // script 模式 / pre-run hook 阶段的 run 不创建 session,内存来源看不到它们。 + anySchedulerRunRunning: () => + readUpdateRelaunchScheduleBusy(getScheduleStorageIfInitialized()), + })); // getMakerCore() 首次调用触发 Maker 构造,同时发起自定义 MCP 初始加载。 // await 确保第一个会话的 mcpProviders 数组已填入已保存的自定义 MCP(P2 冷启动竞态修复)。 getMakerCore(); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/cindySlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/cindySlot.test.ts index a4e31266bb9..cc1bdbe9173 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/cindySlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/cindySlot.test.ts @@ -1307,3 +1307,102 @@ describe('快问快答(oneshot_text)', () => { }); }); }); + +/** + * 更新重启前的阻断探针要问「Cindy slot 现在有没有在干活」。两半状态各自独立记账 + * (异步 jobs / 同步 inflight),只查一半就会漏一半 —— 而漏掉的后果是 forceQuit() + * 连 Ghost Node runtime 一起销毁,正在生成的付费结果直接丢掉。 + */ +describe('anyInflightWork(更新重启阻断探针)', () => { + it('空闲时为 false', () => { + const { slot } = makeSlot(); + expect(slot.anyInflightWork()).toBe(false); + }); + + it('同步代办在途期间为 true,结算后回到 false', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const { slot } = makeSlot({ + generateImage: vi.fn(async () => { + // 请求已计入 inflight、但还没结算的那一刻。 + expect(slot.anyInflightWork()).toBe(true); + await gate; + return { buffer: new Uint8Array([1]), mimeType: 'image/png' }; + }), + } as unknown as Partial); + + const pending = slot.handleModelRequest('art', REQ); + release(); + await pending; + expect(slot.anyInflightWork()).toBe(false); + }); + + // 寄存 / 撤回寄存在进入代办链之前就 return 了,走不到 inflight 记账;被打断会卡在 + // blob 落盘与账本挂引用之间,所以单独记 mediaOps。 + it('寄存在途期间为 true,结算后回到 false', async () => { + const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]); + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + let seenDuring: boolean | null = null; + const { slot } = makeSlot({ + depositMedia: vi.fn(async () => { + seenDuring = slot.anyInflightWork(); + await gate; + return { hash: 'a'.repeat(64), bytes: PNG_BYTES.length, usedBytes: 10, quotaBytes: 100 }; + }), + } as unknown as Partial); + + const pending = slot.handleModelRequest('art', { + type: 'cindy-request', + kind: 'deposit_media', + data: Buffer.from(PNG_BYTES).toString('base64'), + }); + release(); + await pending; + expect(seenDuring).toBe(true); + expect(slot.anyInflightWork()).toBe(false); + }); + + it('寄存抛错也会释放在途计数(finally)', async () => { + const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]); + const { slot } = makeSlot({ + depositMedia: vi.fn(async () => { throw new Error('disk is full'); }), + } as unknown as Partial); + + const r = await slot.handleModelRequest('art', { + type: 'cindy-request', + kind: 'deposit_media', + data: Buffer.from(PNG_BYTES).toString('base64'), + }); + expect(r).toMatchObject({ ok: false }); + // 计数泄漏会让重启入口从此永久卡在「有任务在跑」。 + expect(slot.anyInflightWork()).toBe(false); + }); + + // 异步提交只对视频类开放(图像代办秒级完成,走同步等待)。 + it('异步视频代办(mode:submit)受理后为 true', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const { slot } = makeSlot({ + generateVideo: vi.fn(async () => { + await gate; + return { + buffer: new Uint8Array([1]), + mimeType: 'video/mp4', + videoParams: { durationSeconds: 4, resolution: '720p', ratio: '16:9', fps: 24 }, + }; + }), + } as unknown as Partial); + + const res = await slot.handleModelRequest('art', { + type: 'cindy-request', + kind: 'gen_video', + prompt: '一只猫奔跑', + mode: 'submit', + }); + expect(res).toMatchObject({ ok: true, status: 'running' }); + // 受理即返回,job 仍在途 —— 此时没有任何 turn 级信号还亮着。 + expect(slot.anyInflightWork()).toBe(true); + release(); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostSessionActivity.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostSessionActivity.test.ts index ec1b431808e..9a3640032db 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostSessionActivity.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostSessionActivity.test.ts @@ -118,4 +118,41 @@ describe('GhostSessionActivityTracker', () => { it('TTL 常量覆盖 mivo 单窗轮询(105s)', () => { expect(GHOST_SESSION_ACTIVITY_TTL_MS).toBeGreaterThan(105_000); }); + + // anySessionBusy 给「这个破坏性动作会不会打断正在干的活」这类全局判定用(更新重启前的 + // 阻断探针)。那些调用方没有 sessionId 可传,而 renderer 侧的 store 只靠 0↔1 推送累积、 + // 没有全量快照通道,不能当权威来源。 + describe('anySessionBusy', () => { + it('没有任何在途活动时为 false', () => { + const { tracker } = makeTracker(); + expect(tracker.anySessionBusy()).toBe(false); + }); + + it('任意会话有在途活动即为 true', () => { + const { tracker } = makeTracker(); + tracker.begin('k1', 's1'); + expect(tracker.anySessionBusy()).toBe(true); + }); + + it('多会话时要等最后一个结束才回到 false', () => { + const { tracker } = makeTracker(); + tracker.begin('k1', 's1'); + tracker.begin('k2', 's2'); + tracker.end('k1'); + expect(tracker.isSessionBusy('s1')).toBe(false); + // s2 还在干活 —— 此时重启仍会打断它。 + expect(tracker.anySessionBusy()).toBe(true); + tracker.end('k2'); + expect(tracker.anySessionBusy()).toBe(false); + }); + + it('TTL 兜底熄灭后也回到 false(意识崩了不会永久拦住重启)', () => { + const { tracker, pendingTimerIds, fire } = makeTracker(); + tracker.begin('k1', 's1'); + expect(tracker.anySessionBusy()).toBe(true); + const [ttl] = pendingTimerIds(); + fire(ttl); + expect(tracker.anySessionBusy()).toBe(false); + }); + }); }); diff --git a/apps/desktop/src/main/cindy-brain/cindySlot.ts b/apps/desktop/src/main/cindy-brain/cindySlot.ts index 67574a4c063..a4d9b891047 100644 --- a/apps/desktop/src/main/cindy-brain/cindySlot.ts +++ b/apps/desktop/src/main/cindy-brain/cindySlot.ts @@ -362,6 +362,12 @@ export function stripJsonFences(text: string): string { export class GhostCindySlot { private readonly inflight = new Map(); + /** + * 寄存 / 撤回寄存(deposit_media / release_media)的在途条数。与 inflight 分开记:那个是 + * 代办限流账(getInflightLimit),这个只回答「重启会打断什么」—— 理由见 handleModelRequest + * 里那两个分支的注释。 + */ + private mediaOps = 0; /** 异步代办任务表(jobId → 记录;惰性 sweep,过期即清)。 */ private readonly jobs = new Map(); /** @@ -406,6 +412,12 @@ export class GhostCindySlot { // 而"永不 reject"是本类对沙箱的硬承诺(注入的嗅探/账本抛错也不许穿透)。 if (p?.kind === 'deposit_media' || p?.kind === 'release_media') { const verb = p.kind === 'deposit_media' ? '寄存' : '撤回寄存'; + // 寄存 / 撤回也要登记在途 —— 它们在这一行就 return 了,走不到下面代办链的 inflight + // 记账,而 ingestMedia 落盘与账本挂引用之间被 forceQuit() 打断会留下孤儿 blob。 + // 刻意用独立计数而不并入 inflight:那个是**代办限流账**(getInflightLimit), + // 把面板里删素材 / 粘贴图算进去会让它们撞上「同时进行的代办已达上限」—— + // 那是行为变更,不是本改动该做的事。这里只服务于「重启会打断什么」的判定。 + this.mediaOps += 1; try { return p.kind === 'deposit_media' ? await this.handleDepositMedia(ghostId, p) @@ -417,6 +429,8 @@ export class GhostCindySlot { error: message, }); return { ok: false, message: `${verb}失败:${message}` }; + } finally { + this.mediaOps -= 1; } } // 快问快答(text.oneshot):不经媒体生成链、不选型、秒级同步——单独 @@ -806,6 +820,32 @@ export class GhostCindySlot { * 在途 running(含本单即将占用的名额)都会落成完成记录,一并计入预留, * 上限在任何并发时序下都不被突破。 */ + /** + * 是否有**任意**在途的 Cindy 工作。 + * + * 给「这个破坏性动作会打断什么」这类全局判定用(更新重启前的阻断探针)。三处状态各自独立 + * 维护,只查一部分就等于漏一部分: + * - `jobs`:mode:'submit' 的**异步视频**代办(异步提交只对视频开放,图像秒级完成走同步)。 + * 由 `void runExec()` 脱离调用链跑,发起它的 turn 结束后就没有任何 turn 级信号还亮着。 + * - `inflight`:**同步**代办的 per-ghost 在途计数(gen_image / gen_video / edit_* 的同步 + * 等待、明确不进会话的 oneshot_text)。插件面板发起的请求不一定伴随 turn 或 card-action, + * 所以同样可能所有其它探针都不命中。 + * - `mediaOps`:寄存 / 撤回寄存(deposit_media / release_media)。这两个分支在进入代办链 + * 之前就 return 了、走不到 inflight 记账;被打断会卡在 blob 落盘与账本挂引用之间。 + * + * 三者都会被 forceQuit() 连着 Ghost Node runtime 一起 destroyAll —— 正在生成的付费结果 + * 直接丢掉。所以这里给的是「所有 Cindy slot 在途工作」的统一快照,而不是某一种。 + */ + anyInflightWork(): boolean { + for (const job of this.jobs.values()) { + if (job.status === 'running') return true; + } + for (const count of this.inflight.values()) { + if (count > 0) return true; + } + return this.mediaOps > 0; + } + private evictSettledJobs(ghostId: string): void { const entries = [...this.jobs.entries()].filter(([, j]) => j.ghostId === ghostId); const running = entries.filter(([, j]) => j.status === 'running').length; diff --git a/apps/desktop/src/main/cindy-brain/ghostSessionActivity.ts b/apps/desktop/src/main/cindy-brain/ghostSessionActivity.ts index b3773565935..bfb503f86c1 100644 --- a/apps/desktop/src/main/cindy-brain/ghostSessionActivity.ts +++ b/apps/desktop/src/main/cindy-brain/ghostSessionActivity.ts @@ -62,6 +62,20 @@ export class GhostSessionActivityTracker { return (this.sessionKeys.get(sessionId)?.size ?? 0) > 0; } + /** + * 是否有**任意**会话存在在途的意识后台活动。 + * + * 给「这个破坏性动作会不会打断正在干的活」这类全局判定用(如更新重启前的阻断探针)—— + * 那些调用方没有 sessionId 可传,而 renderer 侧的 ghostSessionActivityStore 只靠 0↔1 + * 推送累积集合、没有全量快照通道,首次订阅时拿不到已在跑的活动,不能作为权威来源。 + */ + anySessionBusy(): boolean { + for (const keys of this.sessionKeys.values()) { + if (keys.size > 0) return true; + } + return false; + } + /** * 活动开始(card-action 派发成功时调;key = 衍生卡位 spawnCallId,兜底原 * callId)。同 key 重复 begin 只续 TTL,不重复计数。 diff --git a/apps/desktop/src/main/relaunchBusyActivity.ts b/apps/desktop/src/main/relaunchBusyActivity.ts new file mode 100644 index 00000000000..63217a9032a --- /dev/null +++ b/apps/desktop/src/main/relaunchBusyActivity.ts @@ -0,0 +1,126 @@ +/** + * relaunchBusyActivity.ts — 「现在重启会不会打断正在干的活」的单一判定。 + * --------------------------------------------------------------------------- + * 背景:手动更新重启(侧栏 UpdateBanner 的「立即重启」)一旦执行就走 forceQuit() —— + * 绕过 before-quit 链、destroyAll() 掉 Ghost Node runtime、process.exit(0)。所以点下去 + * 之前必须回答一个问题:**当前有没有正在跑的活会被这一下打断?** + * + * 这个问题的难点不在判断,而在**来源分散**:仓里「活动」由六个互不相干的跟踪器各自维护, + * 谁都不知道其它几个的存在。判定收在这里一处,renderer 只问一次结论 —— 新增来源只改这里: + * + * 1. 逻辑 turn —— SessionTurnActivityTracker + live session 的 isTurnRunning() + * 2. Claude 后台活动 —— turn 已结束但 CC 子进程仍在调模型(后台 subagent;**不含**后台 Bash) + * 3. Ghost 后台活动 —— card-action 干活,**完全不经 LLM turn**(生成媒体等) + * 4. scheduler 在跑的 run —— **script 模式与 pre-run hook 阶段都不创建 session** + * (script-runner.ts 明确 'script execution does not support worktrees or bound + * sessions'、sessionId 落空串),所以前三个内存探针全都看不到它 + * 5. 后台 Bash 任务 —— run_in_background 的 Bash(dev server、长跑脚本)。它**不调模型**, + * 所以永远点不亮来源 2 的 loopback 信号(useBackgroundBashTasks.ts 的头注释明写这一点); + * 也不折算 makerChatStore 的 running,所以来源 1 同样看不到。快照来源是每个 live session + * 的 listBackgroundTasks() + * 6. Cindy slot 的在途代办 —— 异步(mode:'submit' 的视频生成,`void runExec()` 脱链跑)与 + * 同步(gen_image / gen_video 的同步等待、不进会话的 oneshot_text)两半,在 GhostCindySlot + * 里分别记在 jobs 与 inflight 两个 Map。插件面板发起的请求还可能完全不伴随 turn 或 + * card-action,所以前五个来源都可能不命中 + * + * 新增第 7 个来源时改这一个函数,不必再去翻每个调用点。 + * + * 一个刻意的边界:这份清单**不保证完备** —— 仓里的异步活动持有者是开放集合,每个模块各自在 + * 私有结构里管在途状态,没有统一注册处。但漏掉某个来源**不构成回归**:改动前那次二次确认 + * 同样不做任何活动判定(文案只是「应用会自动重启」),对所有来源都是「点一下就 forceQuit」。 + * 所以覆盖到的来源是净收益,没覆盖到的与改动前行为一致。发现新来源就往这里加一条。 + * + * **fail closed**:任一来源读取抛错都按「有活动」处理。理由是这里服务的是不可撤销的破坏性 + * 动作,「无法确认」不能当成「确认没有」;同样口径见 bootstrap-electron 托盘退出的 + * hasActiveTurn(「A failed busy probe must not turn the tray into an unguarded exit path.」)。 + * 代价只是多一次确认。 + * + * 刻意**不**包含:远程 controller / in-flight remote invoke。那是**无人值守**自动重启该管的 + * (setUpdateAutoRelaunchBusyProbe),不该管手动重启 —— 用户主动点重启时,「有远程设备在看 + * 会话列表」不构成「会被打断的任务」,纳进来只会产生误报警告。 + * + * 五个内存源同步、scheduler 源要查 SQLite,所以整体是 async:先读同步源,**都空闲**才去 + * 查 scheduler(省掉绝大多数情况下的一次 SQLite 往返);拿到 scheduler 结果后再复采一次同步源, + * 关掉「查库期间新 turn 起来了」的窗口 —— 同样的二次采样理由见 updateRelaunchSafety.ts 的 + * hasUpdateRelaunchBusyActivity。 + * + * 依赖全注入,便于单测(规则 14)。 + */ + +export interface RelaunchBusyActivitySources { + /** 是否有任意 session 正在跑逻辑 turn。 */ + anySessionInTurn: () => boolean; + /** 处于「turn 已结束但仍在调模型」后台活动态的会话 id 列表。 */ + listClaudeBackgroundSessions: () => readonly string[]; + /** 是否有任意会话存在在途的 Ghost card-action 后台活动。 */ + anyGhostSessionBusy: () => boolean; + /** + * 是否有任意 live session 存在仍在运行的后台 Bash 任务(run_in_background)。 + * **必须单独查**:后台 Bash 不调模型 → 点不亮 Claude 后台活动信号;也不折算 running → + * 逻辑 turn 看不到。重启会直接杀掉这些子进程(dev server / 长跑脚本)。 + */ + anyBackgroundBashRunning: () => boolean; + /** + * 是否有任意 Cindy slot 在途代办(异步 jobs + 同步 inflight 两半都算)。 + * **必须单独查**:两半各自独立记账、都可能不伴随 turn 或 card-action,而 forceQuit() 会连 + * Ghost Node runtime 一起销毁 —— 正在生成的付费结果直接丢掉。 + */ + anyCindySlotJobRunning: () => boolean; + /** + * scheduler 里是否有 run 处于 running。**必须单独查**:script 模式与 pre-run hook 阶段 + * 都不创建 session,内存来源全看不到它们,而重启会让 run 来不及落终态、脚本子进程变成 + * 失联进程。走 SQLite,所以是异步。 + */ + anySchedulerRunRunning: () => Promise; +} + +/** 判定出的忙闲,附带命中的来源(只用于日志/诊断,不进 UI 文案)。 */ +export interface RelaunchBusyActivity { + busy: boolean; + /** 命中的来源标签;fail-closed 时是抛错的那个来源。 */ + reasons: string[]; +} + +/** + * 五个内存来源每次都全查(不短路),让 reasons 能完整反映现场 —— 诊断「为什么拦了我」时, + * 只知道第一个命中的来源不够用。成本是五次内存读,可忽略。 + */ +export async function evaluateRelaunchBusyActivity( + sources: RelaunchBusyActivitySources, +): Promise { + const readSyncSources = (): string[] => { + const hits: string[] = []; + const probe = (label: string, read: () => boolean): void => { + try { + if (read()) hits.push(label); + } catch { + // fail closed:读不出来就当它忙(见文件头)。标签带 -probe-failed 后缀,便于在日志里 + // 区分「真的有活动」与「探针坏了」——两者都拦,但排查方向完全不同。 + hits.push(`${label}-probe-failed`); + } + }; + probe('session-in-turn', () => sources.anySessionInTurn()); + probe('claude-background-activity', () => sources.listClaudeBackgroundSessions().length > 0); + probe('ghost-background-activity', () => sources.anyGhostSessionBusy()); + probe('background-bash', () => sources.anyBackgroundBashRunning()); + probe('cindy-slot-async-job', () => sources.anyCindySlotJobRunning()); + return hits; + }; + + const firstPass = readSyncSources(); + // 已经确定要拦了就不必再查库 —— 结论不会变,省一次 SQLite 往返。 + if (firstPass.length > 0) return { busy: true, reasons: firstPass }; + + const reasons: string[] = []; + try { + if (await sources.anySchedulerRunRunning()) reasons.push('scheduler-run-running'); + } catch { + reasons.push('scheduler-run-probe-failed'); + } + + // 查库期间可能有新 turn / 后台活动起来,复采一次同步源(理由同 + // updateRelaunchSafety.hasUpdateRelaunchBusyActivity 的二次采样)。 + reasons.push(...readSyncSources()); + + return { busy: reasons.length > 0, reasons }; +} diff --git a/apps/desktop/src/main/relaunchBusyActivityIpc.ts b/apps/desktop/src/main/relaunchBusyActivityIpc.ts new file mode 100644 index 00000000000..cd66f359e35 --- /dev/null +++ b/apps/desktop/src/main/relaunchBusyActivityIpc.ts @@ -0,0 +1,49 @@ +/** + * relaunchBusyActivityIpc.ts — 「现在重启会打断什么」查询的 IPC 装配。 + * --------------------------------------------------------------------------- + * 判定逻辑在 relaunchBusyActivity.ts(纯函数、零 Electron 依赖);这里只负责 handler 注册与 + * **授权边界**。单独成文件是为了能按仓库既有的 *IpcBoundary 测试模式直接测 handler —— + * bootstrap-electron.ts 那个模块 import 一次就会拉起整个 app 启动链,没法单测。 + * + * sender 断言不是可选项:按 docs/dev-rules/electron-security-and-process-boundaries.md §5, + * 新增 handler 不得以「旧代码没校验」为由省略 sender 验证。这个 handler 读的是全局会话 / + * Claude / Ghost 活动态 —— 带 preload 的窗口被导航到不可信内容、WebView、子 frame 都能发 + * IPC,不校验就等于把「本机现在在跑什么」这类信息暴露给它们。 + */ + +import { ipcMain } from 'electron'; + +import { createLogger } from './logger.js'; +import { evaluateRelaunchBusyActivity, type RelaunchBusyActivitySources } from './relaunchBusyActivity.js'; +import { assertTrustedAppRendererEvent } from './security/trustedAppRenderer.js'; + +export const RELAUNCH_BLOCKING_ACTIVITY_CHANNEL = 'update-relaunch:blocking-activity'; + +const log = createLogger('relaunch-activity'); + +/** + * 注册手动更新重启的阻断查询。 + * + * `sources` 用工厂形态传入(而非直接给值):handler 每次被调用都要拿**当时**的跟踪器, + * maker 会在 app session owner 边界被整体换掉,提前捕获会读到过期实例。 + */ +export function registerRelaunchBusyActivityIpc( + resolveSources: () => RelaunchBusyActivitySources, +): void { + // **幂等注册**,不是防御性冗余:调用点(bootstrap-electron 的 registerMakerIpcsAfterSplash) + // 在它之后还有会抛的初始化,而那个 try 的 catch 明写「下次 splash retry 再尝试」,重试时 + // makerIpcsRegistered 仍是 false —— 于是这行会被执行第二次。ipcMain.handle 对同一 channel + // 第二次注册会抛「Attempted to register a second handler」,那不只是本 handler 注册失败: + // 异常会从这里穿出去,把**排在它后面的全部 maker IPC 注册**一起掀掉,而且每次重试都卡在 + // 同一行 —— 结果是 maker 链路永久不可用。先 remove 再 handle,让重复调用总是收敛到 + // 「一个当前有效的 handler」。 + ipcMain.removeHandler(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL); + ipcMain.handle(RELAUNCH_BLOCKING_ACTIVITY_CHANNEL, async (event) => { + assertTrustedAppRendererEvent(event); + const result = await evaluateRelaunchBusyActivity(resolveSources()); + if (result.busy) { + log.info('manual relaunch has live activity', { reasons: result.reasons }); + } + return result.busy; + }); +} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 35e9fdc4f5f..42da1f4bbe5 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2934,6 +2934,15 @@ contextBridge.exposeInMainWorld('electronAPI', { result: 'ready' | 'idle' | 'downloading' | 'manifest_failed' | 'download_failed' | 'manual_download'; }> => ipcRenderer.invoke('update-check-now'), + /** + * 现在重启会不会打断正在跑的活。聚合三个互不相干的活动来源(逻辑 turn / Claude 后台活动 / + * Ghost card-action 后台活动),判定与 fail-closed 口径都在 main 侧一处 + * (relaunchBusyActivity.ts)—— renderer 逐个枚举来源会漏,漏了就是静默打断用户任务。 + * 供 UpdateBanner 决定「直接重启」还是「先弹中断警告」。 + */ + anyActivityBlockingRelaunch: (): Promise => + ipcRenderer.invoke('update-relaunch:blocking-activity'), + /** * Tell the main process to apply the downloaded update and relaunch. */ diff --git a/apps/desktop/src/renderer/__tests__/updateBannerBusyHint.test.tsx b/apps/desktop/src/renderer/__tests__/updateBannerBusyHint.test.tsx deleted file mode 100644 index 2ea2ca9fef3..00000000000 --- a/apps/desktop/src/renderer/__tests__/updateBannerBusyHint.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const { anySessionInTurn, relaunchToUpdate } = vi.hoisted(() => ({ - anySessionInTurn: vi.fn<() => Promise>(), - relaunchToUpdate: vi.fn(), -})); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -vi.mock('@/hooks/useUpdateStatus', () => ({ - useUpdateStatus: () => ({ - status: 'ready', - version: '1.2.3', - errorCode: null, - }), -})); - -vi.mock('@/hooks/useUpdateBannerDismiss', () => ({ - useUpdateBannerDismiss: () => ({ - dismissed: false, - dismiss: vi.fn(), - restore: vi.fn(), - isNewUpdateAfterDismiss: vi.fn(() => false), - }), -})); - -vi.mock('@/components/ui/tooltip', () => ({ - Tip: ({ children }: { children: React.ReactNode }) => children, -})); - -import { UpdateBanner } from '@/components/sidebar/UpdateBanner'; - -beforeEach(() => { - anySessionInTurn.mockReset(); - relaunchToUpdate.mockReset(); - Object.defineProperty(window, 'electronAPI', { - configurable: true, - value: { - anySessionInTurn, - relaunchToUpdate, - clientEndpoints: { websiteUrl: 'https://cindy.ai' }, - } as unknown as Window['electronAPI'], - }); -}); - -afterEach(cleanup); - -describe('UpdateBanner busy-turn restart hint', () => { - it('shows the warning hint in the semantic warning color while any turn is running', async () => { - anySessionInTurn.mockResolvedValue(true); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - - const hint = await screen.findByText('update.banner.confirmBusyHint'); - expect(anySessionInTurn).toHaveBeenCalledTimes(1); - expect(hint.className).toContain('text-[var(--warning-fg)]'); - }); - - it('keeps the existing neutral hint when no turn is running', async () => { - anySessionInTurn.mockResolvedValue(false); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - - await waitFor(() => expect(anySessionInTurn).toHaveBeenCalledTimes(1)); - const hint = screen.getByText('update.banner.confirmHint'); - expect(hint.className).toContain('text-sidebar-muted'); - expect(hint.className).not.toContain('text-[var(--warning-fg)]'); - }); - - it('keeps relaunch behavior unchanged while the text-only snapshot is pending', async () => { - let resolveTurnCheck!: (busy: boolean) => void; - anySessionInTurn.mockImplementation( - () => new Promise((resolve) => { resolveTurnCheck = resolve; }), - ); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - await waitFor(() => expect(anySessionInTurn).toHaveBeenCalledTimes(1)); - - const confirmButton = screen.getByRole('button', { name: 'update.banner.confirmAria' }); - expect((confirmButton as HTMLButtonElement).disabled).toBe(false); - fireEvent.click(confirmButton); - expect(relaunchToUpdate).toHaveBeenCalledTimes(1); - - resolveTurnCheck(false); - }); - - it('keeps the neutral hint when the one-shot probe throws synchronously', async () => { - anySessionInTurn.mockImplementation(() => { - throw new Error('electron bridge is not registered'); - }); - render(); - - fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); - - await waitFor(() => expect(anySessionInTurn).toHaveBeenCalledTimes(1)); - expect(screen.getByText('update.banner.confirmHint')).toBeTruthy(); - }); -}); diff --git a/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx b/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx new file mode 100644 index 00000000000..11f5a251eac --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/updateBannerRelaunchEntry.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment jsdom + +/** + * UpdateBanner 的重启入口判定:点入口先查「有没有任务在跑」,确认没有才直接重启,有任务 + * (或探针拿不到可信答案)就拦一次并说明「会打断进行中的任务」。探针失败刻意 fail closed + * —— 重启会不可撤销地杀掉 in-flight turn,「无法确认」不能当成「确认没有」。 + * + * 「有任务在跑」由哪些活动来源构成(逻辑 turn / Claude 后台活动 / Ghost card-action)是 main + * 侧一处判定的职责,覆盖面由 main/__tests__/relaunchBusyActivity.test.ts 负责。本文件只管 + * renderer 这一侧的契约:**拿到 true 就拦、false 才走、拿不到答案就保守**。 + * + * 另一半是**不变量:一次点击的探针结论,只有在这次点击仍然有效时才能驱动副作用**。 + * 探针在飞期间 dismiss、组件卸载、status 离开 ready 都必须让它作废 —— 三条对称路径 + * 各有一条用例,少任何一条都会漏掉「点了稍后却重启」「装回旧补丁」「confirming 残留」。 + */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + anyActivityBlockingRelaunch, relaunchToUpdate, updateStatus, dismissState, +} = vi.hoisted(() => ({ + anyActivityBlockingRelaunch: vi.fn<() => Promise>(), + relaunchToUpdate: vi.fn(), + updateStatus: { + current: { status: 'ready', version: '1.2.3', errorCode: null } as { + status: string; + version?: string; + errorCode: string | null; + }, + }, + // dismissed 必须可控且由 dismiss() 真正翻转:要测「confirming 残留到下次唤回」,就得能 + // 模拟「点 X 隐藏 → 火焰按钮 restore 重新显示」这条路径,而 restore 不会卸载组件, + // 残留的 state 正是靠它暴露出来的。 + dismissState: { dismissed: false }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@/hooks/useUpdateStatus', () => ({ + useUpdateStatus: () => updateStatus.current, +})); + +vi.mock('@/hooks/useUpdateBannerDismiss', () => ({ + useUpdateBannerDismiss: () => ({ + dismissed: dismissState.dismissed, + dismiss: () => { dismissState.dismissed = true; }, + restore: () => { dismissState.dismissed = false; }, + isNewUpdateAfterDismiss: () => false, + }), +})); + +vi.mock('@/components/ui/tooltip', () => ({ + Tip: ({ children }: { children: React.ReactNode }) => children, +})); + +import { UpdateBanner } from '@/components/sidebar/UpdateBanner'; + +/** 返回一个手动 settle 的探针,用于把「点击后、resolve 前」这段窗口撑开。 */ +function deferredProbe(): (busy: boolean) => void { + let settle!: (busy: boolean) => void; + anyActivityBlockingRelaunch.mockImplementation( + () => new Promise((resolve) => { settle = resolve; }), + ); + return (busy: boolean) => settle(busy); +} + +beforeEach(() => { + anyActivityBlockingRelaunch.mockReset(); + relaunchToUpdate.mockReset(); + updateStatus.current = { status: 'ready', version: '1.2.3', errorCode: null }; + dismissState.dismissed = false; + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + anyActivityBlockingRelaunch, + relaunchToUpdate, + clientEndpoints: { websiteUrl: 'https://cindy.ai' }, + } as unknown as Window['electronAPI'], + }); +}); + +afterEach(cleanup); + +describe('UpdateBanner relaunch entry', () => { + it('warns about the interruption instead of relaunching when main reports live activity', async () => { + anyActivityBlockingRelaunch.mockResolvedValue(true); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + + const hint = await screen.findByText('update.banner.confirmBusyHint'); + expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1); + expect(hint.className).toContain('text-[var(--warning-fg)]'); + // 拦住的这一步不能顺手把 app 重启了 —— 是否打断任务由用户拍板。 + expect(relaunchToUpdate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.confirmAria' })); + expect(relaunchToUpdate).toHaveBeenCalledTimes(1); + }); + + it('relaunches on the first click when main reports nothing running', async () => { + anyActivityBlockingRelaunch.mockResolvedValue(false); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + + await waitFor(() => expect(relaunchToUpdate).toHaveBeenCalledTimes(1)); + expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1); + // 没有任务在跑时不该再出现第二步 —— 那句「应用会自动重启」不带任何信息量。 + expect(screen.queryByRole('button', { name: 'update.banner.confirmAria' })).toBeNull(); + expect(screen.queryByText('update.banner.confirmBusyHint')).toBeNull(); + }); + + // 探针拿不到可信答案时 fail closed:「无法确认」不能当成「确认没有」,重启会不可撤销地 + // 杀掉 in-flight turn。口径同 main 侧托盘退出路径的 hasActiveTurn(catch → true)。 + it('falls back to the warning state when the busy probe throws synchronously', async () => { + anyActivityBlockingRelaunch.mockImplementation(() => { + throw new Error('electron bridge is not registered'); + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + + await screen.findByText('update.banner.confirmBusyHint'); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); + + it('falls back to the warning state when the busy probe rejects', async () => { + anyActivityBlockingRelaunch.mockRejectedValue(new Error('ipc channel closed')); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + + await screen.findByText('update.banner.confirmBusyHint'); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); + + it('ignores repeat clicks while the busy probe is still in flight', async () => { + const settle = deferredProbe(); + render(); + + const entry = screen.getByRole('button', { name: 'update.banner.ariaExpanded' }); + fireEvent.click(entry); + fireEvent.click(entry); + fireEvent.click(entry); + + await waitFor(() => expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1)); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + + settle(false); + await waitFor(() => expect(relaunchToUpdate).toHaveBeenCalledTimes(1)); + }); + + it('applies the same judgement to the collapsed / rail entry', async () => { + anyActivityBlockingRelaunch.mockResolvedValue(false); + const { unmount } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaCollapsed' })); + await waitFor(() => expect(relaunchToUpdate).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('button', { name: 'update.banner.confirmAria' })).toBeNull(); + + unmount(); + relaunchToUpdate.mockClear(); + anyActivityBlockingRelaunch.mockResolvedValue(true); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaCollapsed' })); + // 收起态没有文案位置,拦下来的形态是 ✓ / ✕ 两键。 + await screen.findByRole('button', { name: 'update.banner.confirmAria' }); + expect(screen.getByRole('button', { name: 'update.banner.cancelAria' })).toBeTruthy(); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); + + // ── 不变量:探针结论只在这次点击仍然有效时才生效 ── + // 三条路径都会在探针在飞期间让点击失效,少任何一条都是一个真实缺陷。 + + it('discards the probe when the user dismisses the banner while it is in flight', async () => { + const settle = deferredProbe(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + await waitFor(() => expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1)); + + // 用户点「稍后再说」= 明确表达现在不要重启。 + fireEvent.click(screen.getByRole('button', { name: 'update.banner.dismissAria' })); + rerender(); + settle(false); + + // 给 continuation 足够的微任务窗口跑完,再断言它什么都没做。 + await Promise.resolve(); + await Promise.resolve(); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); + + it('leaves no confirming state behind when dismissed mid-probe with a busy result', async () => { + const settle = deferredProbe(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + await waitFor(() => expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.dismissAria' })); + rerender(); + expect(screen.queryByRole('button', { name: 'update.banner.ariaExpanded' })).toBeNull(); + + settle(true); + await Promise.resolve(); + await Promise.resolve(); + + // 火焰按钮唤回(restore 不卸载组件,残留的 state 会原样显示出来)。confirming 若被 + // 那个已作废的探针置位,用户没再点过入口就会直接落在第二步。 + dismissState.dismissed = false; + rerender(); + + expect(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'update.banner.confirmAria' })).toBeNull(); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); + + it('discards the probe when the ready patch gets superseded while it is in flight', async () => { + const settle = deferredProbe(); + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + await waitFor(() => expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1)); + + // 新版本下载完成,已就绪补丁被顶掉:此时重启会装回旧补丁。 + updateStatus.current = { status: 'superseding', version: '1.2.3', errorCode: null }; + rerender(); + settle(false); + + await Promise.resolve(); + await Promise.resolve(); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + // superseding 态本身仍正常渲染(准备中),不该被这次作废影响。 + expect(screen.getByText('update.banner.preparingButton')).toBeTruthy(); + }); + + it('discards the probe when the component unmounts while it is in flight', async () => { + const settle = deferredProbe(); + const { unmount } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'update.banner.ariaExpanded' })); + await waitFor(() => expect(anyActivityBlockingRelaunch).toHaveBeenCalledTimes(1)); + + unmount(); + settle(false); + + await Promise.resolve(); + await Promise.resolve(); + expect(relaunchToUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx b/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx index 794f2118d85..129cb091f30 100644 --- a/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx +++ b/apps/desktop/src/renderer/__tests__/updateBannerReleaseNotesLink.test.tsx @@ -3,7 +3,7 @@ /** * UpdateBanner「查看更新公告」文字链 —— 方案 A。 * - * 覆盖入口的四条判定:CDN 有公告才显示、点击带的是待装版本号、confirming 两步确认期 + * 覆盖入口的四条判定:CDN 有公告才显示、点击带的是待装版本号、confirming 中断警告期 * 让位、superseding 态不给入口(那时的 version 指向上一个已就绪补丁,不是正在下的新版)。 */ @@ -38,10 +38,28 @@ const NOTES = { version: '1.4.2', date: '2026-07-28', contributors: [], sections const LINK = 'update.banner.viewNotes'; +// 入口按钮现在先查阻断探针再决定「直接重启 vs 进中断警告态」,所以这个文件也需要 +// 一个 electronAPI 桩;默认没有任务在跑(本文件只关心文字链,不关心重启)。 +const { anyActivityBlockingRelaunch, relaunchToUpdate } = vi.hoisted(() => ({ + anyActivityBlockingRelaunch: vi.fn<() => Promise>(), + relaunchToUpdate: vi.fn(), +})); + beforeEach(() => { updateStatus.current = { status: 'ready', version: '1.4.2' }; fetchReleaseNotes.mockReset(); fetchReleaseNotes.mockResolvedValue(NOTES); + anyActivityBlockingRelaunch.mockReset(); + anyActivityBlockingRelaunch.mockResolvedValue(false); + relaunchToUpdate.mockReset(); + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + anyActivityBlockingRelaunch, + relaunchToUpdate, + clientEndpoints: { websiteUrl: 'https://cindy.ai' }, + } as unknown as Window['electronAPI'], + }); }); afterEach(() => { @@ -68,13 +86,14 @@ describe('UpdateBanner release-notes link', () => { expect(screen.queryByText(LINK)).toBeNull(); }); - it('hides the link while the two-step relaunch confirmation is showing', async () => { + it('hides the link while the busy-turn interruption warning is showing', async () => { + anyActivityBlockingRelaunch.mockResolvedValue(true); render(); await screen.findByText(LINK); fireEvent.click(screen.getByText('update.banner.button')); - expect(screen.getByText('update.banner.confirmButton')).toBeTruthy(); + expect(await screen.findByText('update.banner.confirmButton')).toBeTruthy(); expect(screen.queryByText(LINK)).toBeNull(); }); diff --git a/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx b/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx index c3d834052bc..8b92cf493ba 100644 --- a/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx +++ b/apps/desktop/src/renderer/components/sidebar/UpdateBanner.tsx @@ -6,17 +6,23 @@ * of the already-ready patch (status === 'superseding'). Sits between the upper * content slot and UserInfoSection in the Sidebar shell. * - * 更新确认改为「就地两段式」—— 不再弹出屏幕中央的 ConfirmDialog。用户点「立即重启」 - * 后,banner 自身原地切换成确认态:主按钮「确认重启」占据入口按钮原位(鼠标零位移), - * 「取消」置于其下(次级、需刻意移动),从而在保持左下角、减少视线/鼠标移动的同时, - * 用两步显式点击防止误更新。 + * 点「立即重启」不再无条件多要一次确认:先查「有没有任务在跑」(逻辑 turn + turn 已结束但 + * 仍在调模型的后台活动,两个来源都要看),**只有真的有任务时**才就地切换成确认态,确认没有 + * 就直接重启。原先那句「应用会自动重启」的中性二次确认纯属多一次点击、不带信息,已退役。 + * + * 探针拿不到可信答案时 fail closed(当作有任务),因为重启会杀掉 in-flight turn:「无法确认」 + * 不能当成「确认没有」。这条口径跟的是 main 侧托盘退出路径,而不是 WindowControls 关窗那半。 + * + * 因此 confirming 态的语义收窄为**「有任务在进行中,重启会打断它」的中断警告**:标题点明 + * 状态、副标题讲后果(警告色)、主按钮「仍要重启」占据入口按钮原位(鼠标零位移),「取消」 + * 置于其下(次级、需刻意移动)。 * * 「查看更新公告」文字链:ready 态在副标题下给一条 ghost 文字链,点开 UpdateNoticeDialog * 预览**待安装版本**的公告,让用户在「现在重启 vs 稍后」之间有据可依(装完后的自动弹窗 * 只解决装完之后的事)。跨版本时会把「已装版本 → 待装版本」之间跳过的每一版聚合进同一个 * 弹窗(useUpdateNotice 的 onOpenVersion),普通单版本升级就只有一块。三条刻意的边界: * - 只在 ready 态出现。superseding 态顶部刻意不显示版本号(新版还在下),没有可信的 - * 版本可查,confirming 态则要保持两步确认的干净,两者都不给入口。 + * 版本可查,confirming 态则要保持中断警告的干净,两者都不给入口。 * - CDN 上没有该版本公告(或内容不可渲染)时不显示入口 —— 用挂载时的 fetch 探测, * 宁可没有入口,也不给一个点了报错的链接。探测结果被 release-notes 双层缓存复用, * 真正点开时不会再打一次网。 @@ -24,10 +30,10 @@ * 也只能看 <= 当前已装版本的历史,看不到待装版本。这是本方案已知的取舍。 * * - Expanded ready: Flame 36px → "Updated to {v}" → "Relaunch to apply" → notes link → Relaunch pill - * - Expanded confirming: Flame 36px → "Restart to update?" → hint → Confirm pill / Cancel (下方,ghost) + * - Expanded confirming: Flame 36px → "A task is still running" → 警告色 hint → "Restart anyway" pill / Cancel (下方,ghost) * - Expanded superseding: Loader2 36px (spin) → "Newer version found" → "Updating…" → disabled pill with spinner - * - Collapsed ready: Flame 20px, click → confirming(就地展开 ✓ / ✕) - * - Collapsed confirming: Check 20px (确认,占原位) 叠 X 20px (取消) + * - Collapsed ready: Flame 20px, click → 有任务才展开 ✓ / ✕,否则直接重启 + * - Collapsed confirming: Check 20px (仍要重启,占原位) 叠 X 20px (取消) * - Collapsed superseding: Loader2 20px (spin), click is a noop * * superseding 状态下 banner 顶部刻意不显示新版本号 —— 新版还在下,显示版本号等于撒谎; @@ -63,14 +69,27 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // status/version 变化时下面 effect 会自动 restore,新一版更新到达时 banner // 重新出现,不会被上一版的 dismiss 状态吞掉。 const { dismissed, dismiss, restore, isNewUpdateAfterDismiss } = useUpdateBannerDismiss(); - // 就地确认态:替代原先的屏幕中央 ConfirmDialog。 + // 就地中断警告态:只在点击入口时探到「有任务在跑」才进入。 const [confirming, setConfirming] = useState(false); - // 复用关闭窗口保护链路的权威 busy 探针:只统计仍在执行的逻辑 turn, - // 不把 keepalive、已结束但仍在渲染收尾的状态误判为运行中任务。 - const [hasSessionInTurn, setHasSessionInTurn] = useState(false); + // 入口点击的 busy 探针在飞标记 —— 防重入。探针是一次 IPC round trip(毫秒级),所以 + // 刻意不给 loading UI(几毫秒的 spinner 只会闪一下),只用 ref 挡住连点导致的 + // 重复探针 / 重复重启。与 WindowControls 的关窗入口保持同样的「无 loading 态」处理。 + const relaunchProbeRef = useRef(false); + // 一次点击的有效性令牌。探针是异步的,点击那一刻成立的前提在 resolve 时可能已经不成立: + // 用户点了右上角「稍后再说」、新版本把已就绪补丁顶成 superseding、组件被卸载,或用户 + // 又点了一次入口。任一情况都让 epoch 前进,在飞的 continuation 靠 epoch 不匹配自我作废。 + // 不作废会有三个真实后果:①点了「稍后」却突然重启;②superseding 期间重启并装回旧补丁; + // ③dismiss 之后 setConfirming(true) 残留,下次被火焰按钮唤回时直接落在第二步(用户 + // 并没有再点入口)。 + const relaunchEpochRef = useRef(0); + // continuation 里必须读「当前」status,不能读点击时闭包捕获的旧值。刻意在 render 期间 + // 同步镜像而不是用 effect —— effect 会晚一拍,「status 已 setState 但 effect 未执行」的 + // 区间里探针恰好 resolve 就会读到过期的 'ready'。这个 ref 只做最新值镜像,不参与渲染输出。 + const statusRef = useRef(status); + statusRef.current = status; // 进入确认态后把焦点移到「取消」按钮 —— 键盘用户点入口键后原触发元素会卸载, - // 若不主动聚焦,焦点会丢失、无法继续操作。刻意聚焦「取消」而非「确认」:让默认落在 - // 安全动作上,避免再按一次 Enter/Space 就直接更新的误操作(即 Radix 对破坏性操作 + // 若不主动聚焦,焦点会丢失、无法继续操作。刻意聚焦「取消」而非「仍要重启」:让默认落在 + // 安全动作上,避免再按一次 Enter/Space 就打断进行中的任务(即 Radix 对破坏性操作 // 的默认焦点策略)。展开态与收起态共用同一个 ref(同一时刻只渲染其一)。 const cancelBtnRef = useRef(null); // 入口「立即重启」按钮的 ref:取消确认态后把焦点还给它,避免键盘用户退出两步流程时 @@ -101,37 +120,17 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP }, [isTranslocated]); // 一旦不再是 ready(如被 superseding 顶掉 / 出错),复位确认态,避免残留一个 - // 指向旧补丁的「确认重启」。 + // 指向旧补丁的「仍要重启」;同时作废在飞的探针 —— 它的结论建立在「当前补丁可装」之上。 useEffect(() => { - if (status !== 'ready') setConfirming(false); - }, [status]); - - // 每次进入确认态都重新查询,避免沿用上一次打开弹层时的 busy 结果。 - // handler 在 splash / login 阶段尚未注册时可能 reject;此 banner 只会在 - // ready 主界面出现,但仍与 WindowControls 保持同样的安全兜底语义。 - useEffect(() => { - if (!confirming) { - setHasSessionInTurn(false); - return; + if (status !== 'ready') { + relaunchEpochRef.current += 1; + setConfirming(false); } + }, [status]); - let cancelled = false; - setHasSessionInTurn(false); - // Start the probe in a microtask so a synchronously unavailable IPC bridge - // is converted into a rejected promise and handled by the same fallback. - void Promise.resolve() - .then(() => window.electronAPI.anySessionInTurn()) - .then((busy) => { - if (!cancelled) setHasSessionInTurn(busy); - }) - .catch(() => { - if (!cancelled) setHasSessionInTurn(false); - }); - - return () => { - cancelled = true; - }; - }, [confirming]); + // 卸载时同样作废在飞的探针。卸载后 setConfirming 只是一次无效更新,但 handleRelaunch + // 会真的把 app 重启掉 —— 这条 cleanup 不是防 React 警告,是防意外重启。 + useEffect(() => () => { relaunchEpochRef.current += 1; }, []); // 新更新到达时自动 restore:isNewUpdateAfterDismiss 先检查当前 status 是否为 // active update 态(ready / superseding),再对比 dismiss 时的快照——两个条件 @@ -188,7 +187,10 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // 直接落在「确认重启」界面(那是两步流程的第二步,越过第一步显示不合适)。 // 传入当前 status/version 让 store 记录快照,用于后续区分「同一更新 remount」 // 与「真正新更新到达」,避免导航到 /settings 再回来时误 restore。 + // 同时作废在飞的探针:用户点「稍后再说」就是明确表达「现在不要重启」,几毫秒后 resolve + // 的探针结论不能反过来推翻它(否则轻则 confirming 残留、重则直接重启)。 const handleDismiss = () => { + relaunchEpochRef.current += 1; setConfirming(false); dismiss(status, version ?? null); }; @@ -208,6 +210,47 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP window.electronAPI.relaunchToUpdate(theme); }; + // 入口「立即重启」/ 收起态火焰按钮的点击。展开态与收起态共用一份判定: + // - 有任务在跑 → 进 confirming 态,把「会打断进行中的任务」这件事说清楚,由用户拍板; + // - 没有 → 直接重启,不再多要一次无信息量的确认。 + // + // 「有任务在跑」有三个互不相干的来源(逻辑 turn / Claude 后台活动 / Ghost card-action), + // 判定收在 main 侧一处(relaunchBusyActivity.ts),这里只问一次结论。**刻意不在 renderer + // 逐个枚举来源** —— 那样每加一个新来源就会漏一次(本 PR review 里连续被指出三轮), + // 而漏掉的后果是静默打断用户任务。新增来源改 main 侧那一个函数即可,这里不用动。 + // 探针失败 = **无法确认**,不等于「没有任务」。重启会杀掉 in-flight turn,属于不可撤销的 + // 破坏性动作,所以 fail closed:退化成中断警告让用户自己拍板,而不是静默重启。main 侧对每个 + // 来源也各自 fail closed(见 relaunchBusyActivity.ts),这里再兜住整条 IPC 失败的情况。 + // 口径对齐 main 侧托盘退出路径(bootstrap-electron.ts 的 hasActiveTurn:「A failed busy + // probe must not turn the tray into an unguarded exit path.」)。注意 + // WindowControls.handleCloseClick 的 catch 走的是 false,那条是既有行为,本 PR 不改它, + // 但新入口不跟随更宽松的那一半。 + // + // await 之后的两道复核是必需的,不是防御性冗余:探针在飞期间用户可能 dismiss、组件可能 + // 卸载、已就绪补丁可能被 superseding 顶掉。少了它们,「点了稍后却重启」「装回旧补丁」 + // 「confirming 残留到下次唤回」三种都会真实发生。 + const handleRelaunchClick = async (): Promise => { + if (relaunchProbeRef.current) return; + relaunchProbeRef.current = true; + const epoch = relaunchEpochRef.current; + // 初值取 true:探针没给出可信答案的任何路径(reject、桥同步 throw)都落在保守的那一侧。 + let hasInFlight = true; + try { + hasInFlight = await window.electronAPI.anyActivityBlockingRelaunch(); + } catch { + hasInFlight = true; + } finally { + relaunchProbeRef.current = false; + } + // 这次点击是否仍然有效(未被 dismiss / 卸载 / status 离开 ready 作废)。 + if (epoch !== relaunchEpochRef.current) return; + // status 变化的作废由上面那个 effect 打点,但 effect 会晚一拍;这里直接读最新值, + // 关掉「已 setState 未跑 effect」的那段窗口。两道判定针对同一不变量的不同触发路径。 + if (statusRef.current !== 'ready') return; + if (hasInFlight) setConfirming(true); + else handleRelaunch(); + }; + const handleMoveToApplications = () => { setShowTranslocatedDialog(false); window.electronAPI.moveToApplicationsFolder(); @@ -273,7 +316,8 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP // ── Collapsed state: icon only ── if (isCollapsed) { - // 确认态:上方 ✓(确认,占据原 Flame 图标位置,鼠标零位移),下方 ✕(取消)。 + // 确认态(仅在有任务在跑时出现):上方 ✓(仍要重启,占据原 Flame 图标位置,鼠标零位移), + // 下方 ✕(取消)。收起态没有文案位置,「会打断进行中的任务」只能落在 ✓ 的 tooltip 上。 if (confirming && !isPreparing) { return (
@@ -318,7 +362,7 @@ export function UpdateBanner({ isCollapsed, onOpenVersionNotice }: UpdateBannerP >