---
phase: 18-gh-issues-write-back-notes-and-card-cloud-storage
plan: 03
subsystem: sync-layer
tags: [pending-edits, gh-issues, background-service, backoff, flusher]
dependency_graph:
  requires:
    - 18-01 (V006 migration, PendingEditRow, SqliteStore CRUD)
    - 18-02 (Arc<GhIssuesClient>, insert_pending_edit calls in save_note + detect_card_changes)
  provides:
    - PendingEditFlusher background service (pending_edit_flusher.rs)
    - start_flusher() wired into LiveClient::new()
    - Legacy pending_edits.json cleanup on startup
    - Old PendingEditQueue removed
  affects:
    - crates/app/src/dashboard/pending_edit_flusher.rs (new)
    - crates/app/src/dashboard/mod.rs
    - crates/app/src/dashboard/edit_queue.rs (hollowed out)
    - crates/app/src/live_client.rs
    - crates/app/src/main.rs
tech_stack:
  added: []
  patterns:
    - std::thread::Builder::new().name().spawn() for named daemon thread
    - epoch seconds string for backoff comparison (no chrono, TEXT column compatible)
    - Flusher sleeps first then flushes — avoids premature flush before first sync creates issues
    - Arc<SqliteStore> + Arc<GhIssuesClient> passed into flusher thread (Plan 02 Arc refactor reused)
key_files:
  created:
    - crates/app/src/dashboard/pending_edit_flusher.rs
  modified:
    - crates/app/src/dashboard/mod.rs
    - crates/app/src/dashboard/edit_queue.rs
    - crates/app/src/live_client.rs
    - crates/app/src/main.rs
decisions:
  - last_attempted_at stored as epoch seconds string (not ISO 8601) for direct arithmetic in backoff comparison; TEXT column compatible
  - Flusher sleeps BEFORE first pass to avoid racing against sync_card_issues creating issues on startup
  - SaveNote fallback: if payload lacks issue_number, read_all_cards() resolves current github_issue_number; if still None, increment retry and wait for next sync cycle
  - PendingEditQueue removed entirely — no offline JSON fallback; SQLite pending_edits table is the queue
  - pending_edit_count UI counter removed from main.rs (no replacement needed; flusher manages queue silently)
metrics:
  duration_minutes: 12
  completed_date: "2026-03-27"
  tasks_completed: 2
  files_modified: 5
requirements_addressed:
  - CLOUD-02
---

# Phase 18 Plan 03: PendingEditFlusher Background Service Summary

**One-liner:** PendingEditFlusher background thread with 60s interval + exponential backoff (60/120/240/480/900s) flushing SaveNote/UpdateCardBody/UpdateCardTitle SQLite pending_edits to GH Issues, replacing the old JSON-file PendingEditQueue.

## Tasks Completed

| Task | Description | Commit |
|------|-------------|--------|
| 1 | PendingEditFlusher module with backoff logic, flush_one, run_flush_pass, start_flusher | 635e87e |
| 2 | Wire flusher into LiveClient startup, legacy cleanup, remove PendingEditQueue | b119806 |

## What Was Built

### Task 1: PendingEditFlusher module (pending_edit_flusher.rs)

**`backoff_secs(retry_count) -> u64`:**
- Exponential backoff: `60 * 2^min(retry_count, 4)` capped at 900s (15 min)
- Sequence: 60, 120, 240, 480, 900, 900, 900...

**`should_skip_for_backoff(edit) -> bool`:**
- retry_count == 0: never skip (first attempt)
- retry_count >= MAX_RETRIES (15): always skip (dormant)
- Otherwise: compare `last_attempted_at` epoch vs required_wait seconds

**`flush_one(edit, gh_client, store) -> Result<(), String>`:**
- `SaveNote`: posts GH comment via `create_issue_comment`; falls back to `read_all_cards()` lookup if `issue_number` missing from payload
- `UpdateCardBody`: calls `edit_issue_body` with payload `{issue_number, body}`
- `UpdateCardTitle`: calls `edit_issue_title` with payload `{issue_number, title}`
- Unknown edit_type: returns Err (never deletes)

**`run_flush_pass(store, gh_client)`:**
- Reads all pending_edits (FIFO order via created_at ASC)
- Skips backoff-gated edits
- On success: `delete_pending_edit(id)`
- On failure: `increment_pending_edit_retry(id, epoch_now())`

**`start_flusher(store, gh_client) -> JoinHandle<()>`:**
- Named thread "pending-edit-flusher"
- Loops: sleep 60s → run_flush_pass (sleep-first to avoid racing sync_card_issues on startup)
- Handle intentionally dropped (daemon behavior)

**Unit tests (4):**
- `backoff_secs_grows_exponentially_and_caps_at_900` — verifies all 6 boundary values
- `should_skip_zero_retry_returns_false`
- `should_skip_max_retries_returns_true`
- `should_skip_recent_attempt_returns_true` / `should_skip_old_attempt_returns_false`

**`mod.rs`:** Added `pub mod pending_edit_flusher;`

### Task 2: Wire flusher into LiveClient, remove old PendingEditQueue

**`LiveClient::new()` changes:**
1. Constructs `live_client` struct first (was direct return)
2. D-15: Removes `pending_edits.json` via `db_path().parent().join("pending_edits.json")`
3. Starts `crate::dashboard::pending_edit_flusher::start_flusher(store, gh_client)` if `gh_issues` is `Some`
4. Returns `live_client`

**`edit_queue.rs`:** Hollowed out — replaced with deprecation comment. `PendingEditQueue` struct and `PendingEdit` enum removed entirely.

**`dashboard/mod.rs`:** Removed `pub use edit_queue::{PendingEdit, PendingEditQueue};` re-export.

**`main.rs`:**
- Removed import of `PendingEdit, PendingEditQueue`
- Removed `queue_path` + `pending_queue` initialization block
- Removed `window.set_pending_edit_count(...)` calls (4 locations)
- Removed `queue.borrow_mut().push(PendingEdit::...)` calls (6 locations) from `on_card_save_note`, `on_card_confirm_remove_item`, `on_card_remove_single_item`, `on_lookup_item_selected`, `on_lookup_create_confirmed` (2 branches)
- Simplified dispatch calls — receipt return value no longer used for queue fallback

## Verification

- `cargo build --workspace` — compiles cleanly (0 errors)
- `cargo test --workspace` — all tests pass (162 app + 73 integrations + 24 service, 0 failures)
- `backoff_secs(0)=60, (1)=120, (2)=240, (3)=480, (4)=900, (5)=900` — verified by unit test
- No `PendingEditQueue` references remain (only in deprecation comment)
- Flusher started when `GhIssuesClient` available (line in live_client.rs)
- Legacy `pending_edits.json` deleted via `db_path().parent()` (resolves `%APPDATA%/WITwhat/`)
- `SaveNote` without `issue_number` in payload falls back to `read_all_cards()` lookup

## Deviations from Plan

### Auto-fixed Issues

None — plan executed exactly as written.

One note on scope: `main.rs` required more changes than the plan's task description enumerated. The plan listed "remove PendingEditQueue" as the goal; removing it required touching 6 callback sites and 4 `set_pending_edit_count` calls in `main.rs`. All changes are in-scope (Rule 1: removing all references is required to hollow out the old queue).

## Known Stubs

None — all new functionality is fully implemented. The PendingEditFlusher will consume all queued `SaveNote`, `UpdateCardBody`, and `UpdateCardTitle` pending edits on the 60s cycle.

## Self-Check: PASSED
