docs(spec): v0.2.3 #2 AI retry / 수동 trigger design
mini-brainstorm 결정 3개: - Q1=A unreachable backoff cap 15분 (30s→60s→120s→240s→480s→900s) - Q2=A timeout 도 unreachable 동일 (무한 retry, attempts 증가 안 함) - Q3=A retry-all 만 (per-note 버튼 v0.2.4) AiWorker unreachable/timeout 무한 retry + schema/other max 3 유지 + retryAllFailed atomic + FailedBanner (Inbox stack 4번째) + tray '지금 AI 처리 (실패 N건)' 9th callback + ai_retry_manual telemetry. roadmap §3 #2 deviation 1건 (timeout) 의식적 — v0.2.4 dogfood 데이터로 영구 hang 케이스 식별 후 가다듬기. T1-T8 작업 순서 + 단위 ≥ 17개. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
352
docs/superpowers/specs/2026-05-01-v023-ai-retry-design.md
Normal file
352
docs/superpowers/specs/2026-05-01-v023-ai-retry-design.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# v0.2.3 #2 AI retry / 수동 trigger 설계
|
||||
|
||||
**작성일:** 2026-05-01
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** v0.2.3 cut 7항목 중 5번째 항목 (#2 AI retry) 의 mini-brainstorm 결정 + design. roadmap §3 #2 의 In/Out 위에서 §8 미결정 3항목 (unreachable backoff cap / reason 분류 정밀도 / per-note retry) 결정.
|
||||
|
||||
**선행 문서:**
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #2, §8
|
||||
- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15), #1 ollama 회복 (PR #16)
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 요약
|
||||
|
||||
| Q | 결정 | 근거 |
|
||||
|---|------|------|
|
||||
| Q1 unreachable backoff cap | **A 15분 exponential** | 30s → 60s → 120s → 240s → 480s → 900s cap. 회복 latency 짧으면서 오래 꺼지면 부하 미미. |
|
||||
| Q2 timeout 분류 | **A unreachable 동일** (무한 retry) | timeout 99% 는 임시 (gemma cold start, 큰 입력). 영구 hang 케이스는 v0.2.4 dogfood 후 식별. roadmap §3 #2 In 과 deviation — 의식적. |
|
||||
| Q3 per-note retry | **A retry-all 만** | UI 노이즈 회피. NoteCard 단건 버튼은 v0.2.4 dogfood 마찰 발생 시 추가. |
|
||||
|
||||
---
|
||||
|
||||
## 2. AiWorker.processJob 정책 변경
|
||||
|
||||
### 2.1 분기 로직
|
||||
|
||||
`classifyReason(err)` 결과로 분기:
|
||||
|
||||
```ts
|
||||
const reason = classifyReason(err);
|
||||
if (reason === 'unreachable' || reason === 'timeout') {
|
||||
// 무한 retry 경로: attempts 증가 안 함, in-job loop 안에서 sleep + retry
|
||||
const sleepMs = nextBackoffMs(this.unreachableBackoffStep);
|
||||
this.unreachableBackoffStep = Math.min(this.unreachableBackoffStep + 1, 5);
|
||||
this.repo.setNextRunAt(job.noteId, new Date(Date.now() + sleepMs).toISOString(), msg);
|
||||
await this.sleep(sleepMs);
|
||||
// for 루프의 attempt 인덱스 그대로 — 다음 try 도 같은 attempt 번호로 재시도
|
||||
attempt -= 1; // for 루프의 attempt++ 상쇄
|
||||
continue;
|
||||
} else {
|
||||
// schema / other: 기존 max 3 retry 정책 그대로
|
||||
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
|
||||
if (isLast) {
|
||||
this.repo.markAiFailed(job.noteId, msg);
|
||||
if (this.telemetry) {
|
||||
await this.telemetry.emit({ kind: 'ai_failed', payload: { ... } }).catch(() => {});
|
||||
}
|
||||
this.emit(job.noteId);
|
||||
return;
|
||||
}
|
||||
await this.sleep(this.backoffsMs[attempt + 1] ?? 0);
|
||||
}
|
||||
```
|
||||
|
||||
성공 시 `unreachableBackoffStep = 0` 으로 reset.
|
||||
|
||||
### 2.2 backoff schedule
|
||||
|
||||
```ts
|
||||
private readonly UNREACHABLE_BACKOFFS_MS = [30_000, 60_000, 120_000, 240_000, 480_000, 900_000];
|
||||
private nextBackoffMs(step: number): number {
|
||||
return this.UNREACHABLE_BACKOFFS_MS[Math.min(step, 5)];
|
||||
}
|
||||
```
|
||||
|
||||
기존 `backoffsMs = [0, 30_000, 120_000]` 은 schema/other 전용 그대로.
|
||||
|
||||
### 2.3 invariants
|
||||
|
||||
- unreachable/timeout: `markAiFailed` 절대 호출 안 함. `ai_failed` telemetry emit 안 함.
|
||||
- schema/other: 기존 동작 (max 3 후 markAiFailed + emit).
|
||||
- 결과: `ai_failed.reason` 통계에는 schema/other 만 누적 (Q2 = A 의 자연 결과).
|
||||
|
||||
---
|
||||
|
||||
## 3. NoteRepository 확장
|
||||
|
||||
```ts
|
||||
// src/main/repository/NoteRepository.ts
|
||||
|
||||
findFailedIds(): string[];
|
||||
// SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC
|
||||
|
||||
countFailed(): number;
|
||||
// SELECT COUNT(*) FROM notes WHERE ai_status='failed' AND deleted_at IS NULL
|
||||
|
||||
retryAllFailed(now: string): { ids: string[] };
|
||||
// 단일 transaction 안에서:
|
||||
// UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=now WHERE id IN (...)
|
||||
// INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, now)
|
||||
// (이미 pending_jobs row 가 있으면 OR IGNORE — race 가드)
|
||||
|
||||
setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void;
|
||||
// UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?
|
||||
// attempts 변경 없음 — unreachable/timeout 무한 retry 용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. CaptureService + IPC
|
||||
|
||||
### 4.1 CaptureService 메서드
|
||||
|
||||
```ts
|
||||
async retryAllFailed(): Promise<{ count: number }> {
|
||||
const { ids } = this.repo.retryAllFailed(new Date().toISOString());
|
||||
for (const id of ids) {
|
||||
await this.deps.enqueue(id);
|
||||
}
|
||||
if (this.deps.telemetry && ids.length > 0) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: ids.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { count: ids.length };
|
||||
}
|
||||
```
|
||||
|
||||
빈 배열 시 telemetry emit 안 함 — 사용자가 "재시도" 클릭해도 N=0 이면 noise.
|
||||
|
||||
### 4.2 IPC 채널 신규 2
|
||||
|
||||
| 채널 | 입력 | 출력 |
|
||||
|------|------|------|
|
||||
| `inbox:retryAllFailed` | (없음) | `{ count: number }` |
|
||||
| `inbox:failedCount` | (없음) | `number` |
|
||||
|
||||
confirm dialog 불필요 — destructive 아님 (단순 재처리 큐 등록, 데이터 손실 없음).
|
||||
|
||||
---
|
||||
|
||||
## 5. Tray + Banner UI
|
||||
|
||||
### 5.1 Tray 메뉴
|
||||
|
||||
기존 (#1 cut 후):
|
||||
```
|
||||
- 사용 로그 내보내기...
|
||||
- Ollama 재확인 (status.ok=false 시 enabled)
|
||||
```
|
||||
|
||||
신규 (본 cut):
|
||||
```
|
||||
- 사용 로그 내보내기...
|
||||
- Ollama 재확인 (status.ok=false 시 enabled)
|
||||
- 지금 AI 처리 (실패 N건) (failedCount > 0 시 enabled, label dynamic with N)
|
||||
```
|
||||
|
||||
`refreshTrayFailedCount(count: number)` setter — `refreshTrayOllama` 와 동일 패턴. `_failedCount` module-level state + 메뉴 rebuild.
|
||||
|
||||
`createTray` 의 9번째 callback `runRetryAllFailed`. 8 → 9 positional. v0.2.4 backlog #4 (TrayCallbacks object refactor) trigger 더 강화.
|
||||
|
||||
AiWorker.onUpdate 시점에 `refreshTrayFailedCount(repo.countFailed())` 호출.
|
||||
|
||||
### 5.2 FailedBanner
|
||||
|
||||
`src/renderer/inbox/components/FailedBanner.tsx` (신규):
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div className="banner warn" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button onClick={() => { retryAllFailed().catch((e) => console.warn('retryAllFailed failed', e)); }}>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
스타일: warn variant 색상은 PendingBanner 와 다른 차별 (#fff7e6 / #d99500 의 ExpiryBanner 와도 다름) — 본 banner 는 빨강 톤 (#fce4e4 / #a33). 사용자 주의 필요한 영구 실패 신호.
|
||||
|
||||
### 5.3 Inbox 상단 stack 갱신
|
||||
|
||||
```
|
||||
1. OllamaBanner (system - down)
|
||||
2. RecoveryToast (회복 toast)
|
||||
3. PendingBanner (AI 처리 N건 - 일시)
|
||||
4. FailedBanner (AI 실패 N건 - 영구 - 신규)
|
||||
5. ExpiryBanner (만료)
|
||||
6. tagFilter chip
|
||||
7. notes
|
||||
```
|
||||
|
||||
위치 근거: system status > 진행 (transient) > 영구 실패 (actionable) > 트리아지 (expiry, also actionable but lower urgency) > filter > content.
|
||||
|
||||
---
|
||||
|
||||
## 6. zustand store
|
||||
|
||||
```ts
|
||||
// InboxState 확장
|
||||
failedCount: number;
|
||||
retryAllFailed: () => Promise<void>;
|
||||
```
|
||||
|
||||
initial: `failedCount: 0`.
|
||||
|
||||
`loadInitial` / `refreshMeta` 의 `Promise.all` 에 `inboxApi.getFailedCount()` 합류 → `set({ failedCount })`.
|
||||
|
||||
`retryAllFailed` action:
|
||||
```ts
|
||||
async retryAllFailed() {
|
||||
const r = await inboxApi.retryAllFailed();
|
||||
// 낙관적 갱신: failedCount = 0 으로 reset (worker 처리 진행 중)
|
||||
// 실제 카운트는 AiWorker.onUpdate 트리거된 refreshMeta 에서 자연 동기.
|
||||
// PendingBanner 가 처리 중 N 건 노출.
|
||||
set({ failedCount: 0 });
|
||||
// r.count 는 telemetry/log 정보용
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Telemetry
|
||||
|
||||
### 7.1 신규 1 event
|
||||
|
||||
| event | payload | 발화 |
|
||||
|-------|---------|------|
|
||||
| `ai_retry_manual` | `{ failedCount: number }` (≥1) | retryAllFailed 시 ids.length>0 일 때만 |
|
||||
|
||||
빈 배열 시 emit 안 함 — sentinel.
|
||||
|
||||
### 7.2 zod schema
|
||||
|
||||
```ts
|
||||
const AiRetryManualPayload = z.object({
|
||||
failedCount: z.number().int().positive() // ≥1 enforced — 0 emit 자체가 invariant violation
|
||||
}).strict();
|
||||
```
|
||||
|
||||
### 7.3 stats.md 집계
|
||||
|
||||
신규 행 (수동 recheck 사용량 다음):
|
||||
- AI 수동 재시도 사용량: `count` 회 / 누적 `Σfailedcount` 건
|
||||
|
||||
`DailyRow` 에 1 카운터 + sum 누적기 추가.
|
||||
|
||||
### 7.4 기존 `ai_failed` 영향
|
||||
|
||||
변경 없음. unreachable/timeout 가 markAiFailed 안 부르므로 자연히 reason 분포에서 제외. 결과: `ai_failed.reason` 분포 = schema + other 만. dogfood 통계 의미 명확화.
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
| 영역 | 케이스 | 검증 |
|
||||
|------|--------|------|
|
||||
| AiWorker | unreachable 무한 retry | attempts 증가 안 함, markAiFailed 안 호출, ai_failed emit 안 함 |
|
||||
| AiWorker | timeout 무한 retry (Q2=A) | unreachable 와 동일 경로 |
|
||||
| AiWorker | schema fail max 3 | attempts 증가, 마지막에 markAiFailed + ai_failed emit |
|
||||
| AiWorker | other fail max 3 | schema 와 동일 |
|
||||
| AiWorker | unreachable backoff step | 1차 30s, 2차 60s, ..., 6차 900s cap |
|
||||
| AiWorker | success 시 unreachableBackoffStep reset | 다음 unreachable 발생 시 30s 부터 |
|
||||
| Repo | findFailedIds — failed + active 만 | trashed 또는 pending/done 제외 |
|
||||
| Repo | countFailed | 정확 |
|
||||
| Repo | retryAllFailed atomic | ai_status reset + pending_jobs 재투입 |
|
||||
| Repo | retryAllFailed empty | `{ ids: [] }` |
|
||||
| Repo | retryAllFailed pending_jobs 이미 존재 | OR IGNORE — race 안전 |
|
||||
| Repo | setNextRunAt | attempts 변경 없이 next_run_at + last_error 만 |
|
||||
| CaptureService | retryAllFailed — telemetry emit + worker.enqueue 호출 | per-id enqueue + ai_retry_manual emit |
|
||||
| CaptureService | retryAllFailed 빈 결과 emit 없음 | count=0 sentinel |
|
||||
| TelemetryEvents | zod parse `ai_retry_manual` | happy + extra field reject + 0 reject (≥1 invariant) |
|
||||
| TelemetryStats | AI 수동 재시도 집계 | count + sum |
|
||||
| Store | retryAllFailed action — failedCount=0 reset | 낙관적 갱신 |
|
||||
|
||||
총 ≥ 17 단위.
|
||||
|
||||
---
|
||||
|
||||
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
|
||||
|
||||
T1. Repo: findFailedIds + countFailed + retryAllFailed + setNextRunAt + 단위 5개
|
||||
T2. AiWorker: unreachable/timeout 무한 retry 로직 + 단위 6개
|
||||
T3. Telemetry: ai_retry_manual 1 event + stats + 단위 3개
|
||||
T4. CaptureService.retryAllFailed + IPC 2 채널 + preload + 단위 2개
|
||||
T5. shared/types InboxApi + store retryAllFailed + failedCount + 단위 1개
|
||||
T6. FailedBanner 컴포넌트 + App.tsx mount
|
||||
T7. Tray "지금 AI 처리 (실패 N건)" 메뉴 + 9th callback + refreshTrayFailedCount + main wiring
|
||||
T8. closure (gates + roadmap mark + memory backlog)
|
||||
|
||||
---
|
||||
|
||||
## 10. roadmap In/Out 일치
|
||||
|
||||
### 10.1 roadmap §3 #2 In 매핑
|
||||
|
||||
| roadmap | design |
|
||||
|---------|--------|
|
||||
| AiWorker unreachable 무한 retry, attempts 증가 안 함 | §2 ✓ |
|
||||
| schema fail / invalid response / timeout 만 attempts 증가 (max 3 유지) | §2 — **timeout 은 deviation (Q2=A)**, schema/other 만 attempts 증가 |
|
||||
| markAiFailed 한 노트 수동 re-enqueue | §3 retryAllFailed |
|
||||
| 트레이 + Inbox "지금 AI 처리 (실패 N건)" | §5 ✓ |
|
||||
| FailedBanner | §5.2 ✓ |
|
||||
| IPC `inbox:retryAllFailed`, `inbox:failedCount` | §4 ✓ |
|
||||
| Telemetry `ai_retry_manual {failedCount}` | §7 ✓ |
|
||||
| 단위 테스트 | §8 ≥ 17 |
|
||||
|
||||
### 10.2 Out 유지
|
||||
|
||||
- per-note retry 버튼 (Q3=A) — Out
|
||||
- failed reason 별 차등 정책 — Out (모두 동일 max 3, telemetry 통계만 분리)
|
||||
- retry progress UI — Out (PendingBanner 가 자연 표현)
|
||||
- retry rate-limit — Out
|
||||
|
||||
### 10.3 roadmap deviation
|
||||
|
||||
§3 #2 In 의 "timeout 만 attempts 증가" 와 본 design 의 Q2=A "timeout 무한 retry" 가 충돌. 의식적 변경 — `ai_failed.reason='timeout'` 통계가 부족할 수 있음. dogfood 데이터로 검증 후 v0.2.4 에서 hang 케이스 분리 가능.
|
||||
|
||||
---
|
||||
|
||||
## 11. 위험 / 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| unreachable 무한 retry 큐 폭주 | sleep await sequential. 같은 job 안 in-place loop, 새 job 추가 0. cap 15분. |
|
||||
| retryAllFailed 가 큰 N (예: 100+) | enqueue in-memory queue push. AiWorker 가 sequential 처리 — provider 호출 1개씩. 폭주 0. |
|
||||
| timeout 분류 잘못 — 영구 hang 노트가 무한 retry | telemetry markAiFailed 시점만 emit → timeout 무한 retry 노트 stats 안 보임. v0.2.4 dogfood 시 ai_status='pending' 의 attempts 분포로 영구 hang 식별. 필요 시 timeout cap 도입. |
|
||||
| unreachableBackoffStep 이 process restart 시 reset | 의도. next_run_at 가 미래면 sleep, 과거면 즉시 retry — 자연. |
|
||||
| schema 후 unreachable 발생 — backoff step 이 unreachableBackoffStep 와 별개 인덱스 | unreachableBackoffStep 은 unreachable/timeout 전용. schema 의 attempts 와 독립. 단위 테스트 회귀 가드. |
|
||||
| retryAllFailed 와 AiWorker 큐의 race (이미 처리 중인 노트 재투입) | retryAllFailed SQL 이 ai_status='failed' 만 → 처리 중 ('pending') 노트는 자연 제외. atomic transaction. |
|
||||
| pending_jobs 재투입 시 이미 pending_jobs row 존재 (예: race) | INSERT OR IGNORE — duplicate ignored. attempts/next_run_at 그대로 유지. 안전. |
|
||||
|
||||
---
|
||||
|
||||
## 12. 게이트 (PR 머지 조건)
|
||||
|
||||
- `npm run typecheck` 0 errors
|
||||
- `npm test` — 344 + 17 = 361+
|
||||
- `npm run test:e2e` 1/1
|
||||
- main 머지
|
||||
|
||||
머지 후:
|
||||
- roadmap §3 #2 ✓ 완료 마커
|
||||
- v0.2.4 backlog 누적
|
||||
|
||||
---
|
||||
|
||||
## 13. 변경 이력
|
||||
|
||||
| 일자 | 변경 |
|
||||
|------|------|
|
||||
| 2026-05-01 | 초안 — Q1=A (15분 cap), Q2=A (timeout=unreachable), Q3=A (retry-all only). AiWorker unreachable/timeout 무한 retry + retryAllFailed atomic + FailedBanner + tray "지금 AI 처리" + ai_retry_manual telemetry. roadmap §3 #2 deviation 1건 (timeout) 의식적. |
|
||||
Reference in New Issue
Block a user