From 6807d3f784bbee9a2c5cb21fe7a3edee6f0142bb Mon Sep 17 00:00:00 2001 From: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:16:36 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(desktop):=20=E6=94=B6=E5=8F=A3=E8=BF=9F?= =?UTF-8?q?=E5=88=B0=20wake=20=E7=BB=88=E6=80=81=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=E7=9A=84=20pendingTaskWake=20=E6=A1=A5=E6=8E=A5,=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=BE=A7=E6=A0=8F=20spinner=20=E6=B0=B8=E4=B9=85?= =?UTF-8?q?=E8=BD=AC=E5=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主轮 Done 之后才到达的 wake 型任务终态(fork 会话收到父会话任务的终态、重连 重放等)会置位唤醒桥接,但设计中的两条消费路径(wake turn 启动 / wake-fail Done)在这类迟到/误投场景永远不会发生;pendingTaskWake 又不在 reconcileStaleRunningTasks 的对账覆盖内,活动熄灭对账沿的 running 候选粗筛 (迟到终态本身是 completed,候选恒空)还会把它挡在自愈门外 —— running 快照 被永久撑住,侧栏 spinner 永转、无自愈路径。 修复(全部复用既有的活动熄灭延迟对账机制): - agent_task_update:主轮 Done 已越过后置位的桥接,置位即调度一次延迟对账兜底; - 对账粗筛(调度沿 + 到点回调):pendingTaskWake > 0 时放行,不再仅看 running 候选; - seedBackgroundTaskSnapshots 新增 opts.reconcileWakeBridge:主 turn 不在跑且 main 权威表无任何 wake 型任务时清零桥接三元组;权威表仍有 wake 任务在跑或 wake turn 已启动(桥接已被消费)时不动,不误杀真实空窗。 回归:pendingTaskWakeBridgeReconcile.test.ts 4 例(泄漏复现+收口 / 权威表有 活任务不误杀 / 旧签名路径行为不变 / 正常 wake turn 消费链路不受影响)。 typecheck 通过;makerChatStore.ts 存量 lint 问题(9148 行 prefer-const,主干 同样报)未在本提交范围内。 Signed-off-by: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> --- .../pendingTaskWakeBridgeReconcile.test.ts | 111 ++++++++++++++++++ .../src/renderer/lib/makerChatStore.ts | 70 ++++++++++- 2 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts diff --git a/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts b/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts new file mode 100644 index 0000000000..1cb03dda29 --- /dev/null +++ b/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts @@ -0,0 +1,111 @@ +/** + * 唤醒桥接(pendingTaskWake)泄漏对账回归 —— sidebar spinner 永久转圈修复。 + * + * 场景:主轮 Done 之后才到达的 wake 型任务终态(fork 会话收到父会话任务的终态、 + * 重连重放等)会置位桥接;设计上桥接等「wake turn 启动(isTurnStart 消费)」或 + * 「wake turn 失败的 Done」收尾,但这类迟到/误投终态不会有任何后续事件跟进, + * 两条清除路径都永远不来 —— hasBackgroundAgentWork 永真,running 快照永久含 + * 该会话(spinner 永转),且 pendingTaskWake 不在 reconcileStaleRunningTasks + * 的对账覆盖内(迟到终态本身就是 completed,不是 running 残留)。 + * + * 修复:活动熄灭延迟对账路径拿到 main 权威表后收口桥接 + * (seedBackgroundTaskSnapshots 的 opts.reconcileWakeBridge)。 + * 本测试直接驱动真实 store(__applyStatusUpdateForTest / __applyStreamEventForTest), + * 断言 getRunningSnapshot 的可观察行为。 + */ + +import { describe, expect, it } from 'vitest'; + +import { makerChatStore } from '@/lib/makerChatStore'; + +function pushStatus( + sessionId: string, + partial: Partial & Pick, +): void { + makerChatStore.__applyStatusUpdateForTest(sessionId, { + sessionId, + tokenUsage: 0, + contextTokens: 0, + contextWindow: 0, + ...partial, + } as CCAgentStatusUpdate); +} + +/** 迟到的 wake 型任务终态(completed)—— 桥接置位的触发帧。 */ +function pushLateWakeTerminal(sessionId: string, taskId: string): void { + makerChatStore.__applyStreamEventForTest(sessionId, { + sessionId, + type: 'agent_task_update', + source: 'claude-code', + data: { + provider: 'claude-code', + taskId, + taskType: 'local_agent', + status: 'completed', + }, + } as CCAgentStreamEvent); +} + +function isRunningInSnapshot(sessionId: string): boolean { + return makerChatStore.getRunningSnapshot().get(sessionId)?.isRunning ?? false; +} + +/** 一轮正常主 turn:启动 → Done。 */ +function runMainTurn(sessionId: string): void { + pushStatus(sessionId, { isRunning: true, status: 'Working' }); + pushStatus(sessionId, { isRunning: false, status: 'Done' }); +} + +describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { + it('主轮 Done 后迟到的 wake 终态撑住 running 快照;空权威表对账后收口熄灭', () => { + const S = 'wake-bridge-leak-reconcile'; + runMainTurn(S); + expect(isRunningInSnapshot(S)).toBe(false); + + // 迟到/误投的 wake 终态:置位桥接,running 快照被撑起(bug 现场)。 + pushLateWakeTerminal(S, 't-late'); + expect(isRunningInSnapshot(S)).toBe(true); + + // 延迟对账落地:main 权威表为空、主 turn 不在跑 → 桥接收口,spinner 熄灭。 + makerChatStore.seedBackgroundTaskSnapshots(S, [], { reconcileWakeBridge: true }); + expect(isRunningInSnapshot(S)).toBe(false); + }); + + it('权威表仍有 wake 任务在跑时不收口(不误杀真实空窗)', () => { + const S = 'wake-bridge-leak-alive-task'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-late-2'); + expect(isRunningInSnapshot(S)).toBe(true); + + // main 权威表仍报告一个 wake 型任务在跑:桥接保留,快照继续 running。 + makerChatStore.seedBackgroundTaskSnapshots( + S, + [{ taskId: 't-alive', taskType: 'local_agent' }], + { reconcileWakeBridge: true }, + ); + expect(isRunningInSnapshot(S)).toBe(true); + }); + + it('未传 reconcileWakeBridge 的既有路径行为不变(挂载水合不收口)', () => { + const S = 'wake-bridge-leak-legacy-path'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-late-3'); + expect(isRunningInSnapshot(S)).toBe(true); + + // 旧签名调用(挂载/面板路径):空表 early-return,桥接保持原状。 + makerChatStore.seedBackgroundTaskSnapshots(S, []); + expect(isRunningInSnapshot(S)).toBe(true); + }); + + it('wake turn 正常启动仍按原语义消费桥接(修复不破坏正常链路)', () => { + const S = 'wake-bridge-normal-consume'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-late-4'); + expect(isRunningInSnapshot(S)).toBe(true); + + // wake turn 启动(isTurnStart 消费一个桥接计数)→ Done:快照正常收敛。 + pushStatus(S, { isRunning: true, status: 'Working' }); + pushStatus(S, { isRunning: false, status: 'Done' }); + expect(isRunningInSnapshot(S)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index 8d9c882040..29e0392786 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -4927,6 +4927,18 @@ export function handleStreamEvent( !state.turnStoppedByUser && (state.agentStatus.isRunning || (state.agentStatus.status !== 'Done' && state.agentStatus.status !== '')); + // 主轮 Done 已越过后才置位的桥接:设计上等「wake turn 启动(isTurnStart 消费)」 + // 或「wake turn 失败的 Done(终态分支消费)」收尾;但跨会话误投 / 重放的迟到 + // 终态不会有任何后续事件跟进(fork 会话收到父会话任务的终态等),两条清除路径 + // 都永远不来,桥接会永久撑住 running 快照(spinner 永转),且 pendingTaskWake + // 不在 reconcileStaleRunningTasks 的对账覆盖内(它只收 taskUpdates 的 running + // 残留,而迟到终态本身就是 completed)。这里按「活动熄灭延迟对账」同款机制补 + // 一次权威对账兜底:到点后若 wake turn 已启动(桥接被消费)对账自然空转;只有 + // 确认「无 turn 在跑 + main 权威表无 wake 任务」才收口(见 + // seedBackgroundTaskSnapshots 的 reconcileWakeBridge)。 + if (wakesAfterTerminal && !mainTurnDoneNotCrossed) { + scheduleBackgroundTaskReconcile(event.sessionId); + } return { ...state, lastAgentMeta: incomingMeta ?? state.lastAgentMeta, @@ -6037,7 +6049,13 @@ function scheduleBackgroundTaskReconcile(sessionId: string): void { // 已无 running 条目则无账可对,不发无谓 IPC。定时器只在 store 对象初始化 // 之后才可能到点,前向引用 makerChatStore 安全。 const staleRunningCandidates = makerChatStore.captureRunningClaudeTaskIds(sessionId); - if (staleRunningCandidates.size === 0) return; + // 桥接泄漏时 taskUpdates 里往往已无 running 条目(迟到终态本身就是 completed), + // 只看 running 候选会把桥接对账挡在门外 —— pendingTaskWake > 0 时同样放行。 + if ( + staleRunningCandidates.size === 0 && + !makerChatStore.hasPendingWakeBridgeForReconcile(sessionId) + ) + return; const api = window.electronAPI?.maker; if (!api?.listSessionBackgroundTasks) return; void api @@ -6047,7 +6065,10 @@ function scheduleBackgroundTaskReconcile(sessionId: string): void { // 响应落地前再复查一次:请求在飞期间远程注册表才完成会话水合的话, // 本机「查无此会话」的空表不可用于收口镜像任务。 if (isRemoteSessionSticky(sessionId)) return; - makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { staleRunningCandidates }); + makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { + staleRunningCandidates, + reconcileWakeBridge: true, + }); }) .catch(() => { // 静默:与其余快照拉取失败同口径(失败不对账,下次翻转沿 / 挂载重试)。 @@ -7883,7 +7904,12 @@ function initGlobalListeners(options: GlobalListenerOptions = {}): void { } if (isRemoteSessionSticky(p.sessionId)) return; // 调度前粗筛:没有 running 条目就不必挂定时器;到点后还会再次捕获候选集。 - if (makerChatStore.captureRunningClaudeTaskIds(p.sessionId).size === 0) return; + // 唤醒桥接(pendingTaskWake)泄漏时 running 条目为空,同样需要对账,放行。 + if ( + makerChatStore.captureRunningClaudeTaskIds(p.sessionId).size === 0 && + !makerChatStore.hasPendingWakeBridgeForReconcile(p.sessionId) + ) + return; scheduleBackgroundTaskReconcile(p.sessionId); }, 'session-background-activity-reconcile', @@ -14374,6 +14400,14 @@ export const makerChatStore = { } return out; }, + /** + * 该会话是否有仍未消费的唤醒桥接计数(pendingTaskWake > 0)。 + * 用途:活动熄灭延迟对账的粗筛放行 —— 桥接泄漏时 taskUpdates 里往往已无 + * running 条目(迟到终态本身就是 completed),只看 running 候选会把桥接 + * 对账挡在门外(见 seedBackgroundTaskSnapshots 的 reconcileWakeBridge)。 + */ + hasPendingWakeBridgeForReconcile: (sessionId: string): boolean => + (sessions.get(sessionId)?.pendingTaskWake ?? 0) > 0, /** * 后台任务快照水合:把 main 的 listSessionBackgroundTasks 结果补进 taskUpdates。 * 只补「store 里完全没见过」的任务 —— 事件流是唯一实时源,快照可能落后于刚到 @@ -14386,14 +14420,24 @@ export const makerChatStore = { * running 且不在快照中的条目标 stopped(终态事件丢失的自愈,时序论证见 * reconcileStaleRunningTasks)。仅本机会话可传:device-link 镜像会话的快照 * 有降级空表窗口,不可当权威(与远程豁免 running 折算同口径)。 + * + * opts.reconcileWakeBridge(可选):唤醒桥接对账收口 —— 仅活动熄灭延迟对账 + * 路径可传(同 staleRunningCandidates 的本机会话口径)。快照落地后,若主 turn + * 不在跑且权威表里没有任何 wake 型任务,仍 > 0 的 pendingTaskWake 只能是 + * 「不会再有 wake turn 跟进」的迟到 / 误投终态留下的泄漏(fork 会话收到父会话 + * 任务终态、重连重放等)—— 不清会永久撑住 running 快照(spinner 永转)。wake + * turn 真要启动时 isTurnStart 本来就会消费桥接,这里的提前清零只影响空窗显示, + * 不改变任何 turn 语义;权威表里仍有 wake 任务在跑(终态未到)时不清,桥接继续 + * 履行防误报职责。 */ seedBackgroundTaskSnapshots: ( sessionId: string, tasks: Array<{ taskId: string; taskType?: string; toolUseId?: string; title?: string }>, - opts?: { staleRunningCandidates?: ReadonlySet }, + opts?: { staleRunningCandidates?: ReadonlySet; reconcileWakeBridge?: boolean }, ): void => { const candidates = opts?.staleRunningCandidates; - if (!tasks.length && !(candidates && candidates.size > 0)) return; + if (!tasks.length && !(candidates && candidates.size > 0) && !opts?.reconcileWakeBridge) + return; setState(sessionId, (s) => { let next = s; for (const t of tasks) { @@ -14419,6 +14463,22 @@ export const makerChatStore = { if (candidates && candidates.size > 0) { next = reconcileStaleRunningTasks(next, tasks, candidates); } + // 唤醒桥接对账收口(语义见方法头注释的 reconcileWakeBridge 段)。 + if ( + opts?.reconcileWakeBridge && + next.pendingTaskWake > 0 && + !next.agentStatus.isRunning && + !tasks.some( + (t) => typeof t.taskType === 'string' && WAKE_AGENT_TASK_TYPES.has(t.taskType), + ) + ) { + next = { + ...next, + pendingTaskWake: 0, + pendingTaskWakeDuringTurn: 0, + pendingTaskWakeStarted: false, + }; + } return next; }); }, From ea8ebc4254884d8ed39915da309a0b452f4d1c01 Mon Sep 17 00:00:00 2001 From: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:56:19 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(desktop):=20=E6=A1=A5=E6=8E=A5=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E5=8A=A0=E4=BB=A3=E9=99=85=E4=B8=80=E8=87=B4+?= =?UTF-8?q?=E6=9C=80=E5=B0=8F=E5=B9=B4=E9=BE=84=E5=8F=8C=E9=97=B8,?= =?UTF-8?q?=E5=9B=9E=E5=BA=94=20review=20=E7=AB=9E=E6=80=81=E6=84=8F?= =?UTF-8?q?=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 意见(greptile Issue 1 / dash-s-cindy 场景 2-4)共同点:旧空快照可能 清除请求在飞期间新置位的桥接;「main 运行表为空」也不能独立证明合法 wake 不会稍后启动;多 wake 场景不能被一次清空。 改动: - state 新增 pendingTaskWakeArmedAt(最近置位时刻,置位刷新、清零置 null); - 对账回调在发起快照请求前捕获桥接代际(capturePendingWakeBridgeCount); - seedBackgroundTaskSnapshots 的 reconcileWakeBridge 由 boolean 改为代际计数, 收口四条件:计数与代际一致(在飞无新增无消费)+ 主 turn 不在跑 + 距最近 置位超过 WAKE_BRIDGE_RECONCILE_MIN_AGE_MS(10s)+ 权威表无 wake 型任务; 条件未齐且计数仍 >0 时重新调度下一轮对账复查,不做部分清除; - 回归测试 4→7 例:新增在飞代际、最小年龄闸、多 wake 依次消费三例; 同步补齐 makerQueueState.test.ts 状态构造器的新字段。 Signed-off-by: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> --- .../__tests__/makerQueueState.test.ts | 1 + .../pendingTaskWakeBridgeReconcile.test.ts | 124 ++++++++++++++++-- .../src/renderer/lib/makerChatStore.ts | 113 +++++++++++----- 3 files changed, 190 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts index 7657426c19..6a5bfa39cd 100644 --- a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts @@ -116,6 +116,7 @@ const state = (overrides: Partial = {}): SessionChatState => ( pendingTaskWake: 0, pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, turnStoppedByUser: false, lastAgentMeta: null, ...overrides, diff --git a/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts b/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts index 1cb03dda29..9b11868d2b 100644 --- a/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts +++ b/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts @@ -8,16 +8,23 @@ * 该会话(spinner 永转),且 pendingTaskWake 不在 reconcileStaleRunningTasks * 的对账覆盖内(迟到终态本身就是 completed,不是 running 残留)。 * - * 修复:活动熄灭延迟对账路径拿到 main 权威表后收口桥接 - * (seedBackgroundTaskSnapshots 的 opts.reconcileWakeBridge)。 + * 修复:活动熄灭延迟对账路径拿到 main 权威表后收口桥接,四条件全部满足才清 + * (计数与请求前捕获的代际一致 / 主 turn 不在跑 / 距最近置位超过最小年龄 / + * 权威表无 wake 型任务),见 seedBackgroundTaskSnapshots 的 reconcileWakeBridge。 * 本测试直接驱动真实 store(__applyStatusUpdateForTest / __applyStreamEventForTest), - * 断言 getRunningSnapshot 的可观察行为。 + * 断言 getRunningSnapshot 的可观察行为;用 fake timers 控制最小年龄时钟。 */ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { makerChatStore } from '@/lib/makerChatStore'; +/** 与 makerChatStore 的 WAKE_BRIDGE_RECONCILE_MIN_AGE_MS 对齐(10s)+ 余量。 */ +const OVER_MIN_AGE_MS = 11_000; +const UNDER_MIN_AGE_MS = 2_000; + +const T0 = new Date('2026-01-01T00:00:00.000Z').getTime(); + function pushStatus( sessionId: string, partial: Partial & Pick, @@ -56,8 +63,29 @@ function runMainTurn(sessionId: string): void { pushStatus(sessionId, { isRunning: false, status: 'Done' }); } +/** 模拟延迟对账:请求前捕获代际 → 快照落地。 */ +function reconcileWithSnapshot( + sessionId: string, + tasks: Array<{ taskId: string; taskType?: string }>, +): void { + const captured = makerChatStore.capturePendingWakeBridgeCount(sessionId); + makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { + reconcileWakeBridge: captured, + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(T0); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { - it('主轮 Done 后迟到的 wake 终态撑住 running 快照;空权威表对账后收口熄灭', () => { + it('主轮 Done 后迟到的 wake 终态撑住 running 快照;静止+超龄+空权威表 → 收口熄灭', () => { const S = 'wake-bridge-leak-reconcile'; runMainTurn(S); expect(isRunningInSnapshot(S)).toBe(false); @@ -66,8 +94,80 @@ describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { pushLateWakeTerminal(S, 't-late'); expect(isRunningInSnapshot(S)).toBe(true); - // 延迟对账落地:main 权威表为空、主 turn 不在跑 → 桥接收口,spinner 熄灭。 - makerChatStore.seedBackgroundTaskSnapshots(S, [], { reconcileWakeBridge: true }); + // 超过最小年龄后延迟对账落地:main 权威表为空、计数静止、主 turn 不在跑 + // → 桥接收口,spinner 熄灭。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + reconcileWithSnapshot(S, []); + expect(isRunningInSnapshot(S)).toBe(false); + }); + + it('距最近置位不足最小年龄时不收口(合法 wake 启动慢于对账延迟不被误熄)', () => { + const S = 'wake-bridge-min-age-gate'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-late-age'); + expect(isRunningInSnapshot(S)).toBe(true); + + // 3s 级延迟对账先到:年龄不足,桥接保留,快照继续 running。 + vi.setSystemTime(T0 + UNDER_MIN_AGE_MS); + reconcileWithSnapshot(S, []); + expect(isRunningInSnapshot(S)).toBe(true); + + // 复查轮到点(已超龄)才收口。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + reconcileWithSnapshot(S, []); + expect(isRunningInSnapshot(S)).toBe(false); + }); + + it('请求在飞窗口内新置位的桥接不被旧快照清除(代际一致性)', () => { + const S = 'wake-bridge-inflight-generation'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-late-old'); + // 模拟请求发起前捕获代际(此刻计数 = 1)。 + const capturedBeforeFlight = makerChatStore.capturePendingWakeBridgeCount(S); + expect(capturedBeforeFlight).toBe(1); + + // 请求在飞期间:又一条 wake 终态置位(计数 = 2,armedAt 刷新)。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + pushLateWakeTerminal(S, 't-late-new'); + + // 旧空快照落地:计数(2)≠ 代际(1)→ 一个都不清,快照继续 running。 + makerChatStore.seedBackgroundTaskSnapshots(S, [], { + reconcileWakeBridge: capturedBeforeFlight, + }); + expect(isRunningInSnapshot(S)).toBe(true); + + // 下一轮对账:计数静止(2 = 2)且距第二次置位已超龄 → 收口。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS * 2); + reconcileWithSnapshot(S, []); + expect(isRunningInSnapshot(S)).toBe(false); + }); + + it('多 wake 依次消费:对账不一次清空尚待消费的合法计数', () => { + const S = 'wake-bridge-multi-wake'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-multi-1'); + pushLateWakeTerminal(S, 't-multi-2'); + expect(makerChatStore.capturePendingWakeBridgeCount(S)).toBe(2); + + // 捕获代际(2)后 wake turn A 启动:isTurnStart 消费一个计数(剩 1)。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + const capturedBeforeFlight = makerChatStore.capturePendingWakeBridgeCount(S); + pushStatus(S, { isRunning: true, status: 'Working' }); + expect(makerChatStore.capturePendingWakeBridgeCount(S)).toBe(1); + + // 旧快照落地:计数(1)≠ 代际(2),且主 turn 在跑 → 不清。 + makerChatStore.seedBackgroundTaskSnapshots(S, [], { + reconcileWakeBridge: capturedBeforeFlight, + }); + expect(makerChatStore.capturePendingWakeBridgeCount(S)).toBe(1); + + // turn A Done:剩余 1 个桥接继续撑住 running 快照,等 wake turn B。 + pushStatus(S, { isRunning: false, status: 'Done' }); + expect(isRunningInSnapshot(S)).toBe(true); + + // wake turn B 启动并完成:计数消费到 0,快照自然收敛。 + pushStatus(S, { isRunning: true, status: 'Working' }); + pushStatus(S, { isRunning: false, status: 'Done' }); expect(isRunningInSnapshot(S)).toBe(false); }); @@ -77,12 +177,9 @@ describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { pushLateWakeTerminal(S, 't-late-2'); expect(isRunningInSnapshot(S)).toBe(true); - // main 权威表仍报告一个 wake 型任务在跑:桥接保留,快照继续 running。 - makerChatStore.seedBackgroundTaskSnapshots( - S, - [{ taskId: 't-alive', taskType: 'local_agent' }], - { reconcileWakeBridge: true }, - ); + // main 权威表仍报告一个 wake 型任务在跑:即便静止+超龄,桥接保留。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + reconcileWithSnapshot(S, [{ taskId: 't-alive', taskType: 'local_agent' }]); expect(isRunningInSnapshot(S)).toBe(true); }); @@ -93,6 +190,7 @@ describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { expect(isRunningInSnapshot(S)).toBe(true); // 旧签名调用(挂载/面板路径):空表 early-return,桥接保持原状。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); makerChatStore.seedBackgroundTaskSnapshots(S, []); expect(isRunningInSnapshot(S)).toBe(true); }); diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index 29e0392786..afa3c85853 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -2586,6 +2586,14 @@ export interface SessionChatState { * 仅运行时使用,不持久化。 */ pendingTaskWakeStarted: boolean; + /** + * 唤醒桥接最近一次置位时刻(ms)。仅 wakesAfterTerminal 置位时刷新,清零路径 + * 顺带置 null(残留旧值无害:所有读取方都以 pendingTaskWake > 0 为前置)。 + * 用途:桥接对账收口的最小年龄闸 —— 距最近置位不足 + * WAKE_BRIDGE_RECONCILE_MIN_AGE_MS 时不收口,防止把「合法但启动慢于对账 + * 延迟的 wake 空窗」误判为泄漏(bot review:旧快照不得清新桥接)。 + */ + pendingTaskWakeArmedAt: number | null; /** * 用户主动 Stop 标记:会话级(非组件级),确保同一 session 在多窗口打开时 * 任一窗口的 Stop 都能阻止其他窗口触发预测等后续行为。 @@ -2711,6 +2719,7 @@ function createInitialState(): SessionChatState { pendingTaskWake: 0, pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, turnStoppedByUser: false, lastAgentMeta: null, }; @@ -2782,6 +2791,7 @@ export const EMPTY_SESSION_STATE: SessionChatState = Object.freeze({ pendingTaskWake: 0, pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, turnStoppedByUser: false, lastAgentMeta: null, }) as SessionChatState; @@ -4950,6 +4960,8 @@ export function handleStreamEvent( nextWake > 0 ? (state.pendingTaskWakeDuringTurn + (wakesAfterTerminal && mainTurnDoneNotCrossed ? 1 : 0)) : 0, + // 最小年龄闸的时钟起点:每次真实置位都刷新(见字段注释)。 + pendingTaskWakeArmedAt: wakesAfterTerminal ? Date.now() : state.pendingTaskWakeArmedAt, }; } @@ -5797,6 +5809,7 @@ function forceFinalizeOnSessionClosed(state: SessionChatState): SessionChatState pendingTaskWake: 0, pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, turnStoppedByUser: false, agentStatus: { ...finalized.agentStatus, @@ -6025,6 +6038,14 @@ let textDeltaFlushTimer: ReturnType | null = null; // 事件 / wake turn 落地留窗口,到点后拉一次快照走 seed+对账;每会话至多一个 // 待执行定时器(防抖),active:true 到达即取消。只在翻转沿触发,不轮询。 const BACKGROUND_TASK_RECONCILE_DELAY_MS = 3000; + +/** + * 唤醒桥接对账收口的最小年龄:距最近一次桥接置位不足此时长时一律不收口 + * (改为重新调度下一轮对账)。正常 wake 空窗是毫秒~秒级,10s 给慢机 / 高负载 + * 下的合法 wake turn 留足启动余量;真泄漏(误投 / 重放,永远无 wake 跟进) + * 只是晚 ~10s 熄灭,仍远好于修复前的永久转圈。 + */ +const WAKE_BRIDGE_RECONCILE_MIN_AGE_MS = 10_000; const backgroundTaskReconcileTimers = new Map>(); function cancelBackgroundTaskReconcile(sessionId: string): void { @@ -6051,11 +6072,11 @@ function scheduleBackgroundTaskReconcile(sessionId: string): void { const staleRunningCandidates = makerChatStore.captureRunningClaudeTaskIds(sessionId); // 桥接泄漏时 taskUpdates 里往往已无 running 条目(迟到终态本身就是 completed), // 只看 running 候选会把桥接对账挡在门外 —— pendingTaskWake > 0 时同样放行。 - if ( - staleRunningCandidates.size === 0 && - !makerChatStore.hasPendingWakeBridgeForReconcile(sessionId) - ) - return; + // 计数在请求发起前捕获(桥接代际):收口只在「响应落地时计数与代际完全一致 + // (在飞期间无新增无消费)」时发生,请求在飞窗口内新置位的桥接绝不被旧快照 + // 清除(bot review:旧快照不得清新桥接)。 + const capturedWakeBridgeCount = makerChatStore.capturePendingWakeBridgeCount(sessionId); + if (staleRunningCandidates.size === 0 && capturedWakeBridgeCount === 0) return; const api = window.electronAPI?.maker; if (!api?.listSessionBackgroundTasks) return; void api @@ -6067,7 +6088,7 @@ function scheduleBackgroundTaskReconcile(sessionId: string): void { if (isRemoteSessionSticky(sessionId)) return; makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { staleRunningCandidates, - reconcileWakeBridge: true, + reconcileWakeBridge: capturedWakeBridgeCount, }); }) .catch(() => { @@ -7907,7 +7928,7 @@ function initGlobalListeners(options: GlobalListenerOptions = {}): void { // 唤醒桥接(pendingTaskWake)泄漏时 running 条目为空,同样需要对账,放行。 if ( makerChatStore.captureRunningClaudeTaskIds(p.sessionId).size === 0 && - !makerChatStore.hasPendingWakeBridgeForReconcile(p.sessionId) + makerChatStore.capturePendingWakeBridgeCount(p.sessionId) === 0 ) return; scheduleBackgroundTaskReconcile(p.sessionId); @@ -14401,13 +14422,15 @@ export const makerChatStore = { return out; }, /** - * 该会话是否有仍未消费的唤醒桥接计数(pendingTaskWake > 0)。 - * 用途:活动熄灭延迟对账的粗筛放行 —— 桥接泄漏时 taskUpdates 里往往已无 - * running 条目(迟到终态本身就是 completed),只看 running 候选会把桥接 - * 对账挡在门外(见 seedBackgroundTaskSnapshots 的 reconcileWakeBridge)。 + * 捕获该会话当前的唤醒桥接计数(pendingTaskWake)。 + * 用途:活动熄灭延迟对账 —— ①粗筛放行:桥接泄漏时 taskUpdates 里往往已无 + * running 条目(迟到终态本身就是 completed),只看 running 候选会把桥接对账 + * 挡在门外;②桥接代际:必须在发起 listSessionBackgroundTasks **之前**捕获, + * 收口只在「响应落地时计数与代际一致」时发生,请求在飞窗口内新置位 / 被 + * isTurnStart 消费过的桥接都不收(见 seedBackgroundTaskSnapshots)。 */ - hasPendingWakeBridgeForReconcile: (sessionId: string): boolean => - (sessions.get(sessionId)?.pendingTaskWake ?? 0) > 0, + capturePendingWakeBridgeCount: (sessionId: string): number => + sessions.get(sessionId)?.pendingTaskWake ?? 0, /** * 后台任务快照水合:把 main 的 listSessionBackgroundTasks 结果补进 taskUpdates。 * 只补「store 里完全没见过」的任务 —— 事件流是唯一实时源,快照可能落后于刚到 @@ -14422,18 +14445,24 @@ export const makerChatStore = { * 有降级空表窗口,不可当权威(与远程豁免 running 折算同口径)。 * * opts.reconcileWakeBridge(可选):唤醒桥接对账收口 —— 仅活动熄灭延迟对账 - * 路径可传(同 staleRunningCandidates 的本机会话口径)。快照落地后,若主 turn - * 不在跑且权威表里没有任何 wake 型任务,仍 > 0 的 pendingTaskWake 只能是 - * 「不会再有 wake turn 跟进」的迟到 / 误投终态留下的泄漏(fork 会话收到父会话 - * 任务终态、重连重放等)—— 不清会永久撑住 running 快照(spinner 永转)。wake - * turn 真要启动时 isTurnStart 本来就会消费桥接,这里的提前清零只影响空窗显示, - * 不改变任何 turn 语义;权威表里仍有 wake 任务在跑(终态未到)时不清,桥接继续 - * 履行防误报职责。 + * 路径可传(同 staleRunningCandidates 的本机会话口径),值为**发起快照请求前** + * 捕获的 pendingTaskWake 计数(桥接代际)。快照落地后,同时满足以下全部条件 + * 才把桥接三元组清零,否则(计数仍 > 0 时)重新调度下一轮对账、留待复查: + * 1. 当前计数与代际完全一致 —— 在飞窗口内无新增置位、也无 isTurnStart 消费, + * 旧快照绝不清新桥接、也不吞多 wake 场景里尚待依次消费的合法计数; + * 2. 主 turn 不在跑; + * 3. 距最近一次置位已超过 WAKE_BRIDGE_RECONCILE_MIN_AGE_MS —— 给慢机 / + * 高负载下合法 wake turn 留足启动余量; + * 4. main 权威表里没有任何 wake 型任务。 + * 全部满足时,仍挂着的桥接只能是「不会再有 wake turn 跟进」的迟到 / 误投终态 + * 留下的泄漏(fork 会话收到父会话任务终态、重连重放等)—— 不清会永久撑住 + * running 快照(spinner 永转)。wake turn 真要启动时 isTurnStart 本来就会消费 + * 桥接,收口只影响空窗显示,不改变任何 turn 语义。 */ seedBackgroundTaskSnapshots: ( sessionId: string, tasks: Array<{ taskId: string; taskType?: string; toolUseId?: string; title?: string }>, - opts?: { staleRunningCandidates?: ReadonlySet; reconcileWakeBridge?: boolean }, + opts?: { staleRunningCandidates?: ReadonlySet; reconcileWakeBridge?: number }, ): void => { const candidates = opts?.staleRunningCandidates; if (!tasks.length && !(candidates && candidates.size > 0) && !opts?.reconcileWakeBridge) @@ -14463,21 +14492,35 @@ export const makerChatStore = { if (candidates && candidates.size > 0) { next = reconcileStaleRunningTasks(next, tasks, candidates); } - // 唤醒桥接对账收口(语义见方法头注释的 reconcileWakeBridge 段)。 - if ( - opts?.reconcileWakeBridge && - next.pendingTaskWake > 0 && - !next.agentStatus.isRunning && - !tasks.some( + // 唤醒桥接对账收口(四条件语义见方法头注释的 reconcileWakeBridge 段)。 + const capturedWakeBridge = opts?.reconcileWakeBridge ?? 0; + if (capturedWakeBridge > 0 && next.pendingTaskWake > 0) { + const bridgeSettled = next.pendingTaskWake === capturedWakeBridge; + const bridgeAgedOut = + next.pendingTaskWakeArmedAt !== null && + Date.now() - next.pendingTaskWakeArmedAt >= WAKE_BRIDGE_RECONCILE_MIN_AGE_MS; + const authorityHasWakeTask = tasks.some( (t) => typeof t.taskType === 'string' && WAKE_AGENT_TASK_TYPES.has(t.taskType), - ) - ) { - next = { - ...next, - pendingTaskWake: 0, - pendingTaskWakeDuringTurn: 0, - pendingTaskWakeStarted: false, - }; + ); + if ( + bridgeSettled && + bridgeAgedOut && + !next.agentStatus.isRunning && + !authorityHasWakeTask + ) { + next = { + ...next, + pendingTaskWake: 0, + pendingTaskWakeDuringTurn: 0, + pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, + }; + } else { + // 条件未齐(在飞变动 / 年龄不足 / turn 在跑 / 权威表仍有 wake 任务): + // 本轮不动,重新调度下一轮对账复查。真泄漏最终会静止、超龄、表空, + // 收口收敛;合法 wake 则会经 isTurnStart 消费计数,后续对账自然空转。 + scheduleBackgroundTaskReconcile(sessionId); + } } return next; }); From 5ee8cc74f2f783a72125e7c947233e91cf746e80 Mon Sep 17 00:00:00 2001 From: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:17:07 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(desktop):=20=E6=A1=A5=E6=8E=A5=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E4=BB=A5=20main=20continuation=20claim=20=E4=B8=BA?= =?UTF-8?q?=E6=9D=83=E5=A8=81=E5=88=A4=E6=8D=AE=20+=20=E7=BD=AE=E4=BD=8D?= =?UTF-8?q?=E4=BB=A3=E6=AC=A1=E9=98=B2=20ABA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应第二轮 review(codex P2 两条 / greptile P1): - codex P2:noteBackgroundTaskEvent 在任务终态立即将其移出 runningBackgroundTasks,listBackgroundTasks() 的空快照不能证明没有待启动 的 continuation。新增贯通链路:claude-code agent countPendingWakeContinuations()(awaiting+active claim 数,cancelled 不计) → base-agent 接口声明 → Session 透传 → LIST_SESSION_BACKGROUND_TASKS IPC 返回 pendingContinuations → renderer 收口第 6 条件:计数**明确为 0** 才允许 清桥接;信号缺失(旧 main)按不可用处理,只重试、不退化回空表判据。 - greptile P1(ABA):state 新增 pendingTaskWakeGen(仅置位自增),对账捕获 与收口比对代次 —— 在飞窗口内「消费后又置位、计数恰好回到捕获值」不再可能 被误认为同一代际。 - 回归测试 7→10 例:新增 continuation>0 不收口 / 信号缺失不收口 / ABA 代次 三例;makerQueueState 构造器补 pendingTaskWakeGen。 验证:新测试 10/10、makerQueueState 18/18、周边 48 例全绿;desktop typecheck 0 错;maker-core 直跑 tsc 与主干错误集完全一致(16 个,全为 codex/pi 测试 存量,本次改动的三个文件零新增)。 Signed-off-by: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> --- apps/desktop/src/main/maker-ipc/register.ts | 8 +- apps/desktop/src/preload/preload.ts | 2 + .../__tests__/makerQueueState.test.ts | 1 + .../pendingTaskWakeBridgeReconcile.test.ts | 111 ++++++++++++++---- .../src/renderer/lib/makerChatStore.ts | 90 +++++++++----- apps/desktop/src/renderer/vite-env.d.ts | 2 + packages/maker-core/src/agents/base-agent.ts | 9 ++ .../src/agents/claude-code/index.ts | 16 +++ packages/maker-core/src/session.ts | 9 ++ 9 files changed, 195 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 14c11e7566..aa1ae5b7ea 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -5488,7 +5488,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ipcMain.handle(MAKER_INVOKE.LIST_SESSION_BACKGROUND_TASKS, (_e, sessionId: unknown) => { if (typeof sessionId !== 'string') throwIpcError('INVALID_PARAMS', 'sessionId required'); const live = maker.getSession(sessionId); - return { tasks: live ? live.listBackgroundTasks() : [] }; + // pendingContinuations:「任务已终态、wake turn 尚未启动或仍在跑」的 + // continuation claim 数。tasks 在任务终态后立即不含该任务,renderer 的 + // 唤醒桥接对账不能拿空 tasks 当「无后续」—— 必须本字段为 0 才允许收口。 + return { + tasks: live ? live.listBackgroundTasks() : [], + pendingContinuations: live ? live.countPendingWakeContinuations() : 0, + }; }); // workflow 逐 agent 进度树(只读)。从活跃会话拿 workDir + sdkSessionId → 推导 Claude Code diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a6424f7cc9..ccc48be57c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -5118,6 +5118,8 @@ contextBridge.exposeInMainWorld('electronAPI', { sessionId: string, ): Promise<{ tasks: Array<{ taskId: string; taskType?: string; toolUseId?: string; title?: string }>; + /** 「任务已终态、wake turn 尚未启动或仍在跑」的 continuation claim 数(桥接对账收口权威依据)。 */ + pendingContinuations?: number; }> => ipcRenderer.invoke('maker:session-background-tasks:list', sessionId), /** 通用 OAuth 供应商(目录 auth.oauth 描述符驱动)登录 / 登出 / 取消。 */ providerOAuthLogin: ( diff --git a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts index 6a5bfa39cd..4b29857da6 100644 --- a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts @@ -117,6 +117,7 @@ const state = (overrides: Partial = {}): SessionChatState => ( pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, pendingTaskWakeArmedAt: null, + pendingTaskWakeGen: 0, turnStoppedByUser: false, lastAgentMeta: null, ...overrides, diff --git a/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts b/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts index 9b11868d2b..aaa143d940 100644 --- a/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts +++ b/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts @@ -8,11 +8,12 @@ * 该会话(spinner 永转),且 pendingTaskWake 不在 reconcileStaleRunningTasks * 的对账覆盖内(迟到终态本身就是 completed,不是 running 残留)。 * - * 修复:活动熄灭延迟对账路径拿到 main 权威表后收口桥接,四条件全部满足才清 - * (计数与请求前捕获的代际一致 / 主 turn 不在跑 / 距最近置位超过最小年龄 / - * 权威表无 wake 型任务),见 seedBackgroundTaskSnapshots 的 reconcileWakeBridge。 - * 本测试直接驱动真实 store(__applyStatusUpdateForTest / __applyStreamEventForTest), - * 断言 getRunningSnapshot 的可观察行为;用 fake timers 控制最小年龄时钟。 + * 修复:活动熄灭延迟对账路径拿到 main 权威后收口桥接,六条件全部满足才清 + * (计数与代际一致 / 置位代次未变 / 主 turn 不在跑 / 距最近置位超过最小年龄 / + * 权威表无 wake 型任务 / main 权威 continuation 计数明确为 0),见 + * seedBackgroundTaskSnapshots 的 reconcileWakeBridge。本测试直接驱动真实 store + * (__applyStatusUpdateForTest / __applyStreamEventForTest),断言 getRunningSnapshot + * 的可观察行为;用 fake timers 控制最小年龄时钟。 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -63,14 +64,16 @@ function runMainTurn(sessionId: string): void { pushStatus(sessionId, { isRunning: false, status: 'Done' }); } -/** 模拟延迟对账:请求前捕获代际 → 快照落地。 */ +/** 模拟延迟对账:请求前捕获代际 → 快照落地(main 权威 continuation 计数可指定)。 */ function reconcileWithSnapshot( sessionId: string, tasks: Array<{ taskId: string; taskType?: string }>, + pendingContinuations: number | null = 0, ): void { - const captured = makerChatStore.capturePendingWakeBridgeCount(sessionId); + const captured = makerChatStore.capturePendingWakeBridge(sessionId); makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { reconcileWakeBridge: captured, + authorityPendingContinuations: pendingContinuations, }); } @@ -85,7 +88,7 @@ afterEach(() => { }); describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { - it('主轮 Done 后迟到的 wake 终态撑住 running 快照;静止+超龄+空权威表 → 收口熄灭', () => { + it('主轮 Done 后迟到的 wake 终态撑住 running 快照;六条件齐 → 收口熄灭', () => { const S = 'wake-bridge-leak-reconcile'; runMainTurn(S); expect(isRunningInSnapshot(S)).toBe(false); @@ -94,13 +97,40 @@ describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { pushLateWakeTerminal(S, 't-late'); expect(isRunningInSnapshot(S)).toBe(true); - // 超过最小年龄后延迟对账落地:main 权威表为空、计数静止、主 turn 不在跑 - // → 桥接收口,spinner 熄灭。 + // 超过最小年龄后延迟对账落地:权威表空、continuation 计数 0、代际静止、 + // 主 turn 不在跑 → 桥接收口,spinner 熄灭。 vi.setSystemTime(T0 + OVER_MIN_AGE_MS); - reconcileWithSnapshot(S, []); + reconcileWithSnapshot(S, [], 0); expect(isRunningInSnapshot(S)).toBe(false); }); + it('main 权威 continuation 计数 > 0 时不收口(空任务表 ≠ 无待启动的 wake)', () => { + const S = 'wake-bridge-pending-continuation'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-cont'); + expect(isRunningInSnapshot(S)).toBe(true); + + // 任务终态后立即从运行表出表,但 continuation claim 仍 awaiting: + // 即便任务表为空、代际静止、超龄,也必须保留桥接。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + reconcileWithSnapshot(S, [], 1); + expect(isRunningInSnapshot(S)).toBe(true); + + // continuation 归零后的下一轮对账才收口。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS * 2); + reconcileWithSnapshot(S, [], 0); + expect(isRunningInSnapshot(S)).toBe(false); + }); + + it('continuation 信号缺失(旧 main,null)时不收口 —— 不退化回空表判据', () => { + const S = 'wake-bridge-missing-signal'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-null-signal'); + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + reconcileWithSnapshot(S, [], null); + expect(isRunningInSnapshot(S)).toBe(true); + }); + it('距最近置位不足最小年龄时不收口(合法 wake 启动慢于对账延迟不被误熄)', () => { const S = 'wake-bridge-min-age-gate'; runMainTurn(S); @@ -109,36 +139,66 @@ describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { // 3s 级延迟对账先到:年龄不足,桥接保留,快照继续 running。 vi.setSystemTime(T0 + UNDER_MIN_AGE_MS); - reconcileWithSnapshot(S, []); + reconcileWithSnapshot(S, [], 0); expect(isRunningInSnapshot(S)).toBe(true); // 复查轮到点(已超龄)才收口。 vi.setSystemTime(T0 + OVER_MIN_AGE_MS); - reconcileWithSnapshot(S, []); + reconcileWithSnapshot(S, [], 0); expect(isRunningInSnapshot(S)).toBe(false); }); - it('请求在飞窗口内新置位的桥接不被旧快照清除(代际一致性)', () => { + it('请求在飞窗口内新置位的桥接不被旧快照清除(计数代际)', () => { const S = 'wake-bridge-inflight-generation'; runMainTurn(S); pushLateWakeTerminal(S, 't-late-old'); // 模拟请求发起前捕获代际(此刻计数 = 1)。 - const capturedBeforeFlight = makerChatStore.capturePendingWakeBridgeCount(S); - expect(capturedBeforeFlight).toBe(1); + const capturedBeforeFlight = makerChatStore.capturePendingWakeBridge(S); + expect(capturedBeforeFlight.count).toBe(1); - // 请求在飞期间:又一条 wake 终态置位(计数 = 2,armedAt 刷新)。 + // 请求在飞期间:又一条 wake 终态置位(计数 = 2,armedAt / 代次刷新)。 vi.setSystemTime(T0 + OVER_MIN_AGE_MS); pushLateWakeTerminal(S, 't-late-new'); // 旧空快照落地:计数(2)≠ 代际(1)→ 一个都不清,快照继续 running。 makerChatStore.seedBackgroundTaskSnapshots(S, [], { reconcileWakeBridge: capturedBeforeFlight, + authorityPendingContinuations: 0, }); expect(isRunningInSnapshot(S)).toBe(true); - // 下一轮对账:计数静止(2 = 2)且距第二次置位已超龄 → 收口。 + // 下一轮对账:代际静止且距第二次置位已超龄 → 收口。 vi.setSystemTime(T0 + OVER_MIN_AGE_MS * 2); - reconcileWithSnapshot(S, []); + reconcileWithSnapshot(S, [], 0); + expect(isRunningInSnapshot(S)).toBe(false); + }); + + it('ABA 碰撞:在飞窗口内消费后又置位、计数回到捕获值 —— 代次不同,不收口', () => { + const S = 'wake-bridge-aba-generation'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-aba-1'); + // 请求发起前捕获:count = 1, gen = g。 + const capturedBeforeFlight = makerChatStore.capturePendingWakeBridge(S); + expect(capturedBeforeFlight.count).toBe(1); + + // 在飞期间:wake turn 启动消费桥接(count → 0)→ Done → 新终态又置位 + // (count 回到 1,但置位代次 +1)。 + pushStatus(S, { isRunning: true, status: 'Working' }); + pushStatus(S, { isRunning: false, status: 'Done' }); + pushLateWakeTerminal(S, 't-aba-2'); + expect(makerChatStore.capturePendingWakeBridge(S).count).toBe(1); + expect(makerChatStore.capturePendingWakeBridge(S).gen).not.toBe(capturedBeforeFlight.gen); + + // 超长延迟的旧快照落地:计数相等(1 = 1)且新桥接已超龄,但代次不同 → 不收口。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS * 3); + makerChatStore.seedBackgroundTaskSnapshots(S, [], { + reconcileWakeBridge: capturedBeforeFlight, + authorityPendingContinuations: 0, + }); + expect(isRunningInSnapshot(S)).toBe(true); + + // 用当前代际的对账才允许收口。 + reconcileWithSnapshot(S, [], 0); expect(isRunningInSnapshot(S)).toBe(false); }); @@ -147,19 +207,20 @@ describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { runMainTurn(S); pushLateWakeTerminal(S, 't-multi-1'); pushLateWakeTerminal(S, 't-multi-2'); - expect(makerChatStore.capturePendingWakeBridgeCount(S)).toBe(2); + expect(makerChatStore.capturePendingWakeBridge(S).count).toBe(2); // 捕获代际(2)后 wake turn A 启动:isTurnStart 消费一个计数(剩 1)。 vi.setSystemTime(T0 + OVER_MIN_AGE_MS); - const capturedBeforeFlight = makerChatStore.capturePendingWakeBridgeCount(S); + const capturedBeforeFlight = makerChatStore.capturePendingWakeBridge(S); pushStatus(S, { isRunning: true, status: 'Working' }); - expect(makerChatStore.capturePendingWakeBridgeCount(S)).toBe(1); + expect(makerChatStore.capturePendingWakeBridge(S).count).toBe(1); // 旧快照落地:计数(1)≠ 代际(2),且主 turn 在跑 → 不清。 makerChatStore.seedBackgroundTaskSnapshots(S, [], { reconcileWakeBridge: capturedBeforeFlight, + authorityPendingContinuations: 0, }); - expect(makerChatStore.capturePendingWakeBridgeCount(S)).toBe(1); + expect(makerChatStore.capturePendingWakeBridge(S).count).toBe(1); // turn A Done:剩余 1 个桥接继续撑住 running 快照,等 wake turn B。 pushStatus(S, { isRunning: false, status: 'Done' }); @@ -177,9 +238,9 @@ describe('pendingTaskWake 桥接泄漏对账(reconcileWakeBridge)', () => { pushLateWakeTerminal(S, 't-late-2'); expect(isRunningInSnapshot(S)).toBe(true); - // main 权威表仍报告一个 wake 型任务在跑:即便静止+超龄,桥接保留。 + // main 权威表仍报告一个 wake 型任务在跑:即便其余条件齐,桥接保留。 vi.setSystemTime(T0 + OVER_MIN_AGE_MS); - reconcileWithSnapshot(S, [{ taskId: 't-alive', taskType: 'local_agent' }]); + reconcileWithSnapshot(S, [{ taskId: 't-alive', taskType: 'local_agent' }], 0); expect(isRunningInSnapshot(S)).toBe(true); }); diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index afa3c85853..0340baafa2 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -2594,6 +2594,13 @@ export interface SessionChatState { * 延迟的 wake 空窗」误判为泄漏(bot review:旧快照不得清新桥接)。 */ pendingTaskWakeArmedAt: number | null; + /** + * 唤醒桥接置位代次:仅 wakesAfterTerminal 置位时自增,消费 / 清零不动。 + * 用途:桥接对账的代际标识 —— 收口要求「计数与代际捕获值一致 **且** 代次未变」。 + * 只比计数会被 ABA 碰撞骗过(快照响应在飞超长时,旧桥接被消费、新终态又把 + * 计数恢复到捕获值),代次单调递增使「期间有过新置位」必然可见(bot review P1)。 + */ + pendingTaskWakeGen: number; /** * 用户主动 Stop 标记:会话级(非组件级),确保同一 session 在多窗口打开时 * 任一窗口的 Stop 都能阻止其他窗口触发预测等后续行为。 @@ -2720,6 +2727,7 @@ function createInitialState(): SessionChatState { pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, pendingTaskWakeArmedAt: null, + pendingTaskWakeGen: 0, turnStoppedByUser: false, lastAgentMeta: null, }; @@ -2792,6 +2800,7 @@ export const EMPTY_SESSION_STATE: SessionChatState = Object.freeze({ pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, pendingTaskWakeArmedAt: null, + pendingTaskWakeGen: 0, turnStoppedByUser: false, lastAgentMeta: null, }) as SessionChatState; @@ -4962,6 +4971,8 @@ export function handleStreamEvent( : 0, // 最小年龄闸的时钟起点:每次真实置位都刷新(见字段注释)。 pendingTaskWakeArmedAt: wakesAfterTerminal ? Date.now() : state.pendingTaskWakeArmedAt, + // 置位代次:对账收口的 ABA 防护(见字段注释)。 + pendingTaskWakeGen: state.pendingTaskWakeGen + (wakesAfterTerminal ? 1 : 0), }; } @@ -5810,6 +5821,7 @@ function forceFinalizeOnSessionClosed(state: SessionChatState): SessionChatState pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, pendingTaskWakeArmedAt: null, + pendingTaskWakeGen: state.pendingTaskWakeGen, turnStoppedByUser: false, agentStatus: { ...finalized.agentStatus, @@ -6072,23 +6084,28 @@ function scheduleBackgroundTaskReconcile(sessionId: string): void { const staleRunningCandidates = makerChatStore.captureRunningClaudeTaskIds(sessionId); // 桥接泄漏时 taskUpdates 里往往已无 running 条目(迟到终态本身就是 completed), // 只看 running 候选会把桥接对账挡在门外 —— pendingTaskWake > 0 时同样放行。 - // 计数在请求发起前捕获(桥接代际):收口只在「响应落地时计数与代际完全一致 - // (在飞期间无新增无消费)」时发生,请求在飞窗口内新置位的桥接绝不被旧快照 - // 清除(bot review:旧快照不得清新桥接)。 - const capturedWakeBridgeCount = makerChatStore.capturePendingWakeBridgeCount(sessionId); - if (staleRunningCandidates.size === 0 && capturedWakeBridgeCount === 0) return; + // 桥接代际在请求发起前捕获(计数 + 置位代次):收口只在「响应落地时计数与 + // 代际完全一致且代次未变」时发生 —— 请求在飞窗口内新置位的桥接绝不被旧快照 + // 清除,代次单调另挡 ABA 碰撞(bot review:旧快照不得清新桥接 / P1 ABA)。 + const capturedWakeBridge = makerChatStore.capturePendingWakeBridge(sessionId); + if (staleRunningCandidates.size === 0 && capturedWakeBridge.count === 0) return; const api = window.electronAPI?.maker; if (!api?.listSessionBackgroundTasks) return; void api .listSessionBackgroundTasks(sessionId) - .then(({ tasks }) => { + .then(({ tasks, pendingContinuations }) => { if (!Array.isArray(tasks)) return; // 响应落地前再复查一次:请求在飞期间远程注册表才完成会话水合的话, // 本机「查无此会话」的空表不可用于收口镜像任务。 if (isRemoteSessionSticky(sessionId)) return; makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { staleRunningCandidates, - reconcileWakeBridge: capturedWakeBridgeCount, + reconcileWakeBridge: capturedWakeBridge, + // main 权威的「任务已终态、wake turn 尚未启动或仍在跑」continuation + // 计数。旧 main(字段缺失)按「信号不可用」处理 —— 不收口,只重试, + // 绝不退化回「拿空任务表当无后续」的旧判据。 + authorityPendingContinuations: + typeof pendingContinuations === 'number' ? pendingContinuations : null, }); }) .catch(() => { @@ -7928,7 +7945,7 @@ function initGlobalListeners(options: GlobalListenerOptions = {}): void { // 唤醒桥接(pendingTaskWake)泄漏时 running 条目为空,同样需要对账,放行。 if ( makerChatStore.captureRunningClaudeTaskIds(p.sessionId).size === 0 && - makerChatStore.capturePendingWakeBridgeCount(p.sessionId) === 0 + makerChatStore.capturePendingWakeBridge(p.sessionId).count === 0 ) return; scheduleBackgroundTaskReconcile(p.sessionId); @@ -14422,15 +14439,18 @@ export const makerChatStore = { return out; }, /** - * 捕获该会话当前的唤醒桥接计数(pendingTaskWake)。 + * 捕获该会话当前的唤醒桥接代际(计数 + 置位代次)。 * 用途:活动熄灭延迟对账 —— ①粗筛放行:桥接泄漏时 taskUpdates 里往往已无 * running 条目(迟到终态本身就是 completed),只看 running 候选会把桥接对账 * 挡在门外;②桥接代际:必须在发起 listSessionBackgroundTasks **之前**捕获, - * 收口只在「响应落地时计数与代际一致」时发生,请求在飞窗口内新置位 / 被 - * isTurnStart 消费过的桥接都不收(见 seedBackgroundTaskSnapshots)。 + * 收口只在「响应落地时计数与代际一致且置位代次未变」时发生 —— 请求在飞窗口 + * 内新置位 / 被 isTurnStart 消费过的桥接都不收;代次单调递增另挡「消费后又 + * 置位恰好回到原计数」的 ABA 碰撞(见 seedBackgroundTaskSnapshots)。 */ - capturePendingWakeBridgeCount: (sessionId: string): number => - sessions.get(sessionId)?.pendingTaskWake ?? 0, + capturePendingWakeBridge: (sessionId: string): { count: number; gen: number } => { + const s = sessions.get(sessionId); + return { count: s?.pendingTaskWake ?? 0, gen: s?.pendingTaskWakeGen ?? 0 }; + }, /** * 后台任务快照水合:把 main 的 listSessionBackgroundTasks 结果补进 taskUpdates。 * 只补「store 里完全没见过」的任务 —— 事件流是唯一实时源,快照可能落后于刚到 @@ -14446,14 +14466,21 @@ export const makerChatStore = { * * opts.reconcileWakeBridge(可选):唤醒桥接对账收口 —— 仅活动熄灭延迟对账 * 路径可传(同 staleRunningCandidates 的本机会话口径),值为**发起快照请求前** - * 捕获的 pendingTaskWake 计数(桥接代际)。快照落地后,同时满足以下全部条件 - * 才把桥接三元组清零,否则(计数仍 > 0 时)重新调度下一轮对账、留待复查: - * 1. 当前计数与代际完全一致 —— 在飞窗口内无新增置位、也无 isTurnStart 消费, - * 旧快照绝不清新桥接、也不吞多 wake 场景里尚待依次消费的合法计数; - * 2. 主 turn 不在跑; - * 3. 距最近一次置位已超过 WAKE_BRIDGE_RECONCILE_MIN_AGE_MS —— 给慢机 / + * 捕获的桥接代际(计数 + 置位代次)。快照落地后,同时满足以下全部条件才把 + * 桥接清零,否则(计数仍 > 0 时)重新调度下一轮对账、留待复查: + * 1. 当前计数与代际捕获值完全一致 —— 在飞窗口内无新增置位、也无 isTurnStart + * 消费,旧快照绝不清新桥接、也不吞多 wake 场景里尚待依次消费的合法计数; + * 2. 置位代次未变 —— 单调代次挡「在飞窗口内先消费后置位、计数恰好回到捕获值」 + * 的 ABA 碰撞(bot review P1); + * 3. 主 turn 不在跑; + * 4. 距最近一次置位已超过 WAKE_BRIDGE_RECONCILE_MIN_AGE_MS —— 给慢机 / * 高负载下合法 wake turn 留足启动余量; - * 4. main 权威表里没有任何 wake 型任务。 + * 5. main 权威表里没有任何 wake 型任务; + * 6. opts.authorityPendingContinuations === 0 —— main 权威的「任务已终态、 + * wake turn 尚未启动或仍在跑」continuation claim 数。这是收口的**决定性 + * 判据**:任务终态后立即从运行表出表,条件 5 的空表不能证明没有待启动的 + * continuation(bot review P2 两条);信号缺失(null,旧 main)按不可用 + * 处理 —— 不收口,只重试。 * 全部满足时,仍挂着的桥接只能是「不会再有 wake turn 跟进」的迟到 / 误投终态 * 留下的泄漏(fork 会话收到父会话任务终态、重连重放等)—— 不清会永久撑住 * running 快照(spinner 永转)。wake turn 真要启动时 isTurnStart 本来就会消费 @@ -14462,10 +14489,14 @@ export const makerChatStore = { seedBackgroundTaskSnapshots: ( sessionId: string, tasks: Array<{ taskId: string; taskType?: string; toolUseId?: string; title?: string }>, - opts?: { staleRunningCandidates?: ReadonlySet; reconcileWakeBridge?: number }, + opts?: { + staleRunningCandidates?: ReadonlySet; + reconcileWakeBridge?: { count: number; gen: number }; + authorityPendingContinuations?: number | null; + }, ): void => { const candidates = opts?.staleRunningCandidates; - if (!tasks.length && !(candidates && candidates.size > 0) && !opts?.reconcileWakeBridge) + if (!tasks.length && !(candidates && candidates.size > 0) && !opts?.reconcileWakeBridge?.count) return; setState(sessionId, (s) => { let next = s; @@ -14492,21 +14523,26 @@ export const makerChatStore = { if (candidates && candidates.size > 0) { next = reconcileStaleRunningTasks(next, tasks, candidates); } - // 唤醒桥接对账收口(四条件语义见方法头注释的 reconcileWakeBridge 段)。 - const capturedWakeBridge = opts?.reconcileWakeBridge ?? 0; - if (capturedWakeBridge > 0 && next.pendingTaskWake > 0) { - const bridgeSettled = next.pendingTaskWake === capturedWakeBridge; + // 唤醒桥接对账收口(六条件语义见方法头注释的 reconcileWakeBridge 段)。 + const capturedWakeBridge = opts?.reconcileWakeBridge; + if (capturedWakeBridge && capturedWakeBridge.count > 0 && next.pendingTaskWake > 0) { + const bridgeSettled = + next.pendingTaskWake === capturedWakeBridge.count && + next.pendingTaskWakeGen === capturedWakeBridge.gen; const bridgeAgedOut = next.pendingTaskWakeArmedAt !== null && Date.now() - next.pendingTaskWakeArmedAt >= WAKE_BRIDGE_RECONCILE_MIN_AGE_MS; const authorityHasWakeTask = tasks.some( (t) => typeof t.taskType === 'string' && WAKE_AGENT_TASK_TYPES.has(t.taskType), ); + // 决定性判据:main 权威 continuation 计数明确为 0 才允许收口。 + const authorityConfirmsNoContinuation = opts?.authorityPendingContinuations === 0; if ( bridgeSettled && bridgeAgedOut && !next.agentStatus.isRunning && - !authorityHasWakeTask + !authorityHasWakeTask && + authorityConfirmsNoContinuation ) { next = { ...next, diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index f5d114d709..e67b7e062c 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -4780,6 +4780,8 @@ interface ElectronAPI { /** 会话仍在运行的后台任务快照(挂载 / 重载后补回存量;实时增量走事件流)。 */ listSessionBackgroundTasks: (sessionId: string) => Promise<{ tasks: Array<{ taskId: string; taskType?: string; toolUseId?: string; title?: string }>; + /** 「任务已终态、wake turn 尚未启动或仍在跑」的 continuation claim 数(桥接对账收口权威依据)。 */ + pendingContinuations?: number; }>; /** * renderer → main 单向镜像「模型显示/隐藏」override 整张快照(modelVisibilityPrefs)。 diff --git a/packages/maker-core/src/agents/base-agent.ts b/packages/maker-core/src/agents/base-agent.ts index 83a1612a5a..939cb1f30e 100644 --- a/packages/maker-core/src/agents/base-agent.ts +++ b/packages/maker-core/src/agents/base-agent.ts @@ -1523,6 +1523,15 @@ export interface AgentSessionHandle { */ listBackgroundTasks?(): BackgroundTaskSnapshot[]; + /** + * 「任务已终态、wake turn 尚未启动或仍在跑」的 continuation claim 数 + * (awaiting + active,cancelled 不计)。listBackgroundTasks 在任务终态后 + * 立即不再包含该任务,空快照不能证明后续没有 wake turn —— renderer 的 + * 唤醒桥接对账以本计数为收口权威依据。不支持的 agent 留空(Session 层 + * 回退为 0,消费方按「信号不可用 → 不收口」保守处理)。 + */ + countPendingWakeContinuations?(): number; + /** * Resolve the provider claim attached atomically to a specific `done` event. * Returns null when that event has no matching continuation boundary. diff --git a/packages/maker-core/src/agents/claude-code/index.ts b/packages/maker-core/src/agents/claude-code/index.ts index cf5e85e9b3..ac082ec967 100644 --- a/packages/maker-core/src/agents/claude-code/index.ts +++ b/packages/maker-core/src/agents/claude-code/index.ts @@ -5691,6 +5691,22 @@ export class ClaudeCodeAgent extends BaseAgent { })); }, + countPendingWakeContinuations() { + // 「任务已终态、wake turn 尚未启动或仍在跑」的 continuation claim 数。 + // runningBackgroundTasks 在任务终态时立即出表(noteBackgroundTaskEvent), + // 因此 listBackgroundTasks() 的空快照**不能**证明后续没有 wake turn —— + // renderer 的唤醒桥接对账必须以本计数为权威依据(为 0 才允许收口), + // 而不是把「没有仍在运行的任务」当成「没有待启动的 continuation」。 + // cancelled 不计(不会再有 wake turn 跟进);awaiting / active 都计 + // (active 期间主 turn isRunning 本就为 true,双重保护)。 + if (closed) return 0; + let n = 0; + for (const claim of continuationClaims.values()) { + if (claim.state === 'awaiting' || claim.state === 'active') n += 1; + } + return n; + }, + beginTurnContinuationWait(continuationId?: number) { if (continuationId === undefined) return null; return continuationClaims.get(continuationId)?.state ?? null; diff --git a/packages/maker-core/src/session.ts b/packages/maker-core/src/session.ts index 969137a0fb..1229c6339d 100644 --- a/packages/maker-core/src/session.ts +++ b/packages/maker-core/src/session.ts @@ -794,6 +794,15 @@ export class Session { return this.handle.listBackgroundTasks?.() ?? []; } + /** + * 「任务已终态、wake turn 尚未启动或仍在跑」的 continuation claim 数。 + * 会话已关闭 / agent 不支持 → 0(此时不会再有 wake turn,0 即事实)。 + */ + countPendingWakeContinuations(): number { + if (this.status === 'closed' || this.status === 'error') return 0; + return this.handle.countPendingWakeContinuations?.() ?? 0; + } + /** * Resolve the provider-owned continuation claim already attached to this * exact `done`. Host observers must never infer this from the current task From 1a0c89ccad28e3a2db158d0c0e3093d3ddae6fc3 Mon Sep 17 00:00:00 2001 From: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:20:34 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(desktop):=20=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=E7=B2=97=E7=AD=9B=E7=94=A8=E4=BE=8B=E9=80=82=E9=85=8D=E6=A1=A5?= =?UTF-8?q?=E6=8E=A5=E6=94=BE=E8=A1=8C=E8=AF=AD=E4=B9=89(CI=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=A0=B9=E5=9B=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI「Linux unit tests (2/2)」失败根因:既有用例『远程会话豁免;无 running 条目 不发 IPC』的第二段用 wake 型 completed 终态构造「无 running 条目」场景 —— 该帧 在本 PR 语义下会置位唤醒桥接并放行对账 IPC,而这正是修复目的(旧断言把泄漏 永转路径固化成了预期)。 调整:①原段改用 local_bash 终态(白名单外不置位),保留「没活不空跑 IPC」的 原验证意图;②追加 wake 终态段,固化「桥接置位 → 粗筛放行对账」的新语义,并 指向 pendingTaskWakeBridgeReconcile.test.ts 的收口条件测试。 本地实跑:该文件 11/11、桥接回归 10/10 全绿。 Signed-off-by: mengfanyu9410-hub <282780248+mengfanyu9410-hub@users.noreply.github.com> --- .../makerChatStoreBackgroundTaskReconcile.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/__tests__/makerChatStoreBackgroundTaskReconcile.test.ts b/apps/desktop/src/renderer/__tests__/makerChatStoreBackgroundTaskReconcile.test.ts index 57a7779d17..ff127bc17f 100644 --- a/apps/desktop/src/renderer/__tests__/makerChatStoreBackgroundTaskReconcile.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerChatStoreBackgroundTaskReconcile.test.ts @@ -409,11 +409,21 @@ describe('活动熄灭触发的 stale running 对账', () => { 'running', ); - // 本机会话但没有 running 条目:调度前粗筛直接跳过 - applyTask(idleSid, { taskId: 't2', status: 'completed', taskType: 'local_agent' }); + // 本机会话但没有 running 条目、也没有唤醒桥接(非 wake 型终态不置位): + // 调度前粗筛直接跳过。 + applyTask(idleSid, { taskId: 't2', status: 'completed', taskType: 'local_bash' }); emitActivity({ sessionId: idleSid, active: false }); await vi.advanceTimersByTimeAsync(3000); expect(listTasks).not.toHaveBeenCalled(); + + // wake 型终态会置位唤醒桥接(pendingTaskWake):即便没有 running 条目, + // 粗筛也要放行对账 —— 迟到 / 误投终态泄漏的桥接正是靠这次权威对账收口 + // (收口条件与代际语义见 lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts)。 + applyTask(idleSid, { taskId: 't3', status: 'completed', taskType: 'local_agent' }); + emitActivity({ sessionId: idleSid, active: false }); + await vi.advanceTimersByTimeAsync(3000); + expect(listTasks).toHaveBeenCalledTimes(1); + expect(listTasks).toHaveBeenCalledWith(idleSid); } finally { makerChatStore.purgeSession(remoteSid); makerChatStore.purgeSession(idleSid);