diff --git a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts index d329ab9d28a..f32f6e0d3b0 100644 --- a/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts +++ b/apps/desktop/src/main/__tests__/sessionRuntimeControlWiring.test.ts @@ -111,7 +111,22 @@ describe('session runtime control wiring', () => { 'MAKER_IPC_INVOKE.SESSION_RUNTIME_FALLBACK_SET', 'MAKER_IPC_INVOKE.SESSION_RUNTIME_FALLBACK_RESET', ], - ['MAKER_IPC_INVOKE.SESSION_RUNTIME_FALLBACK_RESET', 'MAKER_IPC_INVOKE.COMPACTION_GET_PCT'], + [ + 'MAKER_IPC_INVOKE.SESSION_RUNTIME_FALLBACK_RESET', + 'MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_GET', + ], + [ + 'MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_GET', + 'MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_SET', + ], + [ + 'MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_SET', + 'MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_RESET', + ], + [ + 'MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_RESET', + 'MAKER_IPC_INVOKE.COMPACTION_GET_PCT', + ], ['MAKER_IPC_INVOKE.COMPACTION_GET_PCT', 'MAKER_IPC_INVOKE.COMPACTION_GET_STATE'], ['MAKER_IPC_INVOKE.COMPACTION_GET_STATE', 'MAKER_IPC_INVOKE.COMPACTION_RESET_PCT'], ['MAKER_IPC_INVOKE.COMPACTION_RESET_PCT', 'MAKER_IPC_INVOKE.COMPACTION_SET_PCT'], @@ -129,6 +144,9 @@ describe('session runtime control wiring', () => { 'sessionRuntimeFallbackWire()', 'writeSessionRuntimeFallbackEnabled(', 'resetSessionRuntimeFallbackSettings()', + 'interruptedTurnAutoResumeSettingsHandlers.get()', + 'interruptedTurnAutoResumeSettingsHandlers.set(', + 'interruptedTurnAutoResumeSettingsHandlers.reset()', 'writeCompactionPct(', 'resetCompactionPct()', 'writePiCompactionPct(', @@ -145,6 +163,25 @@ describe('session runtime control wiring', () => { } }); + it('uses the user setting as the master gate and cancels only waiting backoffs', () => { + expect(registerSource).toContain( + 'isEnabled: () => readInterruptedTurnAutoResumeSettings().enabled,', + ); + expect(registerSource).not.toContain( + 'readInterruptedTurnAutoResumeSettings().enabled || readSessionRuntimeFallbackSettings().enabled', + ); + expect(registerSource).toContain('autoResumeBookkeeping.cancelWaitingSchedules()'); + + const scheduledCallback = handlerBody( + registerSource, + 'autoResumeBookkeeping.schedule(', + '// 回传展示信息:', + ); + expect( + scheduledCallback.indexOf('readInterruptedTurnAutoResumeSettings().enabled'), + ).toBeLessThan(scheduledCallback.indexOf('maybeApplySessionRuntimeFallback(')); + }); + it('binds compaction writes to the initiating owner stamp', () => { for (const [channel, nextChannel] of [ ['MAKER_IPC_INVOKE.COMPACTION_RESET_PCT', 'MAKER_IPC_INVOKE.COMPACTION_SET_PCT'], diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index e51ed7575d5..42d7bb8d0b5 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -622,6 +622,7 @@ import { import { anySessionInTurn, applyCodexSpawnConfigChangeWithRestart, + cancelWaitingInterruptedTurnAutoResumes, clearDeferredCodexRestartForOwnerBoundary, collectAgentInputQueueScanTexts, createAutomationUserTurnGitBaselineHooks, @@ -699,6 +700,12 @@ import { resetSessionRuntimeFallbackSettings, writeSessionRuntimeFallbackEnabled, } from './maker-host/session-runtime-fallback-store.js'; +import { + readInterruptedTurnAutoResumeSettingsState, + resetInterruptedTurnAutoResumeSettings, + writeInterruptedTurnAutoResumeEnabled, +} from './maker-host/interrupted-turn-auto-resume-store.js'; +import { createInterruptedTurnAutoResumeSettingsHandlers } from './maker-ipc/interruptedTurnAutoResumeSettingsHandlers.js'; import { clearAllSessionProviders } from './maker-host/session-provider-store.js'; import { clearAllSessionRuntimeAxes } from './maker-host/session-effort-store.js'; import { clearAllSessionRuntimeControlStates } from './maker-ipc/sessionRuntimeControl.js'; @@ -1403,6 +1410,14 @@ const updatePresentationLog = createLogger('update-presentation'); const voicePowerBroadcastLog = createLogger('voice-input-power'); const sessionDragPreviewLog = createLogger('session-drag-preview'); const piSubagentLog = createLogger('pi-subagent'); +const interruptedTurnAutoResumeSettingsHandlers = + createInterruptedTurnAutoResumeSettingsHandlers({ + readState: readInterruptedTurnAutoResumeSettingsState, + writeEnabled: writeInterruptedTurnAutoResumeEnabled, + reset: resetInterruptedTurnAutoResumeSettings, + cancelWaiting: cancelWaitingInterruptedTurnAutoResumes, + log: createLogger('interrupted-turn-auto-resume-settings'), + }); let rendererBootGuard: RendererBootGuard | null = null; const lifecycleDbClientManager = createLifecycleDbClientManager({ @@ -4475,6 +4490,22 @@ const registerIpcHandlers = () => { return { ...sessionRuntimeFallbackWire(), effective: 'immediate' as const }; }); + ipcMain.handle(MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_GET, async (event) => { + assertTrustedAppRendererEvent(event); + return interruptedTurnAutoResumeSettingsHandlers.get(); + }); + ipcMain.handle( + MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_SET, + async (event, enabled: unknown) => { + assertTrustedAppRendererEvent(event); + return interruptedTurnAutoResumeSettingsHandlers.set(enabled); + }, + ); + ipcMain.handle(MAKER_IPC_INVOKE.INTERRUPTED_TURN_AUTO_RESUME_RESET, async (event) => { + assertTrustedAppRendererEvent(event); + return interruptedTurnAutoResumeSettingsHandlers.reset(); + }); + ipcMain.handle(MAKER_IPC_INVOKE.COMPACTION_GET_PCT, async (event) => { assertTrustedAppRendererEvent(event); return readCompactionPct(); diff --git a/apps/desktop/src/main/maker-host/__tests__/interrupted-turn-auto-resume-store.test.ts b/apps/desktop/src/main/maker-host/__tests__/interrupted-turn-auto-resume-store.test.ts new file mode 100644 index 00000000000..2e4bbce2e4d --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/interrupted-turn-auto-resume-store.test.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-interrupted-resume-settings-')); + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => tempRoot) }, +})); + +vi.mock('../logger-adapter.js', () => ({ + desktopMakerLogger: { + child: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }), + }, +})); + +import { + __testing, + readInterruptedTurnAutoResumeSettings, + readInterruptedTurnAutoResumeSettingsState, + resetInterruptedTurnAutoResumeSettings, + writeInterruptedTurnAutoResumeEnabled, +} from '../interrupted-turn-auto-resume-store'; + +const settingsFile = path.join(tempRoot, 'interrupted-turn-auto-resume-settings.json'); + +describe('interrupted turn auto-resume settings store', () => { + beforeEach(async () => { + fs.mkdirSync(tempRoot, { recursive: true }); + await resetInterruptedTurnAutoResumeSettings(); + __testing.invalidate(); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('defaults to enabled without materializing an override file', () => { + expect(readInterruptedTurnAutoResumeSettings()).toEqual({ enabled: true }); + expect(readInterruptedTurnAutoResumeSettingsState()).toMatchObject({ + value: { enabled: true }, + defaults: { enabled: true }, + isCustomized: false, + }); + expect(fs.existsSync(settingsFile)).toBe(false); + }); + + it('persists only an explicit opt-out and removes it when restored to the default', async () => { + await writeInterruptedTurnAutoResumeEnabled(false); + expect(JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))).toEqual({ enabled: false }); + expect(readInterruptedTurnAutoResumeSettingsState().isCustomized).toBe(true); + + await writeInterruptedTurnAutoResumeEnabled(true); + expect(fs.existsSync(settingsFile)).toBe(false); + expect(readInterruptedTurnAutoResumeSettingsState().isCustomized).toBe(false); + }); + + it('observes an external kill-switch edit without restarting the app', async () => { + await writeInterruptedTurnAutoResumeEnabled(false); + const originalTimes = fs.statSync(settingsFile); + fs.writeFileSync(settingsFile, JSON.stringify({ enabled: true }), 'utf-8'); + fs.utimesSync(settingsFile, originalTimes.atime, originalTimes.mtime); + + expect(readInterruptedTurnAutoResumeSettings()).toEqual({ enabled: true }); + }); + + it('preserves malformed configuration, falls back to enabled, and exposes reset', async () => { + fs.writeFileSync(settingsFile, '{"enabled":', 'utf-8'); + __testing.invalidate(); + + expect(readInterruptedTurnAutoResumeSettings()).toEqual({ enabled: true }); + expect(readInterruptedTurnAutoResumeSettingsState().isCustomized).toBe(true); + expect(fs.readFileSync(settingsFile, 'utf-8')).toBe('{"enabled":'); + + await expect(writeInterruptedTurnAutoResumeEnabled(false)).rejects.toThrow( + 'settings file is unreadable', + ); + await resetInterruptedTurnAutoResumeSettings(); + expect(fs.existsSync(settingsFile)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/maker-host/interrupted-turn-auto-resume-store.ts b/apps/desktop/src/main/maker-host/interrupted-turn-auto-resume-store.ts index 6b9c1812cc1..64b0d5fdfc7 100644 --- a/apps/desktop/src/main/maker-host/interrupted-turn-auto-resume-store.ts +++ b/apps/desktop/src/main/maker-host/interrupted-turn-auto-resume-store.ts @@ -6,9 +6,8 @@ * * 默认开启:上游把「已经干到一半」的 turn 打断时(SSE 流被切断,SDK 报 * `server_error` 且自己不重试,见 maker-ipc/interruptedTurnAutoResume.ts 文件头), - * 守卫自动补发一次续跑指令接续任务。本开关是守卫自身出问题时的逃生门(隐藏配置, - * 不进 Settings UI;规则 20 的「隐藏配置」层级),用户可通过 agent 改本地配置文件 - * 关闭。 + * 守卫自动补发一次续跑指令接续任务。本开关既是 Settings UI 中的用户偏好,也是 + * 守卫自身出问题时可手改文件的逃生门。 * * 与 silent-stop 的开关**刻意分成两个文件**:两套自愈的判据、额度和故障模式都不同, * 逃生门必须能分别关——一套误动作时不该被迫把另一套也停掉。 @@ -18,6 +17,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { desktopMakerLogger } from './logger-adapter.js'; +import { + createOverrideSettingsFile, + type OverrideSettingsState, +} from './override-settings-file.js'; const log = desktopMakerLogger.child('interrupted-turn-auto-resume-store'); @@ -28,6 +31,7 @@ export interface InterruptedTurnAutoResumeSettings { const DEFAULTS: InterruptedTurnAutoResumeSettings = { enabled: true, }; +const MAX_SETTINGS_BYTES = 4_096; function settingsFilePath(): string { return path.join(app.getPath('userData'), 'interrupted-turn-auto-resume-settings.json'); @@ -41,23 +45,77 @@ function normalize(raw: unknown): InterruptedTurnAutoResumeSettings { }; } +const store = createOverrideSettingsFile({ + filePath: settingsFilePath, + defaults: DEFAULTS, + normalize, + log, + label: 'interrupted turn auto resume', + maxBytes: MAX_SETTINGS_BYTES, + preserveUnreadableFile: true, +}); + /** - * 每次从磁盘读取,不做缓存。kill switch 是守卫出问题时的逃生门:用户手动编辑文件 - * 后必须立即生效,不能等 app 重启。guard 每次 onInterruptedTurn 调 isEnabled() - * 触发本读(频率是「每次 turn 被打断」,与 silent-stop 同量级,不是热路径)。 + * kill switch 是守卫出问题时的逃生门:每次 guard 判定都直接读取这个有 4 KiB 上限的 + * 小文件。不能只看 mtime;时间戳粒度内的原地写也必须立即阻止下一次自动续跑。 */ export function readInterruptedTurnAutoResumeSettings(): InterruptedTurnAutoResumeSettings { + return readInterruptedTurnAutoResumeSettingsState().value; +} + +export function readInterruptedTurnAutoResumeSettingsState(): OverrideSettingsState< + InterruptedTurnAutoResumeSettings +> { + const file = settingsFilePath(); try { - const file = settingsFilePath(); - if (fs.existsSync(file)) { - const raw = JSON.parse(fs.readFileSync(file, 'utf-8')); - return normalize(raw); + if (!fs.existsSync(file)) { + return { + value: { ...DEFAULTS }, + defaults: { ...DEFAULTS }, + isCustomized: false, + customizedKeys: [], + }; } - } catch (err) { - // 读取/解析失败 → 回退默认(开启)。记一条 debug 便于排查用户手改坏了文件的情况。 + const stat = fs.statSync(file); + if (stat.size > MAX_SETTINGS_BYTES) { + throw new Error(`file exceeds ${MAX_SETTINGS_BYTES} byte limit`); + } + const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('settings file root must be an object'); + } + const customizedKeys = Object.keys(parsed); + return { + value: normalize({ ...DEFAULTS, ...parsed }), + defaults: { ...DEFAULTS }, + isCustomized: customizedKeys.length > 0, + customizedKeys, + }; + } catch (error) { + // Preserve the user's file but expose reset in Settings so the UI is not permanently stuck. log.debug('interrupted turn auto resume settings unreadable — using defaults', { - error: err instanceof Error ? err.message : String(err), + error: error instanceof Error ? error.message : String(error), }); + return { + value: { ...DEFAULTS }, + defaults: { ...DEFAULTS }, + isCustomized: true, + customizedKeys: [], + }; } - return { ...DEFAULTS }; } + +export async function writeInterruptedTurnAutoResumeEnabled(enabled: boolean): Promise { + await store.writePatchAtomic({ enabled }); +} + +export async function resetInterruptedTurnAutoResumeSettings(): Promise< + InterruptedTurnAutoResumeSettings +> { + return store.resetAtomic(); +} + +export const __testing = { + normalize, + invalidate: store.invalidateIfChanged, +}; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/autoResumeBookkeeping.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/autoResumeBookkeeping.test.ts index a9a2b6cc0d6..403843e29dc 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/autoResumeBookkeeping.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/autoResumeBookkeeping.test.ts @@ -273,6 +273,28 @@ describe('退避排期:必可撤销、必只认自己那次', () => { expect(h.guardRollbacks).toEqual(['s1']); }); + it('关闭设置只撤销尚在退避期的 attempt', async () => { + const h = createHarness(); + const alreadyRunning = vi.fn(async () => undefined); + const stillWaiting = vi.fn(); + + h.book.beginAttempt('running', 1); + h.book.schedule('running', 1, 1_000, alreadyRunning); + h.book.beginAttempt('waiting', 2); + h.book.schedule('waiting', 2, 5_000, stillWaiting); + + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + + expect(h.book.cancelWaitingSchedules()).toEqual([{ sessionId: 'waiting', attemptToken: 2 }]); + expect(h.guardRollbacks).toEqual(['waiting']); + + vi.advanceTimersByTime(10_000); + await Promise.resolve(); + expect(alreadyRunning).toHaveBeenCalledTimes(1); + expect(stillWaiting).not.toHaveBeenCalled(); + }); + it('新排期顶替旧排期:旧回调不执行、**不**回滚守卫额度(那份属于新那次)', () => { const h = createHarness(); const first = vi.fn(); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/interruptedTurnAutoResumeSettingsHandlers.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/interruptedTurnAutoResumeSettingsHandlers.test.ts new file mode 100644 index 00000000000..1bdf9396313 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/interruptedTurnAutoResumeSettingsHandlers.test.ts @@ -0,0 +1,106 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createInterruptedTurnAutoResumeSettingsHandlers } from '../interruptedTurnAutoResumeSettingsHandlers'; + +function createHarness() { + const readState = vi.fn(() => ({ + value: { enabled: true }, + defaults: { enabled: true }, + isCustomized: false, + })); + const writeEnabled = vi.fn(async () => undefined); + const reset = vi.fn(async () => undefined); + const cancelWaiting = vi.fn(); + const log = { error: vi.fn() }; + const handlers = createInterruptedTurnAutoResumeSettingsHandlers({ + readState, + writeEnabled, + reset, + cancelWaiting, + log, + }); + return { handlers, readState, writeEnabled, reset, cancelWaiting, log }; +} + +describe('interrupted turn auto-resume settings IPC handlers', () => { + it('validates input before persistence and cancels waiting work only after disabling succeeds', async () => { + const { handlers, readState, writeEnabled, cancelWaiting, log } = createHarness(); + + await expect(handlers.set('false')).rejects.toMatchObject({ code: 'INVALID_PARAMS' }); + expect(writeEnabled).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + + readState.mockReturnValue({ + value: { enabled: false }, + defaults: { enabled: true }, + isCustomized: true, + }); + await expect(handlers.set(false)).resolves.toEqual({ + enabled: false, + defaultEnabled: true, + isCustomized: true, + effective: 'immediate', + }); + expect(writeEnabled).toHaveBeenCalledWith(false); + expect(cancelWaiting).toHaveBeenCalledOnce(); + }); + + it('logs write details in Main but returns a stable IPC error without the userData path', async () => { + const { handlers, writeEnabled, cancelWaiting, log } = createHarness(); + const privatePath = path.join(os.tmpdir(), 'private-user-data', 'settings.json'); + const originalMessage = `EACCES: permission denied, rename '${privatePath}'`; + writeEnabled.mockRejectedValueOnce(new Error(originalMessage)); + + const error = await handlers.set(false).catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: 'INTERNAL', + message: '[INTERNAL] interrupted turn auto-resume settings write failed', + }); + expect((error as Error).message).not.toContain(privatePath); + expect(log.error).toHaveBeenCalledWith( + 'interrupted turn auto-resume settings operation failed', + { action: 'write', error: originalMessage }, + ); + expect(cancelWaiting).not.toHaveBeenCalled(); + }); + + it('applies the same error boundary to reset failures', async () => { + const { handlers, reset, log } = createHarness(); + const privatePath = path.join(os.tmpdir(), 'private-user-data', 'settings.json'); + const originalMessage = `EPERM: operation not permitted, unlink '${privatePath}'`; + reset.mockRejectedValueOnce(new Error(originalMessage)); + + const error = await handlers.reset().catch((caught: unknown) => caught); + + expect(error).toMatchObject({ + code: 'INTERNAL', + message: '[INTERNAL] interrupted turn auto-resume settings reset failed', + }); + expect((error as Error).message).not.toContain(privatePath); + expect(log.error).toHaveBeenCalledWith( + 'interrupted turn auto-resume settings operation failed', + { action: 'reset', error: originalMessage }, + ); + }); + + it('sanitizes an unexpected read failure too', () => { + const { handlers, readState, log } = createHarness(); + const privatePath = path.join(os.tmpdir(), 'private-user-data', 'settings.json'); + const originalMessage = `EIO: failed to read '${privatePath}'`; + readState.mockImplementationOnce(() => { + throw new Error(originalMessage); + }); + + expect(() => handlers.get()).toThrowError( + '[INTERNAL] interrupted turn auto-resume settings read failed', + ); + expect(log.error).toHaveBeenCalledWith( + 'interrupted turn auto-resume settings operation failed', + { action: 'read', error: originalMessage }, + ); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/autoResumeBookkeeping.ts b/apps/desktop/src/main/maker-ipc/autoResumeBookkeeping.ts index e4dbcce4ff6..aaf5ac17527 100644 --- a/apps/desktop/src/main/maker-ipc/autoResumeBookkeeping.ts +++ b/apps/desktop/src/main/maker-ipc/autoResumeBookkeeping.ts @@ -760,6 +760,21 @@ export class AutoResumeBookkeeping { }); } + /** + * Cancel only attempts that are still waiting for their backoff timer. + * A fired callback has `timer === null` and is already doing async work, so a settings change + * must not revoke or terminate it mid-flight. + */ + cancelWaitingSchedules(): Array<{ sessionId: string; attemptToken: number }> { + const cancelled: Array<{ sessionId: string; attemptToken: number }> = []; + for (const [sessionId, scheduled] of [...this.schedules]) { + if (scheduled.timer === null || scheduled.attemptToken === null) continue; + const attemptToken = scheduled.attemptToken; + if (this.cancelSchedule(sessionId)) cancelled.push({ sessionId, attemptToken }); + } + return cancelled; + } + /** 终止当前 attempt 并回滚守卫的 pendingResume;没有 attempt 时是 no-op。 */ cancelSchedule(sessionId: string): boolean { const scheduled = this.schedules.get(sessionId); diff --git a/apps/desktop/src/main/maker-ipc/channels.ts b/apps/desktop/src/main/maker-ipc/channels.ts index 7df09fed6b4..8a2abb38a48 100644 --- a/apps/desktop/src/main/maker-ipc/channels.ts +++ b/apps/desktop/src/main/maker-ipc/channels.ts @@ -372,6 +372,9 @@ export const MAKER_INVOKE = { SESSION_RUNTIME_FALLBACK_GET: 'maker:session-runtime-fallback:get', SESSION_RUNTIME_FALLBACK_SET: 'maker:session-runtime-fallback:set', SESSION_RUNTIME_FALLBACK_RESET: 'maker:session-runtime-fallback:reset', + INTERRUPTED_TURN_AUTO_RESUME_GET: 'maker:interrupted-turn-auto-resume:get', + INTERRUPTED_TURN_AUTO_RESUME_SET: 'maker:interrupted-turn-auto-resume:set', + INTERRUPTED_TURN_AUTO_RESUME_RESET: 'maker:interrupted-turn-auto-resume:reset', /** * Claude Code 的自动上下文压缩触发阈值 —— /compaction-settings.json。 * 经 runtimeConfig.autoCompactThresholdPct getter 热读,当前会话下一轮结束即按新值判断。 diff --git a/apps/desktop/src/main/maker-ipc/interruptedTurnAutoResumeSettingsHandlers.ts b/apps/desktop/src/main/maker-ipc/interruptedTurnAutoResumeSettingsHandlers.ts new file mode 100644 index 00000000000..5322a25a1b7 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/interruptedTurnAutoResumeSettingsHandlers.ts @@ -0,0 +1,79 @@ +import type { Logger } from '../logger.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; + +interface InterruptedTurnAutoResumeSettingsState { + value: { enabled: boolean }; + defaults: { enabled: boolean }; + isCustomized: boolean; +} + +interface InterruptedTurnAutoResumeSettingsHandlersDeps { + readState: () => InterruptedTurnAutoResumeSettingsState; + writeEnabled: (enabled: boolean) => void | Promise; + reset: () => void | Promise; + cancelWaiting: () => void; + log: Pick; +} + +function settingsWire(state: InterruptedTurnAutoResumeSettingsState) { + return { + enabled: state.value.enabled, + isCustomized: state.isCustomized, + defaultEnabled: state.defaults.enabled, + }; +} + +/** + * Keep filesystem details on the Main side of the IPC boundary. In particular, + * Node errors from rename/unlink commonly contain the absolute userData path. + */ +function throwSettingsIpcError( + deps: InterruptedTurnAutoResumeSettingsHandlersDeps, + action: 'read' | 'write' | 'reset', + error: unknown, +): never { + deps.log.error('interrupted turn auto-resume settings operation failed', { + action, + error: error instanceof Error ? error.message : String(error), + }); + throwIpcError('INTERNAL', `interrupted turn auto-resume settings ${action} failed`); +} + +/** Business handlers kept separate from ipcMain so failure paths are unit-testable. */ +export function createInterruptedTurnAutoResumeSettingsHandlers( + deps: InterruptedTurnAutoResumeSettingsHandlersDeps, +) { + const readWire = () => settingsWire(deps.readState()); + + return { + get() { + try { + return readWire(); + } catch (error) { + throwSettingsIpcError(deps, 'read', error); + } + }, + + async set(enabled: unknown) { + if (typeof enabled !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'interrupted turn auto-resume enabled required (boolean)'); + } + try { + await deps.writeEnabled(enabled); + if (!enabled) deps.cancelWaiting(); + return { ...readWire(), effective: 'immediate' as const }; + } catch (error) { + throwSettingsIpcError(deps, 'write', error); + } + }, + + async reset() { + try { + await deps.reset(); + return { ...readWire(), effective: 'immediate' as const }; + } catch (error) { + throwSettingsIpcError(deps, 'reset', error); + } + }, + }; +} diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 66d1a5b6e60..5485a2a2ca7 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -1032,8 +1032,7 @@ const silentStopAutoResumeGuard = new SilentStopAutoResumeGuard({ // 中断自动续跑守卫(上游把已有产出的 turn 打断 → 自动替用户点一次「继续」)。 // 与 silent-stop 那份**额度独立记账**,理由见 interruptedTurnAutoResume.ts 文件头。 const interruptedTurnAutoResumeGuard = new InterruptedTurnAutoResumeGuard({ - isEnabled: () => - readInterruptedTurnAutoResumeSettings().enabled || readSessionRuntimeFallbackSettings().enabled, + isEnabled: () => readInterruptedTurnAutoResumeSettings().enabled, log: { debug: (message, meta) => log.debug(message, meta), warn: (message, meta) => log.warn(message, meta), @@ -1284,6 +1283,20 @@ function settleUndispatchedInterruptedAutoResume( return true; } +/** + * Settings-side master switch: cancel only backoff timers that have not fired yet. Attempts + * already classifying or dispatching remain untouched and settle through their existing owner. + */ +export function cancelWaitingInterruptedTurnAutoResumes(): number { + const cancelled = autoResumeBookkeeping.cancelWaitingSchedules(); + for (const { sessionId, attemptToken } of cancelled) { + autoResumeBookkeeping.finalizeSuppressedError(sessionId, attemptToken, { + surfaceBanner: true, + }); + } + return cancelled.length; +} + /** * 非 renderer 发送路径(scheduler runner / hook runner)调用:给 silent-stop * 守卫充值自动续跑额度。renderer 发送走 createMakerSendTransaction 内部已充值, @@ -11692,6 +11705,20 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) return (async () => { let fallbackRebuildSession: Session | null = null; try { + if (!attempt.isCurrent()) return; + // The file is intentionally hot-readable for the manual kill-switch path. UI + // changes cancel waiting timers eagerly; an external file edit is fenced here. + if (!readInterruptedTurnAutoResumeSettings().enabled) { + interruptedTurnAutoResumeGuard.noteResumeSendFailed( + sessionId, + decision.attemptToken, + ); + autoResumeBookkeeping.finalizeSuppressedError(sessionId, decision.attemptToken, { + surfaceBanner: true, + }); + log.info('interrupted-turn auto-resume cancelled by setting', { sessionId }); + return; + } fallbackRebuildSession = await maybeApplySessionRuntimeFallback( sessionId, decision.episodeAttempt, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f0d91bbf1ef..2a72e465ec1 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6458,6 +6458,25 @@ contextBridge.exposeInMainWorld('electronAPI', { defaultEnabled: boolean; effective: 'immediate'; }> => ipcRenderer.invoke('maker:session-runtime-fallback:reset'), + interruptedTurnAutoResumeGet: (): Promise<{ + enabled: boolean; + isCustomized: boolean; + defaultEnabled: boolean; + }> => ipcRenderer.invoke('maker:interrupted-turn-auto-resume:get'), + interruptedTurnAutoResumeSet: ( + enabled: boolean, + ): Promise<{ + enabled: boolean; + isCustomized: boolean; + defaultEnabled: boolean; + effective: 'immediate'; + }> => ipcRenderer.invoke('maker:interrupted-turn-auto-resume:set', enabled), + interruptedTurnAutoResumeReset: (): Promise<{ + enabled: boolean; + isCustomized: boolean; + defaultEnabled: boolean; + effective: 'immediate'; + }> => ipcRenderer.invoke('maker:interrupted-turn-auto-resume:reset'), // Claude Code 自动上下文压缩阈值。仅对新建会话生效。 compactionGetPct: (): Promise => ipcRenderer.invoke('maker:compaction:get-pct'), diff --git a/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx b/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx index 2b77f965e9e..778974655b2 100644 --- a/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx +++ b/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx @@ -1,12 +1,13 @@ /** * WindowBehaviorSection — 「应用行为」section:本机相关的应用级开关。 * - * 三项设置: + * 四项设置: * 1. 「保持电脑唤醒」(keepAwake):main 用 powerSaveBlocker 防系统休眠、放行锁屏, * 让后台 agent / 定时任务持续运行。跨平台生效(mac/win/linux),故常驻显示。 * 2. 「关闭主窗口时」:Windows 选择退出或收起到托盘,Linux 选择退出或最小化。 * 3. 「后台窗口首次左键点击仅激活不透传」(swallowActivationClick,PR #446):仅 * macOS + Windows 有实际效果,Linux 上两条底层路径均 no-op,故该行在 Linux 隐藏。 + * 4. 「连接中断后自动继续任务」:跨平台、Host 级主开关,只取消尚在退避中的排期。 * * 卡片样式沿用 NotificationSection 的规格(rounded 12 / Card bg / 1px Board / * padding 20)以保持视觉一致。 @@ -19,6 +20,8 @@ import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; import { useSwallowActivationClickSettings } from '@/hooks/useSwallowActivationClickSettings'; import { useKeepAwakeSetting } from '@/hooks/useKeepAwakeSetting'; +import { useInterruptedTurnAutoResumeSettings } from '@/hooks/useInterruptedTurnAutoResumeSettings'; +import { DefaultOverrideControls } from './DefaultOverrideControls'; import { isLinuxCloseBehavior, isWindowsCloseBehavior, @@ -36,6 +39,8 @@ function BehaviorCard({ checked, onCheckedChange, ariaLabel, + disabled, + controls, }: { label: string; hint: string; @@ -43,6 +48,8 @@ function BehaviorCard({ checked: boolean; onCheckedChange: (next: boolean) => void; ariaLabel: string; + disabled?: boolean; + controls?: ReactNode; }) { return (
- +
+ {controls} + +
); } @@ -73,6 +88,7 @@ function BehaviorCard({ export function WindowBehaviorSection() { const { enabled, setEnabled } = useSwallowActivationClickSettings(); const { keepAwake, setKeepAwake } = useKeepAwakeSetting(); + const interruptedAutoResume = useInterruptedTurnAutoResumeSettings(); const { t } = useTranslation(); // macOS 上 acceptFirstMouse 是 Cocoa 级参数、只在 BrowserWindow 构造时读一次, // 用户切完开关下次启动才生效——单独渲染一行"需要重启应用"避免和主 hint 混在 @@ -134,6 +150,22 @@ export function WindowBehaviorSection() { ariaLabel={t('settings.devices.keepAwake')} /> + void interruptedAutoResume.setEnabled(next)} + ariaLabel={t('settings.windowBehavior.interruptedAutoResume.aria')} + disabled={interruptedAutoResume.loading || interruptedAutoResume.pending} + controls={ + void interruptedAutoResume.reset()} + /> + } + /> + {(isWindows || isLinux) && (
({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/hooks/useKeepAwakeSetting', () => ({ + useKeepAwakeSetting: () => ({ keepAwake: false, setKeepAwake: vi.fn() }), +})); + +vi.mock('@/hooks/useSwallowActivationClickSettings', () => ({ + useSwallowActivationClickSettings: () => ({ enabled: false, setEnabled: vi.fn() }), +})); + +vi.mock('../DefaultOverrideControls', () => ({ + DefaultOverrideControls: ({ + isCustomized, + onReset, + }: { + isCustomized: boolean; + onReset: () => void; + }) => + isCustomized ? ( +