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
85 changes: 85 additions & 0 deletions scripts/sleep-guard-real-host-check.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Real-host closed-loop check for the Sleep Guard.
*
* Boots the real Electron runtime and drives the COMPILED `sleepGuard` module
* with the REAL `powerSaveBlocker`, asserting engagement in both directions
* (idle -> work -> idle -> work). On macOS it additionally verifies the OS
* power assertion (`PreventUserIdleSystemSleep`) appears while engaged.
*
* Run (from repo root, after `npm run compile:electron`):
* node_modules/.bin/electron scripts/sleep-guard-real-host-check.cjs
*
* Exit code 0 = all checks passed, 1 = any check failed.
*/
const { app, powerSaveBlocker } = require('electron');
const { execFileSync } = require('child_process');

let evaluateSleepGuardWork;
let SleepGuard;
try {
({ evaluateSleepGuardWork, SleepGuard } = require('../dist-electron/main/sleepGuard.js'));
} catch {
({ evaluateSleepGuardWork, SleepGuard } = require('../dist-electron/sleepGuard.js'));
}

const idle = { coworkSessionIds: [], scheduledTaskIds: [], dreamingMetabotIds: [] };

function osAssertionActive() {
if (process.platform !== 'darwin') return null; // not applicable
try {
const out = execFileSync('pmset', ['-g', 'assertions'], { encoding: 'utf8' });
return /PreventUserIdleSystemSleep/.test(out);
} catch {
return null; // pmset unavailable
}
}

