Files
inkling/docs/superpowers/plans/2026-05-01-v023-ai-retry.md
altair823 821db4001d docs(plan): v0.2.3 #2 AI retry / 수동 trigger 구현 계획
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) <noreply@anthropic.com>
2026-05-02 03:08:06 +09:00

42 KiB

#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 discriminatedUnionai_retry_manual 신규 멤버, payload { failedCount: positive int } .strict().
src/main/services/TelemetryService.ts (modify) EmitInput union 에 ai_retry_manual 추가.
src/main/services/telemetryStats.ts (modify) DailyRowai_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) InboxApiretryAllFailed + 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):

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:

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: 커밋
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:

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:

export interface AiWorkerOptions {
  backoffsMs?: number[];
  unreachableBackoffsMs?: number[];  // 신규 v0.2.3 #2
  onUpdate?: (note: Note) => void;
  // ... 나머지 그대로 ...
}

(b) Update class fields and constructor:

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:

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:

} 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(...)):

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: 커밋
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:

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):

const AiRetryManualPayload = z.object({
  failedCount: z.number().int().positive()  // ≥1 (0 emit 자체가 invariant violation)
}).strict();

Append to TelemetryEventSchema discriminatedUnion:

z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict()
  • Step 4: TelemetryService.EmitInput

Append to EmitInput union (last position):

| { 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:

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:

let aiRetryManualCount = 0;
let aiRetryManualFailedSum = 0;

(c) Row creation include ai_retry_manual: 0.

(d) New if-else branch:

} 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:

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:

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):

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: 커밋
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:

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):

| { kind: 'ai_retry_manual'; payload: { failedCount: number } }

Add method (after trashExpiredBatch):

/**
 * 모든 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):

retryAllFailed(): Promise<{ count: number }>;
getFailedCount(): Promise<number>;
  • Step 5: IPC 2 채널

In src/main/ipc/inboxApi.ts, append handlers (after last existing channel):

ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed());
ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed());
  • Step 6: preload

In src/preload/index.ts, append:

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: 커밋
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:

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:

failedCount: number;
retryAllFailed: () => Promise<void>;

(b) Initial state add failedCount: 0,.

(c) loadInitial Promise.all add inboxApi.getFailedCount():

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):

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: 커밋
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:

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:

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

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

<PendingBanner />
<FailedBanner />
<ExpiryBanner />
  • Step 3: typecheck + 단위 + e2e

Run: npm run typecheck && npm test && npm run test:e2e Expected: 모두 PASS.

  • Step 4: 커밋
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):

let _runRetryAllFailed: () => void = () => {};
let _failedCount = 0;

(b) In buildMenu(), add menu item after "Ollama 재확인" (before auto-start block):

items.push({
  label: `지금 AI 처리 (실패 ${_failedCount}건)`,
  enabled: _failedCount > 0,
  click: _runRetryAllFailed
});

(c) Update createTray signature — add 9th positional param:

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:

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:

import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';

(b) AiWorker.onUpdate — add refreshTrayFailedCount(repo.countFailed()) 호출:

기존:

const worker = new AiWorker(repo, provider, {
  onUpdate: (note) => {
    pushNoteUpdated(getInboxWindow, note);
    refreshTray(repo.countToday());
  },
  // ...
});

신규:

const worker = new AiWorker(repo, provider, {
  onUpdate: (note) => {
    pushNoteUpdated(getInboxWindow, note);
    refreshTray(repo.countToday());
    refreshTrayFailedCount(repo.countFailed());
  },
  // ...
});

(c) createTray(...) 호출에 9th callback 추가:

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: 수동 검증
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: 커밋

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: 전체 게이트

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 커밋
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 직후 호출.