feat: 지서버가 활성 모델일 때 절전 안 하도록 keep-alive 신호 추가

08-09에 이미 조사했던 "지서버가 30분 설정보다 훨씬 빨리 잔다" 문제는
사용자가 전기료 절약 관점에서 선호하는 동작으로 확정하고 조사를 접었던
건이다([[project_wol_gate]]). 그런데 오늘(08-10) 외부에서 실제로 사용
중일 때 이 빠른 절전이 매번 ~23초 기상 지연으로 이어져 불편해졌고,
사용자가 "지서버가 안자게 신호를 계속 보내자"고 명시적으로 요청했다 —
원인 재조사가 아니라 능동적 keep-alive를 원하는 것으로 확인.

원인 후보였던 "Windows 유휴 타이머가 마지막 사용자 입력 기준이라
API 트래픽으로는 리셋 안 됨"이 맞다면, 주기적으로 실제 HTTP 요청을
보내는 것 자체가 대응책이 된다.

`llm.provider === 'ollama_local'`이고(getProvider()의 단일 캐시 구조상
어차피 이 조건에서만 실제 채팅 트래픽도 지서버로 간다) WS 클라이언트가
최소 1개 연결돼 있을 때만(앱이 실제로 열려 있을 때만) 2분마다 직결
엔드포인트에 가벼운 /api/tags 요청을 보낸다. 게이트가 아니라 직결로만
보내므로, 이미 잠들어 있으면 그냥 조용히 실패한다 — 깨우는 신호가
아니라 "깨어있는 동안 재우지 않는" 신호라, 나머지 시간대의 절전
이점(사용자가 이미 선택한)을 훼손하지 않는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-10 14:19:40 +09:00
co-authored by Claude Opus 5
parent aa74d4b710
commit 5d88d34b33
2 changed files with 67 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
/**
* ollama-local-keepalive.ts
*
* Periodically pokes 지서버's Ollama endpoint while it's the active model AND someone
* has the app open, so it doesn't drop to sleep mid-session.
*
* Why this exists (2026-08-10): 지서버 (Windows) sleeps much faster than its own 30-minute
* idle setting because Windows' idle timer counts user input (keyboard/mouse), not inbound
* network requests — a headless box that's only ever touched via WOL+Ollama API traffic
* looks "idle" to Windows no matter how many chat turns are running. This was investigated
* on 08-09 and the user decided the fast-sleep behavior was fine (power savings, wol-gate
* absorbs the ~23s wake delay) — see [[project_wol_gate]]. It stopped being fine once the
* user was relying on it while away from home (08-10): the wake delay repeating mid-session
* was disruptive enough that they asked for an explicit keep-alive signal ("지서버가 안자게
* 신호를 계속 보내자") rather than reopening the whole "why does it sleep early" investigation.
*
* This is a signal to keep it FROM sleeping, not a wake trigger — it hits the endpoint
* directly (never through wol-gate), so if 지서버 is already asleep this just fails silently
* and does nothing. Forcing a wake here would undo the power-saving behavior the user chose
* to keep for the other 95% of the time this box isn't actively in use.
*
* Gated on two conditions so it can't run unattended and quietly cancel out the power
* savings the user explicitly kept:
* 1. llm.provider === 'ollama_local' — someone has actually selected 지서버 as the active
* model right now (getProvider()'s single-instance cache means this is also the only
* condition under which any real chat traffic would even reach it).
* 2. At least one WebSocket client is connected — the app is actually open, not just left
* selected on ollama_local from a previous session while the gateway keeps running.
*/
import { getConfig } from '../config/config';
const KEEPALIVE_INTERVAL_MS = 2 * 60 * 1000;
const PING_TIMEOUT_MS = 4_000;
let lastWasAwake: boolean | null = null;
async function pingIfActive(hasConnectedClient: () => boolean): Promise<void> {
if (!hasConnectedClient()) return;
let endpoint = '';
try {
const raw = getConfig().getConfig() as any;
if (raw?.llm?.provider !== 'ollama_local') return;
endpoint = String(raw?.llm?.providers?.ollama_local?.endpoint || '').trim();
} catch { return; }
if (!endpoint) return;
try {
const res = await fetch(`${endpoint}/api/tags`, { signal: AbortSignal.timeout(PING_TIMEOUT_MS) });
const text = await res.text();
const awake = !text.trimStart().startsWith('<'); // wol-gate's "waking up" page is HTML
if (awake !== lastWasAwake) {
console.log(`[ollama-local-keepalive] ${awake ? '지서버 응답 확인 (깨어있음)' : '지서버 무응답 — 절전 상태로 추정'}`);
}
lastWasAwake = awake;
} catch {
if (lastWasAwake !== false) console.log('[ollama-local-keepalive] ping failed — 지서버 무응답');
lastWasAwake = false;
}
}
export function startOllamaLocalKeepAlive(hasConnectedClient: () => boolean): NodeJS.Timeout {
return setInterval(() => { pingIfActive(hasConnectedClient); }, KEEPALIVE_INTERVAL_MS);
}
+3
View File
@@ -107,6 +107,7 @@ import {
import { getPaths } from '../config/paths';
import { getVault, SecretValue } from '../security/vault';
import { getOllamaClient } from '../agents/ollama-client';
import { startOllamaLocalKeepAlive } from './ollama-local-keepalive';
import { spawnAgent } from '../agents/spawner';
import { getSession, addMessage, getHistory, getHistoryForApiCall, getWorkspace, setWorkspace, clearHistory, setContextTokens, cleanupSessions, cleanupEmptySessions, migrateGlobalSessionsToUser, listAllSessions, resolveNumCtx, activeOllamaEndpoint } from './session';
import { hookBus } from './hooks/hooks';
@@ -4707,6 +4708,8 @@ const wsPingInterval = setInterval(() => {
});
}, 30000);
const ollamaLocalKeepAliveInterval = startOllamaLocalKeepAlive(() => !!wss && wss.clients.size > 0);
server.on('error', (err: any) => {
if (err?.code === 'EADDRINUSE') {
console.error(`[Gateway] Port ${HOST}:${PORT} is already in use.`);