Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,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'],
Expand All @@ -49,6 +64,9 @@ describe('session runtime control wiring', () => {
'sessionRuntimeFallbackWire()',
'writeSessionRuntimeFallbackEnabled(',
'resetSessionRuntimeFallbackSettings()',
'interruptedTurnAutoResumeSettingsHandlers.get()',
'interruptedTurnAutoResumeSettingsHandlers.set(',
'interruptedTurnAutoResumeSettingsHandlers.reset()',
'writeCompactionPct(',
'resetCompactionPct()',
'writePiCompactionPct(',
Expand All @@ -65,6 +83,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'],
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ import {
import {
anySessionInTurn,
applyCodexSpawnConfigChangeWithRestart,
cancelWaitingInterruptedTurnAutoResumes,
clearDeferredCodexRestartForOwnerBoundary,
collectAgentInputQueueScanTexts,
createAutomationUserTurnGitBaselineHooks,
Expand Down Expand Up @@ -691,6 +692,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';
Expand Down Expand Up @@ -1386,6 +1393,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({
Expand Down Expand Up @@ -4367,6 +4382,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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
*
* 默认开启:上游把「已经干到一半」的 turn 打断时(SSE 流被切断,SDK 报
* `server_error` 且自己不重试,见 maker-ipc/interruptedTurnAutoResume.ts 文件头),
* 守卫自动补发一次续跑指令接续任务。本开关是守卫自身出问题时的逃生门(隐藏配置,
* 不进 Settings UI;规则 20 的「隐藏配置」层级),用户可通过 agent 改本地配置文件
* 关闭。
* 守卫自动补发一次续跑指令接续任务。本开关既是 Settings UI 中的用户偏好,也是
* 守卫自身出问题时可手改文件的逃生门。
*
* 与 silent-stop 的开关**刻意分成两个文件**:两套自愈的判据、额度和故障模式都不同,
* 逃生门必须能分别关——一套误动作时不该被迫把另一套也停掉。
Expand All @@ -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');

Expand All @@ -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');
Expand All @@ -41,23 +45,77 @@ function normalize(raw: unknown): InterruptedTurnAutoResumeSettings {
};
}

const store = createOverrideSettingsFile<InterruptedTurnAutoResumeSettings>({
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 {
Comment thread
PeaceMaker-best marked this conversation as resolved.
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<void> {
await store.writePatchAtomic({ enabled });
}

export async function resetInterruptedTurnAutoResumeSettings(): Promise<
InterruptedTurnAutoResumeSettings
> {
return store.resetAtomic();
}

export const __testing = {
normalize,
invalidate: store.invalidateIfChanged,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading