feat(ollama): #1 Ollama 회복 polling (v0.2.3 4/7) #16

Merged
altair823 merged 13 commits from feat/v023-ollama into main 2026-05-01 17:08:47 +00:00
Owner

Summary

v0.2.3 cut 7항목 중 4번째 — HealthChecker 가 60s setInterval polling 으로 ollama 상태 자동 갱신, 회복 시 banner 자동 collapse. 수동 재확인 (OllamaBanner + tray 메뉴). Telemetry 3 events.

선행: PR #13 (#7 telemetry), PR #14 (#4 trash), PR #15 (#5 expiry).

Decisions (mini-brainstorm)

Q 결정
Q1 polling 주기 A — 60s
Q2 실패 N회 후 중단 A — 절대 중단 안 함
Q3 backoff A — constant

Architecture

  • HealthChecker.start/stop setInterval + delta-only onUpdate + onTelemetry hook (testability).
  • runOnce({manual?:boolean}) — manual=true 시 ollama_recheck_manual 자동 fire BEFORE provider call.
  • 상태 전이: ok=true→false unreachable + onUpdate, ok=false→true recovered + downtimeMs, reason-only 변경은 onUpdate 만 (telemetry 노이즈 회피).
  • main wiring: health.start() startup, app.on('before-quit') health.stop() (idempotent).
  • IPC inbox:ollamaRecheck (handler 가 runOnce({manual:true}) 호출 + lastStatus 반환), ollama:status push (note:updated 패턴 mirroring).
  • store: recheckOllama action + onOllamaStatus subscriber (App.tsx useEffect).
  • UI: OllamaBanner 재확인 button + tray '"Ollama 재확인'" 메뉴 항목 (status 기반 enabled).

Telemetry

  • ollama_unreachable {reason} (max 500), ollama_recovered {downtimeMs ≥0}, ollama_recheck_manual {} — 모두 zod .strict() (privacy invariant).
  • stats.md: Ollama unreachable 빈도 + 평균 downtimeMs + 수동 recheck 사용량.

Tests

17 신규 단위 (spec §6 의 12개 충족 + 5 over):

  • T1 HealthChecker: 7 (start cadence + idempotent + stop + 3 전이 + manual)
  • T2 telemetry zod + stats: 8 (3 happy + 3 reject + 2 stats)
  • T5 store: 2 (recheckOllama 성공 + 실패 propagate)

Gates

  • typecheck 0 errors
  • 단위 344/344 (29 files)
  • e2e 1/1

Test plan

  • ollama 끄고 60s 대기 → OllamaBanner 등장 + tray 메뉴 'Ollama 재확인' 활성
  • ollama 켜고 60s 대기 → OllamaBanner 자동 사라짐 + tray 메뉴 비활성
  • OllamaBanner 의 '재확인' 버튼 클릭 → 즉시 status 갱신 + telemetry ollama_recheck_manual emit
  • tray 'Ollama 재확인' 클릭 → 동일
  • app quit 시 health.stop 호출 (logs 또는 process exit 검증)

Refs

  • spec: docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md
  • plan: docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md
  • roadmap: docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md §3 #1

🤖 Generated with Claude Code

