§2.1 / §3.2 / §11 보강 — IPC handler 가 직접 telemetry.emit 안 하고
HealthChecker.runOnce({ manual: true }) 호출 → onTelemetry hook 으로
ollama_recheck_manual 발화. 단위 테스트 가능 (HealthChecker 레이어).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
v0.2.3 #1 Ollama 회복 polling 설계
작성일: 2026-05-01 저자: 김태현 (dlsrks0734@gmail.com) 문서 성격: v0.2.3 cut 7항목 중 4번째 항목 (#1 Ollama 회복) 의 mini-brainstorm 결정 + design. roadmap §3 #1 의 In/Out 위에서 §8 미결정 3항목 (polling 주기 / 실패 N회 중단 / backoff) 결정 + 추가 동작 사양 명시.
선행 문서:
docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md§3 #1, §8- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15)
1. 결정 요약
| Q | 결정 | 근거 |
|---|---|---|
| Q1 polling 주기 | A 60s | 회복 latency ≤ 1분 충분. /api/tags 호출 가벼워 부하 미미. dogfood 1인 사용 패턴 (분당 ~1 capture) 과 같은 톤. |
| Q2 실패 N회 후 중단 | A 절대 중단 안 함 | 부하 무시 가능. 중단 시 사용자 마찰 (재확인 버튼 또는 재시작) 만 남김. |
| Q3 exponential backoff | A constant 60s | Q1 결론 + Q2 결론 연결 — backoff 효과 없고 회복 latency 만 늘어남. |
2. HealthChecker 확장
2.1 시그너처
// src/main/services/HealthChecker.ts
export interface HealthCheckerOptions {
intervalMs?: number; // default 60_000
onUpdate?: (status: HealthResult) => void; // delta only — status 가 변할 때만 fire
onTelemetry?: (event: HealthTelemetryEvent) => void; // emit hook (testability)
now?: () => number; // testability
}
export type HealthTelemetryEvent =
| { kind: 'ollama_unreachable'; reason: string }
| { kind: 'ollama_recovered'; downtimeMs: number }
| { kind: 'ollama_recheck_manual' };
export class HealthChecker {
constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {}
/**
* @param opts.manual=true 일 때 결과와 무관하게 onTelemetry({kind:'ollama_recheck_manual'}) 1회 fire.
* IPC `inbox:ollamaRecheck` 가 호출 시 사용 — telemetry 가드를 service 레이어로 끌어 단위 테스트 가능.
*/
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult>;
start(): void; // setInterval 시작 (idempotent — 2회 호출 시 1번만)
stop(): void; // clearInterval (idempotent)
lastStatus(): HealthResult;
}
2.2 상태 전이 로직
runOnce() 안에서 result = await provider.healthCheck() 후:
| 전이 | 동작 |
|---|---|
| ok=true → ok=true (변화 없음) | no-op |
| ok=true → ok=false | unreachableSince = now(). onUpdate(result) 호출. onTelemetry({kind:'ollama_unreachable', reason}) |
| ok=false → ok=true | downtimeMs = now() - unreachableSince. onUpdate(result). onTelemetry({kind:'ollama_recovered', downtimeMs}). unreachableSince = null |
| ok=false → ok=false (reason 동일) | no-op |
| ok=false → ok=false (reason 다름) | onUpdate(result) (UI 갱신). telemetry emit 안 함 (ratio 노이즈 회피) |
2.3 start/stop
start():runOnce()즉시 1회 +setInterval(runOnce, intervalMs)등록. timer 이미 있으면 no-op.stop():clearInterval(timer). timer null 로 set.- App quit hook (
app.on('before-quit')) 에서health.stop()— leak 방지.
3. main wiring + IPC
3.1 main/index.ts 변경
기존:
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h }));
신규:
const health = new HealthChecker(provider, {
onUpdate: (status) => pushOllamaStatus(getInboxWindow, status),
onTelemetry: (ev) => {
if (ev.kind === 'ollama_unreachable') void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
else if (ev.kind === 'ollama_recovered') void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
}
});
health.start();
app.on('before-quit', () => health.stop());
3.2 IPC 채널
| 채널 | 방향 | 용도 |
|---|---|---|
inbox:ollamaStatus |
renderer → main | 기존 — health.lastStatus() 반환. startup / refreshMeta 시 fetch. |
inbox:ollamaRecheck |
renderer → main → renderer | 신규 — main 이 health.runOnce() 호출, 결과 status push, telemetry ollama_recheck_manual emit. |
ollama:status (push) |
main → renderer | 신규 — onUpdate fire 시 main 이 webContents.send. (note:updated 패턴 mirroring) |
inbox:ollamaStatus 는 변경 없음 (기존 IPC 호환).
pushOllamaStatus(getInboxWindow, status) helper 추가 (pushNoteUpdated 의 자매):
// src/main/ipc/inboxApi.ts
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
const w = getWin();
if (!w || w.isDestroyed()) return;
w.webContents.send('ollama:status', status);
}
inbox:ollamaRecheck handler — telemetry emit 은 HealthChecker 의 onTelemetry hook 으로 위임 (testability):
ipcMain.handle('inbox:ollamaRecheck', async () => {
await deps.health.runOnce({ manual: true }); // status 변경 시 onUpdate + ollama_recheck_manual onTelemetry fire
return deps.health.lastStatus();
});
4. store + UI
4.1 store.ts 확장
InboxState 에 신규 action + push subscriber:
recheckOllama: () => Promise<void>;
loadInitial 의 useEffect 에서 inboxApi.onOllamaStatus(cb) 구독 (note:updated 와 동일 패턴):
// App.tsx useEffect
const unsubOllama = inboxApi.onOllamaStatus((status) => {
set({ ollamaStatus: status });
});
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
recheckOllama action:
async recheckOllama() {
const status = await inboxApi.ollamaRecheck();
set({ ollamaStatus: status });
}
4.2 InboxApi + preload 확장
// shared/types.ts InboxApi
ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>;
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
// preload/index.ts
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
onOllamaStatus: (cb) => {
const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status);
ipcRenderer.on('ollama:status', listener);
return () => ipcRenderer.off('ollama:status', listener);
}
4.3 OllamaBanner 변경
status.ok === false 시 "재확인" 버튼 추가 (기존 메시지 + 진단 줄 옆 또는 아래):
<button onClick={() => void recheckOllama()}>재확인</button>
기존 banner 스타일 유지 (warn variant).
4.4 Tray 메뉴
기존 createTray 의 컨텍스트 메뉴에 항목 추가:
{
label: 'Ollama 재확인',
enabled: !health.lastStatus().ok, // dynamic — 정상이면 disabled
click: () => void deps.recheckOllama()
}
createTray 가 7 positional callbacks 받는 현 구조에 1 callback 추가 — v0.2.4 backlog #4 (TrayCallbacks object refactor) 와 정합. 본 cut 에서는 8번째 callback 추가 + backlog #4 의 trigger 만 강화.
5. Telemetry
5.1 신규 3 events
| event | payload | 발화 |
|---|---|---|
ollama_unreachable |
{ reason: string } (max 500) |
ok=true → ok=false 전이 (HealthChecker.onTelemetry) |
ollama_recovered |
{ downtimeMs: number } (≥0) |
ok=false → ok=true 전이 |
ollama_recheck_manual |
{} (empty) |
inbox:ollamaRecheck IPC handler |
5.2 zod schemas
// telemetryEvents.ts
const OllamaUnreachablePayload = z.object({
reason: z.string().min(1).max(500)
}).strict();
const OllamaRecoveredPayload = z.object({
downtimeMs: z.number().nonnegative()
}).strict();
const EmptyPayload = z.object({}).strict();
reason 의 source 는 LocalOllamaProvider.healthCheck() 가 반환하는 generic message — 'connection refused', 'not installed', 'timeout', 'http 500' 등 generic. 본문/PII 누출 0건. max 500 cap 으로 anomaly fence.
5.3 stats.md 집계
신규 행 (휴지통 회수율 다음):
- Ollama unreachable 빈도: {count}건
- 평균 downtimeMs (recovered): {avg}
- 수동 recheck 사용량: {count}건
DailyRow 에 3 새 카운터 추가.
6. 테스트
| 영역 | 단위 | 검증 |
|---|---|---|
| HealthChecker | start() idempotent |
2회 호출 → timer 1개. |
| HealthChecker | start() 즉시 1회 + 60s 마다 |
vi.useFakeTimers() advance, runOnce 호출 횟수. |
| HealthChecker | stop() cleanup |
clearInterval. timer null. |
| HealthChecker | ok=true → ok=false 전이 | onUpdate fire, onTelemetry ollama_unreachable {reason} 1회. |
| HealthChecker | ok=false → ok=true 전이 | onUpdate fire, onTelemetry ollama_recovered {downtimeMs} 1회. downtimeMs ≈ now-unreachableSince. |
| HealthChecker | reason 변경 (ok=false 유지) | onUpdate fire, onTelemetry 0건. |
| HealthChecker | ok=true → ok=true 변화 없음 | onUpdate 0건. |
| TelemetryEvents | zod 3 신규 parse | happy + extra field reject (privacy invariant 회귀). |
| TelemetryStats | 3 카운터 + downtime 평균 | aggregateStats 검증. |
| IPC handler | inbox:ollamaRecheck |
runOnce + telemetry.emit recheck_manual + status 반환. |
| Store | recheckOllama action |
inboxApi.ollamaRecheck → set ollamaStatus. |
| Store | onOllamaStatus subscriber | push 받으면 set ollamaStatus. |
총 ≥ 12 단위. e2e 영향 없음.
7. 작업 순서 (writing-plans 시 task 분할 가이드)
T1. HealthChecker.start/stop + delta 전이 로직 + 단위 7개 (TDD)
T2. Telemetry 3 events (zod + EmitInput + stats.md 집계 + 단위 4개)
T3. main/index.ts wiring (onUpdate + onTelemetry + start() + before-quit stop) + 테스트는 T5 의 IPC 통해
T4. IPC inbox:ollamaRecheck + pushOllamaStatus helper + ollama:status push + 단위 1개
T5. shared/types InboxApi + preload + renderer onOllamaStatus subscriber + recheckOllama action + 단위 2개
T6. OllamaBanner 재확인 버튼 + tray 메뉴 항목 (visual integration)
T7. closure (gates + roadmap mark + memory backlog)
8. roadmap In/Out 일치
8.1 roadmap §3 #1 In 매핑
| roadmap | design |
|---|---|
| 60s polling, runOnce setInterval 자동 발화 | §2 ✓ |
| 회복 시 onUpdate → 구독 (renderer OllamaBanner) 자동 갱신 | §3.2 (push) + §4.1 (subscriber) ✓ |
| 실패 N회 후 polling 중단 정책 | Q2=A 절대 중단 안 함 |
| 수동 재확인 버튼 — OllamaBanner + 트레이 | §4.3 + §4.4 ✓ |
IPC inbox:ollamaRecheck |
§3.2 ✓ |
Telemetry ollama_unreachable {reason}, ollama_recovered {downtimeMs}, ollama_recheck_manual {} |
§5 ✓ |
| 단위 테스트 | §6 ≥ 12 |
8.2 Out 유지
- 사용자 설정 가능 polling 주기 — Out (Q1=A 60s 고정).
- 회복 toast 알림 — Out (banner 자동 사라짐만).
- model 정상성 (tags 외) 체크 — Out (provider 의 healthCheck 만 사용).
9. 위험 / 완화
| 위험 | 완화 |
|---|---|
| polling 이 app quit 시 leak | app.on('before-quit') 에서 health.stop(). 단위 테스트로 stop() 동작 가드. |
| onUpdate 가 status 매번 fire 되어 IPC 폭주 | delta only — last 와 비교 후만 fire. 단위 테스트로 ok=ok no-op 가드. |
| reason 문자열에 본문/PII 누출 | LocalOllamaProvider 가 generic message 만 반환. zod max length 500 cap. privacy invariant 단위 테스트. |
| recheck 가 polling 과 동시 발화 race | runOnce() async, sequential. provider.healthCheck() 가 자체적으로 동시 호출 안전 (HTTP GET). |
| reason 변경만으로 telemetry 폭주 (예: timeout ↔ refused 반복) | reason 변경 시 onUpdate fire 하지만 telemetry emit 안 함 — ratio 노이즈 회피. |
10. 게이트 (PR 머지 조건, roadmap §3.1 일치)
npm run typecheck0 에러npm test— 327 + 12+ = 339+npm run test:e2e1/1- main 머지
머지 후:
- roadmap
### #1 Ollama 회복 (4번)→✓ 완료 memory/project_v024_backlog.mdreview deferred 항목 누적
11. 변경 이력
| 일자 | 변경 |
|---|---|
| 2026-05-01 | 초안 — Q1=A (60s), Q2=A (절대 중단 안 함), Q3=A (constant). HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events + main → renderer push (ollama:status) + manual recheck (banner + tray). |
| 2026-05-01 | §2.1 / §3.2 보강 — runOnce({ manual?: boolean }) 인자 추가, ollama_recheck_manual 도 onTelemetry hook 으로 통합 (IPC handler 가 직접 emit 안 함). 단위 테스트 가능. |