Files
inkling/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md
altair823 050e7f08f1 docs(spec): #1 ollama — runOnce({manual}) + ollama_recheck_manual via hook
§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>
2026-05-02 01:18:28 +09:00

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>;

loadInitialuseEffect 에서 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 typecheck 0 에러
  • npm test — 327 + 12+ = 339+
  • npm run test:e2e 1/1
  • main 머지

머지 후:

  • roadmap ### #1 Ollama 회복 (4번)✓ 완료
  • memory/project_v024_backlog.md review 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 안 함). 단위 테스트 가능.