## Summary v0.2.3 cut 7항목 중 4번째 — HealthChecker 가 60s setInterval polling 으로 ollama 상태 자동 갱신, 회복 시 banner 자동 collapse. 수동 재확인 (OllamaBanner + tray 메뉴). Telemetry 3 events. 선행: PR #13 (#7 telemetry), PR #14 (#4 trash), PR #15 (#5 expiry). ## Decisions (mini-brainstorm) | Q | 결정 | |---|------| | Q1 polling 주기 | A — 60s | | Q2 실패 N회 후 중단 | A — 절대 중단 안 함 | | Q3 backoff | A — constant | ## Architecture - HealthChecker.start/stop setInterval + delta-only onUpdate + onTelemetry hook (testability). - runOnce({manual?:boolean}) — manual=true 시 ollama_recheck_manual 자동 fire BEFORE provider call. - 상태 전이: ok=true→false unreachable + onUpdate, ok=false→true recovered + downtimeMs, reason-only 변경은 onUpdate 만 (telemetry 노이즈 회피). - main wiring: health.start() startup, app.on('before-quit') health.stop() (idempotent). - IPC inbox:ollamaRecheck (handler 가 runOnce({manual:true}) 호출 + lastStatus 반환), ollama:status push (note:updated 패턴 mirroring). - store: recheckOllama action + onOllamaStatus subscriber (App.tsx useEffect). - UI: OllamaBanner 재확인 button + tray '"Ollama 재확인'" 메뉴 항목 (status 기반 enabled). ## Telemetry - ollama_unreachable {reason} (max 500), ollama_recovered {downtimeMs ≥0}, ollama_recheck_manual {} — 모두 zod .strict() (privacy invariant). - stats.md: Ollama unreachable 빈도 + 평균 downtimeMs + 수동 recheck 사용량. ## Tests 17 신규 단위 (spec §6 의 12개 충족 + 5 over): - T1 HealthChecker: 7 (start cadence + idempotent + stop + 3 전이 + manual) - T2 telemetry zod + stats: 8 (3 happy + 3 reject + 2 stats) - T5 store: 2 (recheckOllama 성공 + 실패 propagate) ## Gates - typecheck 0 errors - 단위 344/344 (29 files) - e2e 1/1 ## Test plan - [ ] ollama 끄고 60s 대기 → OllamaBanner 등장 + tray 메뉴 'Ollama 재확인' 활성 - [ ] ollama 켜고 60s 대기 → OllamaBanner 자동 사라짐 + tray 메뉴 비활성 - [ ] OllamaBanner 의 '재확인' 버튼 클릭 → 즉시 status 갱신 + telemetry ollama_recheck_manual emit - [ ] tray 'Ollama 재확인' 클릭 → 동일 - [ ] app quit 시 health.stop 호출 (logs 또는 process exit 검증) ## Refs - spec: docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md - plan: docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md - roadmap: docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md §3 #1 🤖 Generated with Claude Code
altair823 added 11 commits 2026-05-01 16:50:51 +00:00
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>
§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>
8 task TDD 분할 + 단위 ≥ 17개 (spec §6 의 12개 충족 + 5 over):
- T1 HealthChecker.start/stop + delta + onTelemetry hook
- T2 telemetry 3 events + stats.md (downtime 평균 / unreachable 빈도 / recheck 사용량)
- T3 main wiring — health.start + before-quit stop + onUpdate→push
- T4 IPC inbox:ollamaRecheck + pushOllamaStatus helper
- T5 InboxApi + preload + store recheckOllama + onOllamaStatus subscriber
- T6 tray 'Ollama 재확인' 메뉴 + 8th callback
- T7 OllamaBanner 재확인 button
- T8 closure

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- typecheck 0 errors
- 단위 344/344 (T1~T7 누적 17 신규)
- e2e 1/1
- roadmap §3 #1 ✓ 완료 마커

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-01 16:55:36 +00:00
Dismissed
claude-reviewer-01 left a comment
Member

v0.2.3 #1 Ollama recovery polling — spec/plan에 정확히 일치. HealthChecker.start/stop + delta-only onUpdate + 4 상태 전이 + onTelemetry hook + before-quit cleanup + IPC inbox:ollamaRecheck + ollama:status push + tray refreshTrayOllama + OllamaBanner 재확인 버튼이 모두 구현됨. 17 신규 단위 테스트 (HealthChecker 7 + telemetryEvents 6 + telemetryStats 2 + store 2) 가 spec §6 의 ≥12 요구를 초과 충족. 게이트 통과 확인: typecheck 0 errors / npm test 344 PASS / e2e 1/1. spec §10.1 매핑 매트릭스 7항목 모두 검증. 4 상태 전이 (true→true / true→false / false→true / false→false 같은/다른 reason) 단위로 가드. manual flag emit-before-call 의도 보존. zod 3 strict + reason max(500) privacy invariant 단위 회귀 가드. start/stop idempotent + before-quit 첫 statement 위치. note:updated 패턴 mirror 일관 (pushOllamaStatus / onOllamaStatus / 'ollama:status' channel). 머지 가능 — minor/nit 만 v0.2.4 backlog 누적 후보.

