docs(spec): v0.2.3 #1 Ollama 회복 polling design

mini-brainstorm 결과 3개 결정:
- Q1=A polling 주기 60s
- Q2=A 절대 중단 안 함
- Q3=A constant (no backoff)

HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events
(ollama_unreachable / ollama_recovered / ollama_recheck_manual)
+ main → renderer push (ollama:status) + manual recheck (banner + tray).

T1-T7 작업 순서 + 단위 ≥ 12개.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-02 01:16:14 +09:00
parent da7455b25f
commit f36b9ecb5b

View File

@@ -0,0 +1,322 @@
# 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 };
export class HealthChecker {
constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {}
async runOnce(): Promise<HealthResult>; // 기존 메서드 유지 (manual recheck + start 진입점)
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:
```ts
ipcMain.handle('inbox:ollamaRecheck', async () => {
await deps.health.runOnce(); // status 변경 시 onUpdate 자동 fire
void deps.telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {});
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). |