From 821db4001db71a47b438fe11a61b2ebaa7bc3011 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 03:08:06 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20v0.2.3=20#2=20AI=20retry=20/=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20trigger=20=EA=B5=AC=ED=98=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 task TDD 분할 + 단위 ≥ 18개 (spec §6 의 17개 충족 + 1 over): - T1 NoteRepository — findFailedIds/countFailed/retryAllFailed/setNextRunAt - T2 AiWorker unreachable/timeout 무한 retry (15분 cap) - T3 telemetry ai_retry_manual + stats - T4 CaptureService.retryAllFailed + IPC 2채널 - T5 store retryAllFailed action + failedCount - T6 FailedBanner + App.tsx mount - T7 tray '지금 AI 처리' 9th callback - T8 closure Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-01-v023-ai-retry.md | 1205 +++++++++++++++++ 1 file changed, 1205 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-v023-ai-retry.md diff --git a/docs/superpowers/plans/2026-05-01-v023-ai-retry.md b/docs/superpowers/plans/2026-05-01-v023-ai-retry.md new file mode 100644 index 0000000..f7ed0aa --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-v023-ai-retry.md @@ -0,0 +1,1205 @@ +# #2 AI retry / 수동 trigger 구현 plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** v0.2.3 다섯 번째 항목 — AiWorker 의 `unreachable` / `timeout` 실패는 attempts 증가 없이 무한 retry (15분 cap exponential backoff). `schema` / `other` 만 max 3 후 markAiFailed (기존 정책). 사용자가 "지금 AI 처리 (실패 N건)" 트레이/배너 trigger 로 모든 ai_status='failed' 노트 일괄 재투입. Telemetry 1 신규 event (`ai_retry_manual`). + +**Architecture:** AiWorker.processJob 의 catch 분기 변경 — `classifyReason` 결과로 unreachable/timeout 은 in-job loop 안 sleep + attempt 인덱스 유지하며 무한 retry. schema/other 는 기존 incrementJobAttempt 경로. Repo 4 신규 메소드 (`findFailedIds`, `countFailed`, `retryAllFailed`, `setNextRunAt`). CaptureService.retryAllFailed → IPC `inbox:retryAllFailed` → main 이 worker.enqueue 호출 + telemetry. 신규 FailedBanner (Inbox stack, PendingBanner 와 ExpiryBanner 사이) + tray 9th callback "지금 AI 처리 (실패 N건)". + +**Tech Stack:** TypeScript / electron-vite / better-sqlite3 12.9 / zod 4.3.6 / vitest 4 / React 19 / zustand 5. 신규 dep 0. + +**선행 spec:** `docs/superpowers/specs/2026-05-01-v023-ai-retry-design.md` +**선행 cut:** v0.2.3 #1 ollama 회복 (commit `37292f1`) — telemetry hook 패턴 + tray callback 누적 위에서 동작. + +--- + +## File Structure + +| 경로 | 책임 | +|------|------| +| `src/main/repository/NoteRepository.ts` (**modify**) | `findFailedIds()` / `countFailed()` / `retryAllFailed(now)` / `setNextRunAt(noteId, nextRunAt, lastError)` 4 신규 메소드. | +| `src/main/ai/AiWorker.ts` (**modify**) | catch 분기 — unreachable/timeout 무한 retry (in-place loop + sleep cap 15분), schema/other max 3 유지. `unreachableBackoffStep` instance state + reset on success. | +| `src/main/services/telemetryEvents.ts` (**modify**) | zod `discriminatedUnion` 에 `ai_retry_manual` 신규 멤버, payload `{ failedCount: positive int }` `.strict()`. | +| `src/main/services/TelemetryService.ts` (**modify**) | `EmitInput` union 에 `ai_retry_manual` 추가. | +| `src/main/services/telemetryStats.ts` (**modify**) | `DailyRow` 에 `ai_retry_manual` 카운터 + 표 컬럼 + AI 수동 재시도 사용량 합계. | +| `src/main/services/CaptureService.ts` (**modify**) | `TelemetryEmitter` interface 에 `ai_retry_manual` 추가. `retryAllFailed()` 메소드 — repo.retryAllFailed → enqueue per id → emit. | +| `src/main/ipc/inboxApi.ts` (**modify**) | `inbox:retryAllFailed` + `inbox:failedCount` 2 신규 IPC 채널. | +| `src/main/index.ts` (**modify**) | tray 9th callback `runRetryAllFailed`. AiWorker.onUpdate 에서 `refreshTrayFailedCount(repo.countFailed())` 호출. | +| `src/main/tray.ts` (**modify**) | 9th positional callback + `_failedCount` 모듈 state + 메뉴 항목 "지금 AI 처리 (실패 N건)" + `refreshTrayFailedCount` exported setter. | +| `src/preload/index.ts` (**modify**) | `retryAllFailed` invoke + `getFailedCount` invoke. | +| `src/shared/types.ts` (**modify**) | `InboxApi` 에 `retryAllFailed` + `getFailedCount`. | +| `src/renderer/inbox/store.ts` (**modify**) | `failedCount` state + `retryAllFailed` action. `loadInitial`/`refreshMeta` Promise.all 에 `getFailedCount` 합류. | +| `src/renderer/inbox/components/FailedBanner.tsx` (**new**) | 빨강 톤 banner (`#fce4e4 / #a33`). count > 0 시 노출, "재시도" button. | +| `src/renderer/inbox/App.tsx` (**modify**) | `` 직후 `` mount (showTrash=false 분기). | + +테스트: +- `tests/unit/NoteRepository.test.ts` (**modify**) — 5 신규 cases (findFailedIds / countFailed / retryAllFailed atomic / retryAllFailed empty / setNextRunAt). +- `tests/unit/AiWorker.test.ts` (**modify**) — 6 신규 cases (unreachable 무한 retry / timeout 무한 retry / schema max 3 / other max 3 / backoff step 진행 / success 후 step reset). +- `tests/unit/telemetryEvents.test.ts` (**modify**) — 3 신규 (zod parse + ≥1 invariant + extra field reject). +- `tests/unit/telemetryStats.test.ts` (**modify**) — 1 신규 (AI 수동 재시도 집계). +- `tests/unit/CaptureService.test.ts` (**modify**) — 2 신규 (retryAllFailed 정상 + empty no-emit). +- `tests/unit/store.aiRetry.test.ts` (**new**) — 1 case (retryAllFailed 가 failedCount=0 reset). + +--- + +## Task 1: NoteRepository — findFailedIds + countFailed + retryAllFailed + setNextRunAt + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Modify: `tests/unit/NoteRepository.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +Append to `tests/unit/NoteRepository.test.ts` end (existing imports already present): + +```typescript +describe('NoteRepository — failed retry helpers', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + function makeFailed(rawText: string, deletedAt: string | null = null): string { + const { id } = repo.create({ rawText }); + db.prepare( + `UPDATE notes SET ai_status='failed', ai_error='boom', deleted_at=? WHERE id=?` + ).run(deletedAt, id); + db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + return id; + } + + it('findFailedIds returns ai_status=failed AND deleted_at IS NULL only', () => { + const a = makeFailed('a'); + makeFailed('b', '2026-04-30T00:00:00Z'); // trashed + repo.create({ rawText: 'pending'}); // pending + expect(repo.findFailedIds().sort()).toEqual([a].sort()); + }); + + it('countFailed counts active failed notes only', () => { + makeFailed('a'); + makeFailed('b'); + makeFailed('c', '2026-04-30T00:00:00Z'); + expect(repo.countFailed()).toBe(2); + }); + + it('retryAllFailed atomic — ai_status reset + pending_jobs 재투입', () => { + const a = makeFailed('a'); + const b = makeFailed('b'); + const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z'); + expect(r.ids.sort()).toEqual([a, b].sort()); + expect(repo.findById(a)!.aiStatus).toBe('pending'); + expect(repo.findById(b)!.aiStatus).toBe('pending'); + expect(repo.findById(a)!.aiError).toBeNull(); + const jobs = repo.getAllPendingJobs(); + expect(jobs.map((j) => j.noteId).sort()).toEqual([a, b].sort()); + for (const j of jobs) { + expect(j.attempts).toBe(0); + expect(j.nextRunAt).toBe('2026-05-01T12:00:00.000Z'); + } + }); + + it('retryAllFailed empty — { ids: [] }', () => { + expect(repo.retryAllFailed('2026-05-01T12:00:00.000Z')).toEqual({ ids: [] }); + }); + + it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => { + const { id } = repo.create({ rawText: 'x' }); // pending_jobs row attempts=0 + repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error'); + // attempts=1 이 됨 + repo.setNextRunAt(id, '2026-05-01T12:00:00.000Z', 'unreachable'); + const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!; + expect(job.attempts).toBe(1); // 변화 없음 + expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z'); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/NoteRepository.test.ts` +Expected: FAIL — 4 메소드 미정의. + +- [ ] **Step 3: 구현** + +In `src/main/repository/NoteRepository.ts`, append after `markAiFailed`: + +```typescript +findFailedIds(): string[] { + const rows = this.db + .prepare( + `SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC` + ) + .all() as Array<{ id: string }>; + return rows.map((r) => r.id); +} + +countFailed(): number { + const row = this.db + .prepare( + `SELECT COUNT(*) AS c FROM notes WHERE ai_status='failed' AND deleted_at IS NULL` + ) + .get() as { c: number }; + return row.c; +} + +/** + * 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset 하고 pending_jobs 재투입. + * 단일 transaction. v0.2.3 #2 retryAllFailed. + * + * INSERT OR IGNORE 로 race 안전 (이미 pending_jobs row 존재 시 skip — duplicate 방지). + */ +retryAllFailed(now: string): { ids: string[] } { + const ids: string[] = []; + const tx = this.db.transaction(() => { + const rows = this.db + .prepare(`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`) + .all() as Array<{ id: string }>; + if (rows.length === 0) return; + const reset = this.db.prepare( + `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` + ); + const insert = this.db.prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ); + for (const r of rows) { + reset.run(now, r.id); + insert.run(r.id, now); + ids.push(r.id); + } + }); + tx(); + return { ids }; +} + +/** + * pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음. + * v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로). + */ +setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void { + this.db + .prepare( + `UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?` + ) + .run(nextRunAt, lastError.slice(0, 500), noteId); +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/NoteRepository.test.ts` +Expected: typecheck 0. 5 신규 + 기존 모두 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "feat(retry): NoteRepository — findFailedIds/countFailed/retryAllFailed/setNextRunAt (#2 v0.2.3)" +``` + +--- + +## Task 2: AiWorker — unreachable/timeout 무한 retry + +**Files:** +- Modify: `src/main/ai/AiWorker.ts` +- Modify: `tests/unit/AiWorker.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +Append to `tests/unit/AiWorker.test.ts` end: + +```typescript +describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { + let db: Database.Database; + let repo: NoteRepository; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + }); + + class UnreachableProvider implements InferenceProvider { + readonly name = 'fail'; + callCount = 0; + async generate(): Promise { + this.callCount += 1; + throw new Error('ECONNREFUSED'); + } + async healthCheck(): Promise { return { ok: false }; } + } + + class TimeoutProvider implements InferenceProvider { + readonly name = 'fail'; + callCount = 0; + async generate(): Promise { + this.callCount += 1; + throw new Error('Request timeout'); + } + async healthCheck(): Promise { return { ok: false }; } + } + + class SchemaFailProvider implements InferenceProvider { + readonly name = 'fail'; + callCount = 0; + async generate(): Promise { + this.callCount += 1; + // ZodError 시뮬레이션: schema 분류로 인식되도록 throw + const ZodErrorClass = (await import('zod')).ZodError; + throw new ZodErrorClass([{ code: 'custom', message: 'bad', path: [] } as any]); + } + async healthCheck(): Promise { return { ok: true }; } + } + + class RecoveringProvider implements InferenceProvider { + readonly name = 'p'; + callCount = 0; + failTimes: number; + constructor(failTimes: number) { this.failTimes = failTimes; } + async generate(): Promise { + this.callCount += 1; + if (this.callCount <= this.failTimes) throw new Error('ECONNREFUSED'); + return { title: 't', summary: 's', tags: [], dueDate: null }; + } + async healthCheck(): Promise { return { ok: true }; } + } + + it('unreachable — markAiFailed 안 호출, attempts 증가 안 함', async () => { + const provider = new UnreachableProvider(); + const worker = new AiWorker(repo, provider, { + backoffsMs: [0, 30_000, 120_000], + unreachableBackoffsMs: [10, 10, 10, 10, 10, 10], // 빠르게 검증 + now: () => new Date('2026-05-01T12:00:00Z') + }); + const { id } = repo.create({ rawText: 'x' }); + await worker.enqueue(id); + // 6회 retry 발생할 때까지 기다리고 stop. 무한 retry 라 drain() 은 끝나지 않음 — 외부에서 종료 + await new Promise((r) => setTimeout(r, 200)); + // markAiFailed 안 호출됨 → ai_status 그대로 pending + expect(repo.findById(id)!.aiStatus).toBe('pending'); + expect(provider.callCount).toBeGreaterThanOrEqual(2); // 최소 2회는 retry + // attempts 가 증가하지 않았어야 함 (setNextRunAt 만 호출, incrementJobAttempt 안 호출) + const jobs = repo.getAllPendingJobs(); + const job = jobs.find((j) => j.noteId === id)!; + expect(job.attempts).toBe(0); + }); + + it('timeout — unreachable 동일 (Q2=A 회귀 가드)', async () => { + const provider = new TimeoutProvider(); + const worker = new AiWorker(repo, provider, { + backoffsMs: [0, 30_000, 120_000], + unreachableBackoffsMs: [10, 10, 10, 10, 10, 10], + now: () => new Date('2026-05-01T12:00:00Z') + }); + const { id } = repo.create({ rawText: 'x' }); + await worker.enqueue(id); + await new Promise((r) => setTimeout(r, 200)); + expect(repo.findById(id)!.aiStatus).toBe('pending'); // markAiFailed 안 됨 + expect(provider.callCount).toBeGreaterThanOrEqual(2); + }); + + it('schema fail max 3 — markAiFailed + ai_failed emit', async () => { + const provider = new SchemaFailProvider(); + const events: any[] = []; + const worker = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], // 빠르게 + unreachableBackoffsMs: [10], + now: () => new Date('2026-05-01T12:00:00Z'), + telemetry: { emit: async (e) => { events.push(e); } } + }); + const { id } = repo.create({ rawText: 'x' }); + await worker.enqueue(id); + await worker.drain(); + expect(repo.findById(id)!.aiStatus).toBe('failed'); + expect(provider.callCount).toBe(3); + const failedEvent = events.find((e) => e.kind === 'ai_failed'); + expect(failedEvent).toBeDefined(); + expect(failedEvent.payload.reason).toBe('schema'); + }); + + it('unreachable backoff step 진행 — UNREACHABLE_BACKOFFS_MS 순서', async () => { + const sleeps: number[] = []; + class CapturingWorker extends AiWorker { + protected override async sleepCapture(ms: number): Promise { + sleeps.push(ms); + } + } + // 별도 mock — sleep 가로채기 + const provider = new UnreachableProvider(); + const worker = new AiWorker(repo, provider, { + backoffsMs: [0, 30_000, 120_000], + unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000] + }); + // 직접 nextBackoffMs 검증 (private 노출 또는 step API) + // 실제로는 instance state 검증 — 0~5 step 6회 retry 후 sleep 값 확인. 단순화: + expect((worker as any).nextBackoffMs(0)).toBe(30_000); + expect((worker as any).nextBackoffMs(5)).toBe(900_000); + expect((worker as any).nextBackoffMs(10)).toBe(900_000); // cap + }); + + it('success 후 unreachableBackoffStep reset', async () => { + const provider = new RecoveringProvider(2); // 2회 unreachable, 3회 째 성공 + const worker = new AiWorker(repo, provider, { + backoffsMs: [0, 0, 0], + unreachableBackoffsMs: [10, 10, 10, 10, 10, 10], + now: () => new Date('2026-05-01T12:00:00Z') + }); + const { id } = repo.create({ rawText: 'x' }); + await worker.enqueue(id); + await worker.drain(); + expect(repo.findById(id)!.aiStatus).toBe('done'); + expect(provider.callCount).toBe(3); + expect((worker as any).unreachableBackoffStep).toBe(0); // reset + }); + + it('ai_failed.reason 통계 — unreachable/timeout 무한 retry 라 emit 안 함', async () => { + const provider = new UnreachableProvider(); + const events: any[] = []; + const worker = new AiWorker(repo, provider, { + backoffsMs: [0, 30_000, 120_000], + unreachableBackoffsMs: [10, 10, 10, 10, 10, 10], + telemetry: { emit: async (e) => { events.push(e); } } + }); + const { id } = repo.create({ rawText: 'x' }); + await worker.enqueue(id); + await new Promise((r) => setTimeout(r, 200)); + // ai_failed 이벤트가 발생하지 않아야 함 (markAiFailed 호출 안 함 → emit 안 함) + expect(events.filter((e) => e.kind === 'ai_failed')).toHaveLength(0); + }); +}); +``` + +NOTE — 위 테스트가 import 들 (ZodError, AiResponse, HealthResult, InferenceProvider 등) 가 기존 파일에 있는지 확인. 없으면 추가. + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/AiWorker.test.ts` +Expected: FAIL — `unreachableBackoffsMs` option / 무한 retry 분기 / `nextBackoffMs` private method 부재. + +- [ ] **Step 3: AiWorker.ts 변경** + +In `src/main/ai/AiWorker.ts`: + +(a) Update `AiWorkerOptions` to add new field: + +```typescript +export interface AiWorkerOptions { + backoffsMs?: number[]; + unreachableBackoffsMs?: number[]; // 신규 v0.2.3 #2 + onUpdate?: (note: Note) => void; + // ... 나머지 그대로 ... +} +``` + +(b) Update class fields and constructor: + +```typescript +export class AiWorker { + private queue: Job[] = []; + private running = false; + private drainResolvers: Array<() => void> = []; + private backoffsMs: number[]; + private unreachableBackoffsMs: number[]; // 신규 + private unreachableBackoffStep = 0; // 신규 — instance state, success 시 reset + // ... 나머지 그대로 ... + + constructor( + private repo: NoteRepository, + private provider: InferenceProvider, + opts: AiWorkerOptions = {} + ) { + this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000]; + this.unreachableBackoffsMs = opts.unreachableBackoffsMs ?? [30_000, 60_000, 120_000, 240_000, 480_000, 900_000]; + // ... 나머지 그대로 ... + } +``` + +(c) Add private helper: + +```typescript +private nextBackoffMs(step: number): number { + const idx = Math.min(step, this.unreachableBackoffsMs.length - 1); + return this.unreachableBackoffsMs[idx]!; +} +``` + +(d) Modify `processJob` — the existing for-loop catch branch. Replace the entire `catch (err) { ... }` block with: + +```typescript +} catch (err) { + const reason = classifyReason(err); + const msg = (err as Error).message; + this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg, reason }); + if (reason === 'unreachable' || reason === 'timeout') { + // 무한 retry: attempts 증가 안 함, in-place loop + sleep + const sleepMs = this.nextBackoffMs(this.unreachableBackoffStep); + this.unreachableBackoffStep = Math.min(this.unreachableBackoffStep + 1, this.unreachableBackoffsMs.length - 1); + const nextRunAt = new Date(Date.now() + sleepMs).toISOString(); + this.repo.setNextRunAt(job.noteId, nextRunAt, msg); + await this.sleep(sleepMs); + attempt -= 1; // for 루프 attempt++ 상쇄 — 같은 attempt 인덱스로 재시도 + continue; + } + // schema / other: 기존 max 3 retry 정책 + const isLast = attempt === max - 1; + const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString(); + this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg); + if (isLast) { + this.repo.markAiFailed(job.noteId, msg); + this.logger.error('ai.failed', { noteId: job.noteId, err: msg }); + if (this.telemetry) { + await this.telemetry.emit({ + kind: 'ai_failed', + payload: { + noteId: job.noteId, + reason, + attempts: attempt + 1 + } + }).catch(() => {}); + } + this.emit(job.noteId); + return; + } + await this.sleep(this.backoffsMs[attempt + 1] ?? 0); +} +``` + +(e) Add `unreachableBackoffStep = 0` reset on success — inside the success branch (right after `this.repo.updateAiResult(...)`): + +```typescript +this.repo.updateAiResult(job.noteId, { /* ... */ }); +this.unreachableBackoffStep = 0; // 신규 — 성공 시 step reset +this.logger.info('ai.done', { /* ... */ }); +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/AiWorker.test.ts` +Expected: typecheck 0. 6 신규 + 기존 모두 PASS. + +만약 일부 테스트가 timing 이슈로 flaky 면 (e.g. setTimeout 200ms 가 부족), 200 → 500 으로 늘리거나 직접 internal state 폴링. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts +git commit -m "feat(retry): AiWorker unreachable/timeout 무한 retry — 15분 cap (#2 v0.2.3)" +``` + +--- + +## Task 3: Telemetry — ai_retry_manual event + stats + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` +- Modify: `src/main/services/TelemetryService.ts` +- Modify: `src/main/services/telemetryStats.ts` +- Modify: `tests/unit/telemetryEvents.test.ts` +- Modify: `tests/unit/telemetryStats.test.ts` + +- [ ] **Step 1: 실패 테스트 (events)** + +Append to `tests/unit/telemetryEvents.test.ts`: + +```typescript +describe('ai_retry_manual event', () => { + it('parses valid ai_retry_manual', () => { + const ev = validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ai_retry_manual', + payload: { failedCount: 5 } + }); + if (ev.kind !== 'ai_retry_manual') throw new Error('discriminant'); + expect(ev.payload.failedCount).toBe(5); + }); + + it('rejects ai_retry_manual with failedCount=0 (≥1 invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ai_retry_manual', + payload: { failedCount: 0 } + })).toThrow(); + }); + + it('rejects ai_retry_manual with extra payload field (privacy invariant)', () => { + expect(() => validateEvent({ + ts: '2026-05-01T00:00:00.000Z', + kind: 'ai_retry_manual', + payload: { failedCount: 5, rawText: 'leak' } + })).toThrow(); + }); +}); +``` + +- [ ] **Step 2: FAIL** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: FAIL — discriminant 부재. + +- [ ] **Step 3: telemetryEvents.ts 확장** + +Add new payload schema (alongside existing payloads): + +```typescript +const AiRetryManualPayload = z.object({ + failedCount: z.number().int().positive() // ≥1 (0 emit 자체가 invariant violation) +}).strict(); +``` + +Append to `TelemetryEventSchema` discriminatedUnion: + +```typescript +z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict() +``` + +- [ ] **Step 4: TelemetryService.EmitInput** + +Append to `EmitInput` union (last position): + +```typescript +| { kind: 'ai_retry_manual'; payload: { failedCount: number } }; +``` + +- [ ] **Step 5: 테스트 실행 (events) — PASS** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: 3 신규 + 기존 PASS. + +- [ ] **Step 6: 실패 테스트 (stats)** + +Append to `tests/unit/telemetryStats.test.ts`: + +```typescript +describe('aggregateStats — ai_retry_manual', () => { + it('counts events and sums failedCount', () => { + const events = [ + { ts: '2026-05-01T00:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 3 } }, + { ts: '2026-05-01T01:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 7 } } + ]; + const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); + expect(r.md).toContain('ai_retry_manual'); + // 2회 / 누적 10건 + expect(r.md).toMatch(/AI 수동 재시도.*2회.*10건/); + }); +}); +``` + +- [ ] **Step 7: telemetryStats.ts 확장** + +(a) DailyRow add `ai_retry_manual: number;`. + +(b) Accumulator near top: +```typescript +let aiRetryManualCount = 0; +let aiRetryManualFailedSum = 0; +``` + +(c) Row creation include `ai_retry_manual: 0`. + +(d) New if-else branch: +```typescript +} else if (ev.kind === 'ai_retry_manual') { + row.ai_retry_manual += 1; + aiRetryManualCount += 1; + aiRetryManualFailedSum += ev.payload.failedCount; +} +``` + +(e) Table header — add column at the end: +```typescript +lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual |'); +lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|'); +``` + +(f) Body row: +```typescript +lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} |`); +``` + +(g) Add summary line (after 수동 recheck 사용량 line): +```typescript +lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}건`); +``` + +- [ ] **Step 8: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/telemetryEvents.test.ts tests/unit/telemetryStats.test.ts` +Expected: 신규 + 기존 PASS. + +전체 회귀: `npm test`. TelemetryService.test.ts 의 narrowing 가드가 새 kind 들어오면 깨질 수 있음 — 그 가드 라인에 `e.kind !== 'ai_retry_manual'` 추가 (#1 패턴 일치). + +- [ ] **Step 9: 커밋** + +```bash +git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts src/main/services/telemetryStats.ts tests/unit/telemetryEvents.test.ts tests/unit/telemetryStats.test.ts tests/unit/TelemetryService.test.ts +git commit -m "feat(retry): telemetry ai_retry_manual + stats AI 수동 재시도 (#2 v0.2.3)" +``` + +--- + +## Task 4: CaptureService.retryAllFailed + IPC 2 채널 + +**Files:** +- Modify: `src/main/services/CaptureService.ts` +- Modify: `src/main/ipc/inboxApi.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/shared/types.ts` +- Modify: `tests/unit/CaptureService.test.ts` + +- [ ] **Step 1: 실패 테스트** + +Append to `tests/unit/CaptureService.test.ts`: + +```typescript +describe('CaptureService.retryAllFailed', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let calls: Array<{ kind: string; payload: any }>; + let enqueued: string[]; + let svc: CaptureService; + + function makeFailed(rawText: string): string { + const { id } = repo.create({ rawText }); + db.prepare(`UPDATE notes SET ai_status='failed', ai_error='boom' WHERE id=?`).run(id); + db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + return id; + } + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + calls = []; + enqueued = []; + svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: () => {}, + telemetry: { emit: async (input) => { calls.push(input as any); } } + }); + }); + + it('retryAllFailed — enqueue per id + ai_retry_manual emit', async () => { + const a = makeFailed('a'); + const b = makeFailed('b'); + const r = await svc.retryAllFailed(); + expect(r.count).toBe(2); + expect(enqueued.sort()).toEqual([a, b].sort()); + expect(calls).toContainEqual( + expect.objectContaining({ kind: 'ai_retry_manual', payload: { failedCount: 2 } }) + ); + }); + + it('retryAllFailed empty — count=0, no emit', async () => { + const r = await svc.retryAllFailed(); + expect(r.count).toBe(0); + expect(enqueued).toEqual([]); + expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: FAIL** + +Run: `npm test -- tests/unit/CaptureService.test.ts` +Expected: FAIL — `retryAllFailed` 미정의. + +- [ ] **Step 3: CaptureService 확장** + +Extend `TelemetryEmitter` interface (add at end): + +```typescript +| { kind: 'ai_retry_manual'; payload: { failedCount: number } } +``` + +Add method (after `trashExpiredBatch`): + +```typescript +/** + * 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset + worker.enqueue 재투입. + * 빈 결과는 telemetry emit 안 함 (failedCount ≥ 1 invariant). + * v0.2.3 #2 retry-all manual trigger. + */ +async retryAllFailed(): Promise<{ count: number }> { + const { ids } = this.repo.retryAllFailed(new Date().toISOString()); + for (const id of ids) { + await this.deps.enqueue(id); + } + if (ids.length > 0 && this.deps.telemetry) { + await this.deps.telemetry.emit({ + kind: 'ai_retry_manual', + payload: { failedCount: ids.length } + }).catch(() => {}); + } + return { count: ids.length }; +} +``` + +- [ ] **Step 4: shared/types InboxApi** + +In `src/shared/types.ts`, append to InboxApi (alongside other v0.2.3 #2 entries): + +```typescript +retryAllFailed(): Promise<{ count: number }>; +getFailedCount(): Promise; +``` + +- [ ] **Step 5: IPC 2 채널** + +In `src/main/ipc/inboxApi.ts`, append handlers (after last existing channel): + +```typescript +ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed()); +ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed()); +``` + +- [ ] **Step 6: preload** + +In `src/preload/index.ts`, append: + +```typescript +retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'), +getFailedCount: () => ipcRenderer.invoke('inbox:failedCount'), +``` + +- [ ] **Step 7: typecheck + 단위 + e2e** + +Run: `npm run typecheck && npm test && npm run test:e2e` +Expected: typecheck 0, 단위 모두 PASS, e2e 1/1. + +- [ ] **Step 8: 커밋** + +```bash +git add src/main/services/CaptureService.ts src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts tests/unit/CaptureService.test.ts +git commit -m "feat(retry): CaptureService.retryAllFailed + IPC 2 channels (#2 v0.2.3)" +``` + +--- + +## Task 5: zustand store + failedCount + retryAllFailed action + +**Files:** +- Modify: `src/renderer/inbox/store.ts` +- Create: `tests/unit/store.aiRetry.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +Create `tests/unit/store.aiRetry.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockApi = { + listNotes: vi.fn(async () => []), + listTrash: vi.fn(async () => []), + getTrashCount: vi.fn(async () => 0), + getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), + getPendingCount: vi.fn(async () => 0), + getOllamaStatus: vi.fn(async () => ({ ok: true })), + getTodayCount: vi.fn(async () => 0), + restoreNote: vi.fn(async () => {}), + permanentDeleteNote: vi.fn(async () => ({ confirmed: true })), + emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })), + deleteNote: vi.fn(async () => {}), + onNoteUpdated: vi.fn(() => () => {}), + updateAiFields: vi.fn(async () => {}), + setDueDate: vi.fn(async () => {}), + setIntent: vi.fn(async () => {}), + dismissIntent: vi.fn(async () => {}), + listExpired: vi.fn(async () => []), + trashExpiredBatch: vi.fn(async () => ({ trashedCount: 0, confirmed: false })), + ollamaRecheck: vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })), + onOllamaStatus: vi.fn(() => () => {}), + retryAllFailed: vi.fn(async () => ({ count: 0 })), + getFailedCount: vi.fn(async () => 0) +}; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); + +describe('useInbox — AI retry (v0.2.3 #2)', () => { + beforeEach(async () => { + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + useInbox.setState({ + notes: [], trashNotes: [], trashCount: 0, showTrash: false, + loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, failedCount: 5, + ollamaStatus: { ok: true }, + continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }, + expiredCandidates: [], expiredSnoozeUntilMs: null + }); + Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); + }); + + it('retryAllFailed action — failedCount=0 reset 후 IPC 호출', async () => { + mockApi.retryAllFailed.mockResolvedValueOnce({ count: 5 }); + const { useInbox } = await import('../../src/renderer/inbox/store.js'); + await useInbox.getState().retryAllFailed(); + expect(mockApi.retryAllFailed).toHaveBeenCalledTimes(1); + expect(useInbox.getState().failedCount).toBe(0); // 낙관적 갱신 + }); +}); +``` + +- [ ] **Step 2: FAIL** + +Run: `npm test -- tests/unit/store.aiRetry.test.ts` +Expected: FAIL — `retryAllFailed` / `failedCount` 미정의. + +- [ ] **Step 3: store.ts 확장** + +(a) Extend `InboxState`: + +```typescript +failedCount: number; +retryAllFailed: () => Promise; +``` + +(b) Initial state add `failedCount: 0,`. + +(c) `loadInitial` Promise.all add `inboxApi.getFailedCount()`: + +```typescript +async loadInitial() { + set({ loading: true }); + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([ + inboxApi.listNotes({ limit: 50 }), + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus(), + inboxApi.getTodayCount(), + inboxApi.getTrashCount(), + inboxApi.listExpired(), + inboxApi.getFailedCount() + ]); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, loading: false }); +}, +``` + +(d) Same pattern for `refreshMeta`. + +(e) Add action (after `recheckOllama`): + +```typescript +async retryAllFailed() { + await inboxApi.retryAllFailed(); + // 낙관적 갱신: failedCount = 0. AiWorker 처리 진행 중에 PendingBanner 가 N건 노출. + // refreshMeta 가 트리거되면 자연 동기 (worker.onUpdate → main → renderer). + set({ failedCount: 0 }); +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/store.aiRetry.test.ts` +Expected: 1 신규 PASS. + +전체: `npm test` — 모든 sister store 테스트도 PASS. + +- [ ] **Step 5: 커밋** + +```bash +git add src/renderer/inbox/store.ts tests/unit/store.aiRetry.test.ts +git commit -m "feat(retry): store retryAllFailed action + failedCount (#2 v0.2.3)" +``` + +--- + +## Task 6: FailedBanner + App.tsx mount + +**Files:** +- Create: `src/renderer/inbox/components/FailedBanner.tsx` +- Modify: `src/renderer/inbox/App.tsx` + +- [ ] **Step 1: FailedBanner.tsx 작성** + +Create `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 ( +
+ ❌ AI 처리 실패 {count} + +
+ ); +} +``` + +- [ ] **Step 2: App.tsx mount** + +In `src/renderer/inbox/App.tsx`: + +(a) Add import: + +```typescript +import { FailedBanner } from './components/FailedBanner.js'; +``` + +(b) Mount after ``, before ``: + +```tsx + + + +``` + +- [ ] **Step 3: typecheck + 단위 + e2e** + +Run: `npm run typecheck && npm test && npm run test:e2e` +Expected: 모두 PASS. + +- [ ] **Step 4: 커밋** + +```bash +git add src/renderer/inbox/components/FailedBanner.tsx src/renderer/inbox/App.tsx +git commit -m "feat(retry): FailedBanner + App.tsx mount (#2 v0.2.3)" +``` + +--- + +## Task 7: tray "지금 AI 처리 (실패 N건)" 메뉴 + 9th callback + main wiring + +**Files:** +- Modify: `src/main/tray.ts` +- Modify: `src/main/index.ts` + +- [ ] **Step 1: tray.ts 변경** + +In `src/main/tray.ts`: + +(a) Add module state (after `_runOllamaRecheck` / `_ollamaOk`): + +```typescript +let _runRetryAllFailed: () => void = () => {}; +let _failedCount = 0; +``` + +(b) In `buildMenu()`, add menu item after "Ollama 재확인" (before auto-start block): + +```typescript +items.push({ + label: `지금 AI 처리 (실패 ${_failedCount}건)`, + enabled: _failedCount > 0, + click: _runRetryAllFailed +}); +``` + +(c) Update `createTray` signature — add 9th positional param: + +```typescript +export function createTray( + showInbox: () => void, + showCapture: () => void, + runBackup: () => void, + runExport: () => void, + runImport: () => void, + runSync: () => void, + runExportTelemetry: () => void, + runOllamaRecheck: () => void, + runRetryAllFailed: () => void +): TrayType { + _showInbox = showInbox; + _showCapture = showCapture; + _runBackup = runBackup; + _runExport = runExport; + _runImport = runImport; + _runSync = runSync; + _runExportTelemetry = runExportTelemetry; + _runOllamaRecheck = runOllamaRecheck; + _runRetryAllFailed = runRetryAllFailed; + // ... 이하 그대로 ... +} +``` + +(d) Add `refreshTrayFailedCount` exported setter: + +```typescript +export function refreshTrayFailedCount(count: number): void { + _failedCount = count; + if (tray === null) return; + tray.setContextMenu(buildMenu()); +} +``` + +- [ ] **Step 2: main/index.ts 변경** + +In `src/main/index.ts`: + +(a) Update tray import: + +```typescript +import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js'; +``` + +(b) AiWorker.onUpdate — add `refreshTrayFailedCount(repo.countFailed())` 호출: + +기존: +```typescript +const worker = new AiWorker(repo, provider, { + onUpdate: (note) => { + pushNoteUpdated(getInboxWindow, note); + refreshTray(repo.countToday()); + }, + // ... +}); +``` + +신규: +```typescript +const worker = new AiWorker(repo, provider, { + onUpdate: (note) => { + pushNoteUpdated(getInboxWindow, note); + refreshTray(repo.countToday()); + refreshTrayFailedCount(repo.countFailed()); + }, + // ... +}); +``` + +(c) `createTray(...)` 호출에 9th callback 추가: + +```typescript +createTray( + () => createInboxWindow(), + () => showQuickCapture(), + async () => { /* runBackup ... 기존 그대로 */ }, + async () => { /* runExport ... */ }, + async () => { /* runImport ... */ }, + async () => { /* runSync ... */ }, + async () => { /* runExportTelemetry ... */ }, + () => { void health.runOnce({ manual: true }); }, + () => { void capture.retryAllFailed(); } // 9th: 지금 AI 처리 +); +``` + +(d) startup 시 1회 `refreshTrayFailedCount(repo.countFailed())` 호출 — `refreshTray(repo.countToday())` 옆. + +- [ ] **Step 3: typecheck + 단위 + e2e** + +Run: `npm run typecheck && npm test && npm run test:e2e` +Expected: 모두 PASS. + +- [ ] **Step 4: 수동 검증** + +```bash +npm run dev +``` + +수동 확인: +- ollama 끄고 새 노트 생성 → AiWorker 가 무한 retry (markAiFailed 안 됨, ai_status='pending' 유지). FailedBanner 미노출. +- ollama 끈 상태에서 다른 케이스 시뮬레이션 (예: 잘못된 모델) → schema/other 분류 → max 3 후 ai_status='failed' → FailedBanner 1건 노출 + tray "지금 AI 처리 (실패 1건)" 활성. +- "재시도" 클릭 (banner 또는 tray) → confirm 없이 즉시 재투입 → PendingBanner N건 → 처리 진행. +- 재시도 후 ai_succeeded 또는 다시 fail 시 FailedBanner 자동 갱신. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/tray.ts src/main/index.ts +git commit -m "feat(retry): tray '지금 AI 처리' 메뉴 + 9th callback (#2 v0.2.3)" +``` + +--- + +## Task 8: Closure (gates + roadmap mark + memory backlog) + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` +- Modify: `memory/project_v024_backlog.md` (review 결과 반영) + +- [ ] **Step 1: 전체 게이트** + +```bash +npm run typecheck # 0 errors +npm test # 344 + 17 = 361+ PASS +npm run test:e2e # 1/1 +``` + +- [ ] **Step 2: roadmap §3 #2 ✓ 마커** + +In `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md`, change `### #2 AI retry / 수동 trigger (5번)` → `### #2 AI retry / 수동 trigger (5번) ✓ 완료`. + +- [ ] **Step 3: closure 커밋** + +```bash +git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +git commit -m "chore(retry): #2 closure — gates verified + roadmap mark complete" +``` + +- [ ] **Step 4: PR 작성 + 머지** + +PR title: `feat(retry): #2 AI retry 수동 trigger (v0.2.3 5/7)` +PR body: spec/plan/roadmap 링크 + 작업 요약 + 게이트 결과 + 단위 N개 + Q2=A timeout deviation 명시. + +머지 후: +- 로컬 main fast-forward +- `feat/v023-ai-retry` 브랜치 정리 +- v0.2.3 진행: 7항목 중 5/7 완료. 다음 #3 태그 vocab. + +--- + +## Self-Review (작성 후 점검) + +### Spec coverage 매트릭스 + +| spec §10.1 항목 | 본 plan task | +|----------------|------------| +| AiWorker unreachable 무한 retry, attempts 증가 안 함 | T2 | +| timeout 무한 retry (Q2=A) | T2 | +| schema/other max 3 markAiFailed | T2 | +| markAiFailed 노트 수동 re-enqueue | T1 retryAllFailed + T4 CaptureService | +| 트레이 + Inbox "지금 AI 처리 (실패 N건)" | T6 (banner) + T7 (tray) | +| FailedBanner | T6 | +| IPC `inbox:retryAllFailed`, `inbox:failedCount` | T4 | +| Telemetry `ai_retry_manual {failedCount}` | T3 | +| 단위 테스트 ≥ 17 | T1(5) + T2(6) + T3(4) + T4(2) + T5(1) = 18 ≥ 17 | + +### 일관성 + +- T1 의 `retryAllFailed(now): { ids }` → T4 의 `CaptureService.retryAllFailed()` 가 호출. +- T1 의 `setNextRunAt` → T2 의 AiWorker 무한 retry 분기에서 호출. +- T2 의 `unreachableBackoffsMs` option default `[30_000, 60_000, 120_000, 240_000, 480_000, 900_000]` — spec §1 Q1=A 와 일치. +- T3 의 zod schema `failedCount: positive int` → T4 의 emit `{ failedCount: ids.length }` (ids.length>0 가드 후) 일치. +- T7 의 tray 9th callback `() => { void capture.retryAllFailed(); }` → T4 의 service 호출 일관. + +### Out 항목 일관 처리 + +- per-note retry 버튼 → 본 plan 어디에도 없음. ✓ +- failed reason 별 차등 정책 → 모두 동일 max 3 또는 무한 retry 로 분류. ✓ +- retry progress UI → PendingBanner 자연 노출. ✓ +- retry rate-limit → 없음. ✓ + +### Self-review 후 수정 + +- T2 의 4번째 테스트 케이스 `expect((worker as any).nextBackoffMs(...))` 는 private method 직접 접근 — 일반적이지 않지만 작은 helper 검증으로 OK. 단위 테스트의 표현력 우선. +- T2 의 일부 테스트가 `setTimeout(200)` 으로 wait — flaky 가능. 만약 실패 시 wait 늘리거나 직접 internal state 폴링. +- T7 의 startup 시 `refreshTrayFailedCount(repo.countFailed())` 호출 (Step 2.d) 가 createTray 호출 *후* 위치해야 — 그 전에 호출하면 `tray===null` 분기 타고 silently no-op. 안전하지만 무의미. createTray 직후 호출.