# #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**) | `<PendingBanner />` 직후 `<FailedBanner />` 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<AiResponse> {
      this.callCount += 1;
      throw new Error('ECONNREFUSED');
    }
    async healthCheck(): Promise<HealthResult> { return { ok: false }; }
  }

  class TimeoutProvider implements InferenceProvider {
    readonly name = 'fail';
    callCount = 0;
    async generate(): Promise<AiResponse> {
      this.callCount += 1;
      throw new Error('Request timeout');
    }
    async healthCheck(): Promise<HealthResult> { return { ok: false }; }
  }

  class SchemaFailProvider implements InferenceProvider {
    readonly name = 'fail';
    callCount = 0;
    async generate(): Promise<AiResponse> {
      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<HealthResult> { return { ok: true }; }
  }

  class RecoveringProvider implements InferenceProvider {
    readonly name = 'p';
    callCount = 0;
    failTimes: number;
    constructor(failTimes: number) { this.failTimes = failTimes; }
    async generate(): Promise<AiResponse> {
      this.callCount += 1;
      if (this.callCount <= this.failTimes) throw new Error('ECONNREFUSED');
      return { title: 't', summary: 's', tags: [], dueDate: null };
    }
    async healthCheck(): Promise<HealthResult> { 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<void> {
        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<number>;
```

- [ ] **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<void>;
```

(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 (
    <div style={{
      background: '#fce4e4', border: '1px solid #a33', borderRadius: 6,
      padding: '8px 12px', margin: '8px 0', fontSize: 13,
      display: 'flex', alignItems: 'center', gap: 8
    }}>
      <span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
      <button
        onClick={() => {
          retryAllFailed().catch((e) => {
            // eslint-disable-next-line no-console
            console.warn('retryAllFailed failed', e);
          });
        }}
        style={{
          background: '#a33', color: '#fff',
          border: 'none', borderRadius: 4,
          padding: '4px 12px', fontSize: 12, cursor: 'pointer'
        }}
      >
        재시도
      </button>
    </div>
  );
}
```

- [ ] **Step 2: App.tsx mount**

In `src/renderer/inbox/App.tsx`:

(a) Add import:

```typescript
import { FailedBanner } from './components/FailedBanner.js';
```

(b) Mount after `<PendingBanner />`, before `<ExpiryBanner />`:

```tsx
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
```

- [ ] **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 직후 호출.
