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>
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 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):
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 의
unreachableBackoffsMsoption 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 직후 호출.