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__/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); diff --git a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts index 7657426c19..4b29857da6 100644 --- a/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts +++ b/apps/desktop/src/renderer/__tests__/makerQueueState.test.ts @@ -116,6 +116,8 @@ const state = (overrides: Partial = {}): SessionChatState => ( pendingTaskWake: 0, 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 new file mode 100644 index 0000000000..aaa143d940 --- /dev/null +++ b/apps/desktop/src/renderer/lib/__tests__/pendingTaskWakeBridgeReconcile.test.ts @@ -0,0 +1,270 @@ +/** + * 唤醒桥接(pendingTaskWake)泄漏对账回归 —— sidebar spinner 永久转圈修复。 + * + * 场景:主轮 Done 之后才到达的 wake 型任务终态(fork 会话收到父会话任务的终态、 + * 重连重放等)会置位桥接;设计上桥接等「wake turn 启动(isTurnStart 消费)」或 + * 「wake turn 失败的 Done」收尾,但这类迟到/误投终态不会有任何后续事件跟进, + * 两条清除路径都永远不来 —— hasBackgroundAgentWork 永真,running 快照永久含 + * 该会话(spinner 永转),且 pendingTaskWake 不在 reconcileStaleRunningTasks + * 的对账覆盖内(迟到终态本身就是 completed,不是 running 残留)。 + * + * 修复:活动熄灭延迟对账路径拿到 main 权威后收口桥接,六条件全部满足才清 + * (计数与代际一致 / 置位代次未变 / 主 turn 不在跑 / 距最近置位超过最小年龄 / + * 权威表无 wake 型任务 / main 权威 continuation 计数明确为 0),见 + * seedBackgroundTaskSnapshots 的 reconcileWakeBridge。本测试直接驱动真实 store + * (__applyStatusUpdateForTest / __applyStreamEventForTest),断言 getRunningSnapshot + * 的可观察行为;用 fake timers 控制最小年龄时钟。 + */ + +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, +): 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' }); +} + +/** 模拟延迟对账:请求前捕获代际 → 快照落地(main 权威 continuation 计数可指定)。 */ +function reconcileWithSnapshot( + sessionId: string, + tasks: Array<{ taskId: string; taskType?: string }>, + pendingContinuations: number | null = 0, +): void { + const captured = makerChatStore.capturePendingWakeBridge(sessionId); + makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { + reconcileWakeBridge: captured, + authorityPendingContinuations: pendingContinuations, + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(T0); +}); + +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +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); + + // 超过最小年龄后延迟对账落地:权威表空、continuation 计数 0、代际静止、 + // 主 turn 不在跑 → 桥接收口,spinner 熄灭。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + 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); + pushLateWakeTerminal(S, 't-late-age'); + expect(isRunningInSnapshot(S)).toBe(true); + + // 3s 级延迟对账先到:年龄不足,桥接保留,快照继续 running。 + vi.setSystemTime(T0 + UNDER_MIN_AGE_MS); + reconcileWithSnapshot(S, [], 0); + expect(isRunningInSnapshot(S)).toBe(true); + + // 复查轮到点(已超龄)才收口。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + reconcileWithSnapshot(S, [], 0); + expect(isRunningInSnapshot(S)).toBe(false); + }); + + it('请求在飞窗口内新置位的桥接不被旧快照清除(计数代际)', () => { + const S = 'wake-bridge-inflight-generation'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-late-old'); + // 模拟请求发起前捕获代际(此刻计数 = 1)。 + const capturedBeforeFlight = makerChatStore.capturePendingWakeBridge(S); + expect(capturedBeforeFlight.count).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, + authorityPendingContinuations: 0, + }); + expect(isRunningInSnapshot(S)).toBe(true); + + // 下一轮对账:代际静止且距第二次置位已超龄 → 收口。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS * 2); + 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); + }); + + it('多 wake 依次消费:对账不一次清空尚待消费的合法计数', () => { + const S = 'wake-bridge-multi-wake'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-multi-1'); + pushLateWakeTerminal(S, 't-multi-2'); + expect(makerChatStore.capturePendingWakeBridge(S).count).toBe(2); + + // 捕获代际(2)后 wake turn A 启动:isTurnStart 消费一个计数(剩 1)。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + const capturedBeforeFlight = makerChatStore.capturePendingWakeBridge(S); + pushStatus(S, { isRunning: true, status: 'Working' }); + expect(makerChatStore.capturePendingWakeBridge(S).count).toBe(1); + + // 旧快照落地:计数(1)≠ 代际(2),且主 turn 在跑 → 不清。 + makerChatStore.seedBackgroundTaskSnapshots(S, [], { + reconcileWakeBridge: capturedBeforeFlight, + authorityPendingContinuations: 0, + }); + expect(makerChatStore.capturePendingWakeBridge(S).count).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); + }); + + it('权威表仍有 wake 任务在跑时不收口(不误杀真实空窗)', () => { + const S = 'wake-bridge-leak-alive-task'; + runMainTurn(S); + pushLateWakeTerminal(S, 't-late-2'); + expect(isRunningInSnapshot(S)).toBe(true); + + // main 权威表仍报告一个 wake 型任务在跑:即便其余条件齐,桥接保留。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + reconcileWithSnapshot(S, [{ taskId: 't-alive', taskType: 'local_agent' }], 0); + 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,桥接保持原状。 + vi.setSystemTime(T0 + OVER_MIN_AGE_MS); + 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..0340baafa2 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -2586,6 +2586,21 @@ export interface SessionChatState { * 仅运行时使用,不持久化。 */ pendingTaskWakeStarted: boolean; + /** + * 唤醒桥接最近一次置位时刻(ms)。仅 wakesAfterTerminal 置位时刷新,清零路径 + * 顺带置 null(残留旧值无害:所有读取方都以 pendingTaskWake > 0 为前置)。 + * 用途:桥接对账收口的最小年龄闸 —— 距最近置位不足 + * WAKE_BRIDGE_RECONCILE_MIN_AGE_MS 时不收口,防止把「合法但启动慢于对账 + * 延迟的 wake 空窗」误判为泄漏(bot review:旧快照不得清新桥接)。 + */ + pendingTaskWakeArmedAt: number | null; + /** + * 唤醒桥接置位代次:仅 wakesAfterTerminal 置位时自增,消费 / 清零不动。 + * 用途:桥接对账的代际标识 —— 收口要求「计数与代际捕获值一致 **且** 代次未变」。 + * 只比计数会被 ABA 碰撞骗过(快照响应在飞超长时,旧桥接被消费、新终态又把 + * 计数恢复到捕获值),代次单调递增使「期间有过新置位」必然可见(bot review P1)。 + */ + pendingTaskWakeGen: number; /** * 用户主动 Stop 标记:会话级(非组件级),确保同一 session 在多窗口打开时 * 任一窗口的 Stop 都能阻止其他窗口触发预测等后续行为。 @@ -2711,6 +2726,8 @@ function createInitialState(): SessionChatState { pendingTaskWake: 0, pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, + pendingTaskWakeGen: 0, turnStoppedByUser: false, lastAgentMeta: null, }; @@ -2782,6 +2799,8 @@ export const EMPTY_SESSION_STATE: SessionChatState = Object.freeze({ pendingTaskWake: 0, pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, + pendingTaskWakeGen: 0, turnStoppedByUser: false, lastAgentMeta: null, }) as SessionChatState; @@ -4927,6 +4946,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, @@ -4938,6 +4969,10 @@ export function handleStreamEvent( nextWake > 0 ? (state.pendingTaskWakeDuringTurn + (wakesAfterTerminal && mainTurnDoneNotCrossed ? 1 : 0)) : 0, + // 最小年龄闸的时钟起点:每次真实置位都刷新(见字段注释)。 + pendingTaskWakeArmedAt: wakesAfterTerminal ? Date.now() : state.pendingTaskWakeArmedAt, + // 置位代次:对账收口的 ABA 防护(见字段注释)。 + pendingTaskWakeGen: state.pendingTaskWakeGen + (wakesAfterTerminal ? 1 : 0), }; } @@ -5785,6 +5820,8 @@ function forceFinalizeOnSessionClosed(state: SessionChatState): SessionChatState pendingTaskWake: 0, pendingTaskWakeDuringTurn: 0, pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, + pendingTaskWakeGen: state.pendingTaskWakeGen, turnStoppedByUser: false, agentStatus: { ...finalized.agentStatus, @@ -6013,6 +6050,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 { @@ -6037,17 +6082,31 @@ 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 时同样放行。 + // 桥接代际在请求发起前捕获(计数 + 置位代次):收口只在「响应落地时计数与 + // 代际完全一致且代次未变」时发生 —— 请求在飞窗口内新置位的桥接绝不被旧快照 + // 清除,代次单调另挡 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 }); + makerChatStore.seedBackgroundTaskSnapshots(sessionId, tasks, { + staleRunningCandidates, + reconcileWakeBridge: capturedWakeBridge, + // main 权威的「任务已终态、wake turn 尚未启动或仍在跑」continuation + // 计数。旧 main(字段缺失)按「信号不可用」处理 —— 不收口,只重试, + // 绝不退化回「拿空任务表当无后续」的旧判据。 + authorityPendingContinuations: + typeof pendingContinuations === 'number' ? pendingContinuations : null, + }); }) .catch(() => { // 静默:与其余快照拉取失败同口径(失败不对账,下次翻转沿 / 挂载重试)。 @@ -7883,7 +7942,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.capturePendingWakeBridge(p.sessionId).count === 0 + ) + return; scheduleBackgroundTaskReconcile(p.sessionId); }, 'session-background-activity-reconcile', @@ -14374,6 +14438,19 @@ export const makerChatStore = { } return out; }, + /** + * 捕获该会话当前的唤醒桥接代际(计数 + 置位代次)。 + * 用途:活动熄灭延迟对账 —— ①粗筛放行:桥接泄漏时 taskUpdates 里往往已无 + * running 条目(迟到终态本身就是 completed),只看 running 候选会把桥接对账 + * 挡在门外;②桥接代际:必须在发起 listSessionBackgroundTasks **之前**捕获, + * 收口只在「响应落地时计数与代际一致且置位代次未变」时发生 —— 请求在飞窗口 + * 内新置位 / 被 isTurnStart 消费过的桥接都不收;代次单调递增另挡「消费后又 + * 置位恰好回到原计数」的 ABA 碰撞(见 seedBackgroundTaskSnapshots)。 + */ + 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 里完全没见过」的任务 —— 事件流是唯一实时源,快照可能落后于刚到 @@ -14386,14 +14463,41 @@ export const makerChatStore = { * running 且不在快照中的条目标 stopped(终态事件丢失的自愈,时序论证见 * reconcileStaleRunningTasks)。仅本机会话可传:device-link 镜像会话的快照 * 有降级空表窗口,不可当权威(与远程豁免 running 折算同口径)。 + * + * opts.reconcileWakeBridge(可选):唤醒桥接对账收口 —— 仅活动熄灭延迟对账 + * 路径可传(同 staleRunningCandidates 的本机会话口径),值为**发起快照请求前** + * 捕获的桥接代际(计数 + 置位代次)。快照落地后,同时满足以下全部条件才把 + * 桥接清零,否则(计数仍 > 0 时)重新调度下一轮对账、留待复查: + * 1. 当前计数与代际捕获值完全一致 —— 在飞窗口内无新增置位、也无 isTurnStart + * 消费,旧快照绝不清新桥接、也不吞多 wake 场景里尚待依次消费的合法计数; + * 2. 置位代次未变 —— 单调代次挡「在飞窗口内先消费后置位、计数恰好回到捕获值」 + * 的 ABA 碰撞(bot review P1); + * 3. 主 turn 不在跑; + * 4. 距最近一次置位已超过 WAKE_BRIDGE_RECONCILE_MIN_AGE_MS —— 给慢机 / + * 高负载下合法 wake turn 留足启动余量; + * 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 本来就会消费 + * 桥接,收口只影响空窗显示,不改变任何 turn 语义。 */ seedBackgroundTaskSnapshots: ( sessionId: string, tasks: Array<{ taskId: string; taskType?: string; toolUseId?: string; title?: string }>, - opts?: { staleRunningCandidates?: ReadonlySet }, + opts?: { + staleRunningCandidates?: ReadonlySet; + reconcileWakeBridge?: { count: number; gen: number }; + authorityPendingContinuations?: number | null; + }, ): void => { const candidates = opts?.staleRunningCandidates; - if (!tasks.length && !(candidates && candidates.size > 0)) return; + if (!tasks.length && !(candidates && candidates.size > 0) && !opts?.reconcileWakeBridge?.count) + return; setState(sessionId, (s) => { let next = s; for (const t of tasks) { @@ -14419,6 +14523,41 @@ export const makerChatStore = { if (candidates && candidates.size > 0) { next = reconcileStaleRunningTasks(next, tasks, candidates); } + // 唤醒桥接对账收口(六条件语义见方法头注释的 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 && + authorityConfirmsNoContinuation + ) { + next = { + ...next, + pendingTaskWake: 0, + pendingTaskWakeDuringTurn: 0, + pendingTaskWakeStarted: false, + pendingTaskWakeArmedAt: null, + }; + } else { + // 条件未齐(在飞变动 / 年龄不足 / turn 在跑 / 权威表仍有 wake 任务): + // 本轮不动,重新调度下一轮对账复查。真泄漏最终会静止、超龄、表空, + // 收口收敛;合法 wake 则会经 isTurnStart 消费计数,后续对账自然空转。 + scheduleBackgroundTaskReconcile(sessionId); + } + } return 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