# 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 시그너처

```ts
// 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 변경

기존:
```ts
const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h }));
```

신규:
```ts
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` 의 자매):

```ts
// 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):

```ts
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:

```ts
recheckOllama: () => Promise<void>;
```

`loadInitial` 의 `useEffect` 에서 `inboxApi.onOllamaStatus(cb)` 구독 (note:updated 와 동일 패턴):

```ts
// App.tsx useEffect
const unsubOllama = inboxApi.onOllamaStatus((status) => {
  set({ ollamaStatus: status });
});
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
```

`recheckOllama` action:
```ts
async recheckOllama() {
  const status = await inboxApi.ollamaRecheck();
  set({ ollamaStatus: status });
}
```

### 4.2 InboxApi + preload 확장

```ts
// 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` 시 "재확인" 버튼 추가 (기존 메시지 + 진단 줄 옆 또는 아래):

```tsx
<button onClick={() => void recheckOllama()}>재확인</button>
```

기존 banner 스타일 유지 (warn variant).

### 4.4 Tray 메뉴

기존 `createTray` 의 컨텍스트 메뉴에 항목 추가:

```ts
{
  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

```ts
// 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 안 함). 단위 테스트 가능. |
