-
Notifications
You must be signed in to change notification settings - Fork 352
feat(desktop): 更新重启只在有任务运行时才二次确认,并改成中断警告 #1197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
c6c2b63
feat(desktop): 更新重启只在有任务运行时才二次确认,并改成中断警告
dashhuang c9100d1
fix(desktop): 重启探针的结论只在这次点击仍然有效时才生效
dashhuang fd8dd0a
fix(desktop): 重启探针拿不到答案时 fail closed,并给确认按钮补上宾语
dashhuang b139386
fix(desktop): busy 判定补上「turn 已结束但仍在调模型」的后台活动
dashhuang f27cdfa
fix(desktop): 重启阻断判定收到 main 侧一处,补上 Ghost 后台活动
dashhuang 5e284d5
fix(desktop): 重启探针补 scheduler run 与 sender 断言
dashhuang 9f746a5
fix(desktop): 重启阻断判定补第五个来源——后台 Bash 任务
dashhuang 539de40
fix(desktop): 重启阻断判定补第六个来源——Cindy slot 异步代办
dashhuang 19edfef
fix(desktop): Cindy slot 探针改查全部在途工作,不只异步 jobs
dashhuang 34c57a6
fix(desktop): 媒体寄存也登记在途,并补上一个 commit 漏改的注释
dashhuang 471c6e1
fix(desktop): 重启探针 IPC 改为幂等注册,防 splash 重试连带掀掉 maker IPC
dashhuang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
162 changes: 162 additions & 0 deletions
162
apps/desktop/src/main/__tests__/relaunchBusyActivity.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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']); | ||
| }); | ||
| }); |
106 changes: 106 additions & 0 deletions
106
apps/desktop/src/main/__tests__/relaunchBusyActivityIpcBoundary.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, (...args: unknown[]) => 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<boolean> => { 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); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.