v0.2.3 #1 Ollama recovery polling — spec/plan에 정확히 일치. HealthChecker.start/stop + delta-only onUpdate + 4 상태 전이 + onTelemetry hook + before-quit cleanup + IPC inbox:ollamaRecheck + ollama:status push + tray refreshTrayOllama + OllamaBanner 재확인 버튼이 모두 구현됨. 17 신규 단위 테스트 (HealthChecker 7 + telemetryEvents 6 + telemetryStats 2 + store 2) 가 spec §6 의 ≥12 요구를 초과 충족. 게이트 통과 확인: typecheck 0 errors / npm test 344 PASS / e2e 1/1. spec §10.1 매핑 매트릭스 7항목 모두 검증. 4 상태 전이 (true→true / true→false / false→true / false→false 같은/다른 reason) 단위로 가드. manual flag emit-before-call 의도 보존. zod 3 strict + reason max(500) privacy invariant 단위 회귀 가드. start/stop idempotent + before-quit 첫 statement 위치. note:updated 패턴 mirror 일관 (pushOllamaStatus / onOllamaStatus / 'ollama:status' channel). 머지 가능 — minor/nit 만 v0.2.4 backlog 누적 후보.
@@ -128,6 +143,7 @@ app.whenReady().then(async () => {
let backupOnQuitDone = false;
app.on('before-quit', (e) => {

[minor] app.on('before-quit') 가 두 곳에 등록 (line 145 health/backup, line 349 trayInterval cleanup). 첫 핸들러가 e.preventDefault() 후 backup 완료 시 app.quit() → before-quit 재발화 → health.stop() 두 번 호출 (idempotent OK) + clearInterval(trayInterval) 가 두 번째 firing 에야 동작. 이는 pre-existing 패턴이지만 본 PR 가 health.stop() 을 추가하면서 '두 번째 before-quit 발화에서 무엇이 실행되는가' 의 ���드 리딩 부담이 늘어남. v0.2.4 에서 quit cleanup 을 단일 함수로 모으는 refactor 후보.

[minor] app.on('before-quit') 가 두 곳에 등록 (line 145 health/backup, line 349 trayInterval cleanup). 첫 핸들러가 e.preventDefault() 후 backup 완료 시 app.quit() → before-quit 재발화 → health.stop() 두 번 호출 (idempotent OK) + clearInterval(trayInterval) 가 두 번째 firing 에야 동작. 이는 pre-existing 패턴이지만 본 PR 가 health.stop() 을 추가하면서 '두 번째 before-quit 발화에서 무엇이 실행되는가' 의 ���드 리딩 부담이 늘어남. v0.2.4 에서 quit cleanup 을 단일 함수로 모으는 refactor 후보.
@@ -3,2 +15,4 @@
const DEFAULT_INTERVAL_MS = 60_000;
export class HealthChecker {
private last: HealthResult = { ok: true };

[minor] 초기 last = { ok: true } 가 첫 runOnce 에서 healthCheck 가 { ok: true, model: 'gemma4:e4b' } 반환 시 'no transition' 으로 분류되어 onUpdate 가 한 번도 fire 안 함 (okChanged=false, reasonChanged=undefined===undefined). renderer 는 startup 에서 inboxApi.getOllamaStatus() 로 fetch 해서 fine 하지만, push 채널만 의존하는 미래의 consumer 가 model 정보를 놓침. 의도라면 명시 주석 1줄 추가 권장 ('initial last is sentinel — first successful runOnce intentionally suppresses onUpdate when status remains ok'). v0.2.4 deferred OK.

[minor] 초기 last = { ok: true } 가 첫 runOnce 에서 healthCheck 가 { ok: true, model: 'gemma4:e4b' } 반환 시 'no transition' 으로 분류되어 onUpdate 가 한 번도 fire 안 함 (okChanged=false, reasonChanged=undefined===undefined). renderer 는 startup 에서 inboxApi.getOllamaStatus() 로 fetch 해서 fine 하지만, push 채널만 의존하는 미래의 consumer 가 model 정보를 놓침. 의도라면 명시 주석 1줄 추가 권장 ('initial last is sentinel — first successful runOnce intentionally suppresses onUpdate when status remains ok'). v0.2.4 deferred OK.
@@ -10,0 +30,4 @@
}
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult> {
if (opts?.manual === true) {

[nit] manual flag 의 ollama_recheck_manual emit 이 healthCheck 호출 에 fire — 의도적이지만 (provider 실패해도 manual 카운트 보장), 만약 healthCheck 가 throw 하면 (현재 LocalOllamaProvider impl 는 안 함) recheck_manual 은 emit 됐지만 onUpdate 는 fire 안 한 inconsistent state. 주석 1줄 ('emit BEFORE healthCheck — manual count must survive provider exception') 으로 의도 문서화 권장.

[nit] manual flag 의 ollama_recheck_manual emit 이 healthCheck 호출 *전*에 fire — 의도적이지만 (provider 실패해도 manual 카운트 보장), 만약 healthCheck 가 throw 하면 (현재 LocalOllamaProvider impl 는 안 함) recheck_manual 은 emit 됐지만 onUpdate 는 fire 안 한 inconsistent state. 주석 1줄 ('emit BEFORE healthCheck — manual count must survive provider exception') 으로 의도 문서화 권장.
@@ -10,0 +56,4 @@
start(): void {
if (this.timer !== null) return;
void this.runOnce();

[minor] start() 가 runOnce() 를 fire-and-forget (void) 로 즉시 호출. 첫 healthCheck 가 늦게 끝나는 동안 intervalMs 후 두 번째 runOnce 가 시작되면 둘 다 동시에 provider.healthCheck() 호출 → this.last 가 race 로 잘못된 순서로 저장 가능 (HTTP latency 가 intervalMs=60s 근접 시). 실사용에서는 /api/tags 가 ms 단위라 거의 발생 안 하지만, 테스트에서 fakeTimers 와 결합 시 발생 가능. 단위 테스트는 sequential await 사용하므로 가드 안 됨. v0.2.4 에서 'inFlight' guard 또는 last write wins 명시 주석 1줄 권장.

[minor] start() 가 runOnce() 를 fire-and-forget (void) 로 즉시 호출. 첫 healthCheck 가 늦게 끝나는 동안 intervalMs 후 두 번째 runOnce 가 시작되면 둘 다 동시에 provider.healthCheck() 호출 → this.last 가 race 로 잘못된 순서로 저장 가능 (HTTP latency 가 intervalMs=60s 근접 시). 실사용에서는 /api/tags 가 ms 단위라 거의 발생 안 하지만, 테스트에서 fakeTimers 와 결합 시 발생 가능. 단위 테스트는 sequential await 사용하므로 가드 안 됨. v0.2.4 에서 'inFlight' guard 또는 last write wins 명시 주석 1줄 권장.
@@ -30,3 +33,3 @@
window.addEventListener('focus', onFocus);
return () => { unsub(); window.removeEventListener('focus', onFocus); };
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
}, [loadInitial, refreshMeta, upsertNote]);

[nit] useEffect deps array 가 [loadInitial, refreshMeta, upsertNote] 만 — onOllamaStatus 의 setState 는 useInbox.setState 직접 호출이라 deps 영향 없으나, eslint exhaustive-deps 가 다음 추가 시 false positive 낼 수 있음. zustand 의 setState 는 stable reference 라 add 도 noop. 향후 다른 ev 추가 시 patterns 일관성 유지 권장.

[nit] useEffect deps array 가 [loadInitial, refreshMeta, upsertNote] 만 — onOllamaStatus 의 setState 는 useInbox.setState 직접 호출이라 deps 영향 없으나, eslint exhaustive-deps 가 다음 추가 시 false positive 낼 수 있음. zustand 의 setState 는 stable reference 라 add 도 noop. 향후 다른 ev 추가 시 patterns 일관성 유지 권장.
@@ -14,0 +14,4 @@
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<span style={{ flex: 1 }}> {message}</span>
<button
onClick={() => { void recheckOllama(); }}

[nit] 재확인 button 의 onClick 이 void recheckOllama() 사용 — recheckOllama 가 throw 하면 unhandled rejection. 현재 inboxApi.ollamaRecheck → ipcRenderer.invoke 는 IPC 채널이 없어진 케이스 외에는 reject 없고, IPC handler 자체가 healthCheck 가 throw 안 함을 가정. 안전하지만 try/catch 로 명시적 silent swallow 가 더 방어적. UI noise 없으니 nit 수준.

[nit] 재확인 button 의 onClick 이 `void recheckOllama()` 사용 — recheckOllama 가 throw 하면 unhandled rejection. 현재 inboxApi.ollamaRecheck → ipcRenderer.invoke 는 IPC 채널이 없어진 케이스 외에는 reject 없고, IPC handler 자체가 healthCheck 가 throw 안 함을 가정. 안전하지만 try/catch 로 명시적 silent swallow 가 더 방어적. UI noise 없으니 nit 수준.
@@ -0,0 +83,4 @@
now: () => { nowCounter += 1; return nowCounter * 1000; }
});
await hc.runOnce();
await hc.runOnce();

[nit] start() idempotent 테스트가 idx <= 2 만 검증. 만약 두 timer 가 동시에 1000ms 마다 fire 하면 idx 가 3 (즉시 1 + 1초 후 2개) 이 되겠지만, vi.advanceTimersByTimeAsync(1000) 가 두 timer 의 첫 fire 모두 실행하므로 idx 검증이 약함. private timer reference 직접 검증 (e.g., via reflection) 또는 5000ms advance 후 idx <= 6 같은 더 엄격한 invariant 권장. 본 테스트로도 명백한 leak 은 잡힘 — nit 수준.

[nit] start() idempotent 테스트가 `idx <= 2` 만 검증. 만약 두 timer 가 동시에 1000ms 마다 fire 하면 idx 가 3 (즉시 1 + 1초 후 2개) 이 되겠지만, vi.advanceTimersByTimeAsync(1000) 가 두 timer 의 첫 fire 모두 실행하므로 idx 검증이 약함. private timer reference 직접 검증 (e.g., via reflection) 또는 5000ms advance 후 idx <= 6 같은 더 엄격한 invariant 권장. 본 테스트로도 명백한 leak 은 잡힘 — nit 수준.
altair823 added 2 commits 2026-05-01 17:04:48 +00:00
m1 — HealthChecker.last={ok:true} sentinel 의도 주석 (line 17).
  첫 healthy=ok=true 면 transition 으로 인식 안 됨, ok=false 면 unreachable
  transition 으로 정상 인식. telemetry 누락 0.

m2 — runOnce in-flight guard 추가. polling 첫 호출이 늦게 끝나는 동안
  setInterval 가 두 번째 호출 시작하면 같은 promise 반환. healthCheck 가
  idempotent HTTP 라 race 안전하지만, 이중 onUpdate/telemetry emit 회피.

m3 — main.ts before-quit 핸들러 통합. trayInterval cleanup 별도 핸들러
  (line 349) 제거하고 health.stop() 핸들러 안에 흡수. 모든 cleanup 한 곳.

n1 — OllamaBanner 재확인 button 의 onClick 에 .catch 추가.
  recheckOllama Promise rejection 시 console.warn (silent swallow 회피).

n2 — App.tsx useEffect deps array 의도 주석 1줄. onOllamaStatus 콜백이
  useInbox.setState 직접 호출 — store reference 안정적이라 deps 불필요.

n3 — HealthChecker idempotent test 강화. <=2 → ===2 (정확).
  두 timer 등록되면 4 (각 timer 마다 즉시+1s) 가 됨.

n4 — runOnce 의 manual emit 이 healthCheck *전에* fire 인 의도 주석.
  provider 실패 시에도 manual 카운트 1:1 보장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-01 17:07:40 +00:00
claude-reviewer-01 left a comment
Member

round 2: 7개 round 1 이슈 모두 a94c757 단일 커밋에서 정확히 해결. typecheck 통과, unit 344/344, e2e 1/1. m1/n2/n4 의도 주석 명확. m2 in-flight guard (Promise field + finally 리셋) 정확. m3 trayInterval cleanup 첫 핸들러 통합. n1 Promise rejection .catch + console.warn. n3 idx===2 정확 비교. b6c3071 은 artifact cleanup. 신규 regression 없음.

round 2: 7개 round 1 이슈 모두 a94c757 단일 커밋에서 정확히 해결. typecheck 통과, unit 344/344, e2e 1/1. m1/n2/n4 의도 주석 명확. m2 in-flight guard (Promise<HealthResult> field + finally 리셋) 정확. m3 trayInterval cleanup 첫 핸들러 통합. n1 Promise rejection .catch + console.warn. n3 idx===2 정확 비교. b6c3071 은 artifact cleanup. 신규 regression 없음.
altair823 merged commit 37292f1a53 into main 2026-05-01 17:08:47 +00:00
altair823 deleted branch feat/v023-ollama 2026-05-01 17:08:48 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/inkling#16