app.whenReady().then(() => {
const results = [];
const check = (name, ok, detail) => {
results.push(ok);
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}: ${detail}`);
};

const guard = new SleepGuard({ powerSaveBlocker });

// 1. Idle -> guard must NOT engage.
let state = guard.apply(evaluateSleepGuardWork(idle));
check('idle keeps blocker released', state.active === false && state.engaged === false, JSON.stringify(state));

// 2. Work -> guard engages, real powerSaveBlocker isStarted is true.
state = guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['real-host-check-1'] }));
check('work engages blocker', state.active === true && state.engaged === true, JSON.stringify(state));

// 3. OS-level assertion appears while engaged (macOS only).
const osActive = osAssertionActive();
if (osActive === null) {
check('OS assertion (n/a platform)', true, 'not darwin / pmset unavailable');
} else {
check('OS PreventUserIdleSystemSleep active', osActive, `pmset assertion ${osActive ? 'present' : 'MISSING'}`);
}

// 4. Repeated apply while working stays engaged (idempotent).
state = guard.apply(evaluateSleepGuardWork({ ...idle, coworkSessionIds: ['real-host-check-1'], scheduledTaskIds: ['t1'], dreamingMetabotIds: [1] }));
check('multi-source apply stays engaged', state.engaged === true && state.sources.includes('dream'), JSON.stringify(state));

// 5. Idle again -> blocker released in the real runtime.
state = guard.apply(evaluateSleepGuardWork(idle));
check('idle releases blocker', state.engaged === false, JSON.stringify(state));

// 6. Re-engage (second direction) -> blocker starts again.
state = guard.apply(evaluateSleepGuardWork({ ...idle, scheduledTaskIds: ['t2'] }));
check('re-engage after release', state.engaged === true && state.sources.includes('scheduledTask'), JSON.stringify(state));

guard.dispose();
const afterDispose = guard.isEngaged();
check('dispose releases blocker', afterDispose === false, `engaged=${afterDispose}`);

const passed = results.filter(Boolean).length;
const ok = results.every(Boolean);
console.log(`\nRESULT: ${ok ? 'PASS' : 'FAIL'} (${passed}/${results.length})`);
app.exit(ok ? 0 : 1);
}).catch((error) => {
console.error('Real-host check crashed:', error);
app.exit(1);
});
1 change: 0 additions & 1 deletion src/main/libs/opcatInscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* use @opcat-labs/scrypt-ts-opcat instead of standard bitcoinjs-lib.
*/

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyOpcat = any;

const OPCAT_WALLET_API = 'https://wallet-api.opcatlabs.io';
Expand Down
9 changes: 9 additions & 0 deletions src/main/libs/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ export class Scheduler {
console.log('[Scheduler] Stopped');
}

/**
* Ids of scheduled tasks currently executing (abort controllers live for the
* whole run). Used by the sleep guard to keep the device awake while a
* scheduled task is running.
*/
getActiveTaskIds(): string[] {
return Array.from(this.activeTasks.keys());
}

reschedule(): void {
if (!this.running) return;
if (this.timer) {
Expand Down
94 changes: 92 additions & 2 deletions src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, session, nativeTheme, dialog, shell, nativeImage, systemPreferences, Menu } from 'electron';
import { app, BrowserWindow, ipcMain, session, nativeTheme, dialog, shell, nativeImage, systemPreferences, Menu, powerSaveBlocker } from 'electron';
import type { FileFilter, MessageBoxOptions, OpenDialogOptions, Session, WebContents } from 'electron';
import path from 'path';
import fs from 'fs';
Expand Down Expand Up @@ -200,6 +200,7 @@ import {
} from './services/cognitiveChatCompletion';
import { normalizeMetabotLlmId } from './services/llmFallback';
import { startDreamService, stopDreamService, getDreamService } from './services/dreamService';
import { SleepGuard, evaluateSleepGuardWork, type SleepGuardWorkInput, type SleepGuardState } from './sleepGuard';
import { DreamStore } from './dreamStore';
import { MessageFeedbackStore } from './messageFeedbackStore';
import { computeDreamRetryDelayMs } from './libs/dreamPrompt';
Expand Down Expand Up @@ -2557,6 +2558,8 @@ let mcpStore: McpStore | null = null;
let projectStore: ProjectStore | null = null;
let coworkRunner: CoworkRunner | null = null;
let coworkTurnSubmissionController: CoworkTurnSubmissionController | null = null;
let sleepGuard: SleepGuard | null = null;
let sleepGuardRefreshTimer: ReturnType<typeof setInterval> | null = null;
let skillManager: SkillManager | null = null;
let metaAppManager: MetaAppManager | null = null;
let botBrowserMetaAppCacheService: BotBrowserMetaAppCacheService | null = null;
Expand Down Expand Up @@ -5115,6 +5118,9 @@ const getCoworkRunner = () => {
});

coworkRunner.on('complete', (sessionId: string, claudeSessionId: string | null) => {
// A session finished: recompute the sleep guard so the blocker is
// released as soon as no other work source is active.
recomputeSleepGuard();
if (!shouldForwardCoworkStreamEvent(getCoworkStore(), sessionId)) {
return;
}
Expand All @@ -5127,6 +5133,7 @@ const getCoworkRunner = () => {
});

coworkRunner.on('error', (sessionId: string, error: string) => {
recomputeSleepGuard();
if (!shouldForwardCoworkStreamEvent(getCoworkStore(), sessionId)) {
return;
}
Expand Down Expand Up @@ -6610,8 +6617,77 @@ const getScheduler = () => {
return scheduler;
};

// --- Sleep Guard: keep the host device awake while IDBots is working ---------
// Engages Electron's `powerSaveBlocker` only while at least one work source
// (active cowork session / running scheduled task / nightly dream) is active,
// and releases it as soon as everything is idle. Pure policy lives in
// src/main/sleepGuard.ts; this block wires it to the live work sources.
const getSleepGuard = (): SleepGuard => {
if (!sleepGuard) {
sleepGuard = new SleepGuard({
powerSaveBlocker,
onChanged: (state) => broadcastSleepGuardStatus(state),
});
broadcastSleepGuardStatus(sleepGuard.getState());
}
return sleepGuard;
};

const collectSleepGuardWork = (): SleepGuardWorkInput => {
let coworkSessionIds: string[] = [];
try {
coworkSessionIds = getCoworkRunner().getActiveSessionIds();
} catch (error) {
console.warn('[SleepGuard] collect cowork sessions failed:', error);
}
let scheduledTaskIds: string[] = [];
try {
scheduledTaskIds = getScheduler().getActiveTaskIds();
} catch (error) {
console.warn('[SleepGuard] collect scheduled tasks failed:', error);
}
let dreamingMetabotIds: number[] = [];
try {
dreamingMetabotIds = getDreamService()?.getDreamingBotIds() ?? [];
} catch (error) {
console.warn('[SleepGuard] collect dreaming bots failed:', error);
}
return { coworkSessionIds, scheduledTaskIds, dreamingMetabotIds };
};

const recomputeSleepGuard = (): void => {
try {
getSleepGuard().apply(evaluateSleepGuardWork(collectSleepGuardWork()));
} catch (error) {
console.warn('[SleepGuard] recompute failed:', error);
}
};

const broadcastSleepGuardStatus = (state: SleepGuardState): void => {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('powerGuard:changed', state);
}
};

const startSleepGuardRefresh = (): void => {
if (sleepGuardRefreshTimer) return;
// Periodic safety refresh: self-heals even if an event-driven update was
// missed (e.g. a session ended without its completion event firing).
sleepGuardRefreshTimer = setInterval(recomputeSleepGuard, 20_000);
recomputeSleepGuard();
};

const stopSleepGuardRefresh = (): void => {
if (sleepGuardRefreshTimer) {
clearInterval(sleepGuardRefreshTimer);
sleepGuardRefreshTimer = null;
}
sleepGuard?.dispose();
sleepGuard = null;
};

// 获取正确的预加载脚本路径
const PRELOAD_PATH = app.isPackaged
const PRELOAD_PATH = app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../dist-electron/preload.js');

Expand Down Expand Up @@ -6759,6 +6835,11 @@ if (!gotTheLock) {
return getStore().get(key);
});

// Sleep guard status for the harness frontend (badge).
ipcMain.handle('powerGuard:status', () => {
return getSleepGuard().getState();
});

// 广播 store 键变更到所有窗口,供多窗口之间实时同步(配置机器人信息等场景)
const broadcastStoreChanged = (key: string): void => {
BrowserWindow.getAllWindows().forEach((win) => {
Expand Down Expand Up @@ -7488,6 +7569,9 @@ if (!gotTheLock) {
sessionType?: 'standard' | 'browser';
permissionMode?: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions';
}) => {
// A session is starting: engage the sleep guard immediately so the device
// does not sleep while the harness is working.
recomputeSleepGuard();
return withSqliteRecovery('cowork:session:start', async () => {
try {
const coworkStoreInstance = getCoworkStore();
Expand Down Expand Up @@ -7659,6 +7743,7 @@ if (!gotTheLock) {
try {
const runner = getCoworkRunner();
runner.stopSession(sessionId);
recomputeSleepGuard();
return { success: true };
} catch (error) {
return {
Expand Down Expand Up @@ -13310,6 +13395,8 @@ ipcMain.handle('gigSquare:sendOrder', async (_event, params: {
if (!isAutoLaunched() || !isPrimaryWindow) {
win.show();
}
// Start the sleep guard: keep the host device awake while IDBots is working
startSleepGuardRefresh();
});

return win;
Expand Down Expand Up @@ -13393,6 +13480,9 @@ ipcMain.handle('gigSquare:sendOrder', async (_event, params: {
let isCleanupInProgress = false;

const runAppCleanup = async (): Promise<void> => {
// Release the sleep guard first so the device may sleep normally during
// shutdown even if a later cleanup step stalls.
stopSleepGuardRefresh();
await stopMetaAppServer().catch((error) => {
console.error('[metaapps] Failed to stop local server during cleanup:', error);
});
Expand Down
11 changes: 11 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ contextBridge.exposeInMainWorld('electron', {
};
},
},
powerGuard: {
getStatus: () => ipcRenderer.invoke('powerGuard:status'),
onChanged: (callback: (state: { active: boolean; sources: string[]; engaged: boolean }) => void) => {
const handler = (
_event: Electron.IpcRendererEvent,
state: { active: boolean; sources: string[]; engaged: boolean },
) => callback(state);
ipcRenderer.on('powerGuard:changed', handler);
return () => ipcRenderer.removeListener('powerGuard:changed', handler);
},
},
skills: {
list: () => ipcRenderer.invoke('skills:list'),
setEnabled: (options: { id: string; enabled: boolean }) => ipcRenderer.invoke('skills:setEnabled', options),
Expand Down
Loading