Skip to content

feat(harness): SQLite pending requests for crash-durable approvals - #10

Merged
ranvier2d2 merged 4 commits into
mainfrom
feat/sqlite-pending-requests
Mar 25, 2026
Merged

feat(harness): SQLite pending requests for crash-durable approvals#10
ranvier2d2 merged 4 commits into
mainfrom
feat/sqlite-pending-requests

Conversation

@ranvier2d2

@ranvier2d2 ranvier2d2 commented Mar 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add harness_pending_requests SQLite table so in-flight approvals and elicitations survive BEAM restarts
  • Fix Projector to handle both request/resolved AND user-input/resolved (Claude/OpenCode emit the latter for elicitations)
  • Fix CodexSession.assert_codex_versionSystem.cmd doesn't support :timeout, replaced with Task.async + Task.yield

Design decisions

  • Single table (harness_pending_requests) instead of two separate tables for approvals vs elicitations — they share the same event lifecycle (request/openedrequest/resolved | user-input/resolved)
  • INSERT OR IGNORE on open, DELETE on resolve — no zombie rows possible, no status column needed for Phase 1
  • Recovery: on startup, pending requests are loaded from SQLite and merged into session snapshots as the authoritative source (more reliable than the JSON snapshot in harness_sessions which may be stale)
  • Lockstep persistence: pending request writes happen in the same GenServer cast as event persistence + projection — no "two truths" risk

Files changed

File Change
storage.ex New table migration + 3 API functions (insert, resolve, query)
projector.ex Match user-input/resolved alongside request/resolved
snapshot_server.ex persist_pending_request/1 + merge_pending_into_sessions/2 for recovery
codex_session.ex Fix System.cmd timeout → Task.async + Task.yield
storage_test.exs 6 new tests (CRUD, idempotency, recovery, user-input lifecycle)
helpers.ts Increase assistant message timeout 10s → 30s
.gitignore Ignore demo scripts + remove tracked e2e-results

Test plan

  • 23 Elixir tests passing (6 new)
  • Pending requests survive SnapshotServer restart
  • user-input/resolved correctly clears pending rows
  • INSERT OR IGNORE prevents duplicates
  • reset! clears pending table
  • Manual: send approval request → kill BEAM → restart → verify pending still in snapshot

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Persistent tracking of pending requests so unresolved items survive restarts and are queryable.
  • Bug Fixes

    • Improved recovery and automatic resolution of pending requests on shutdown/termination to avoid leaks.
    • Notification events now consistently mark resolved requests (including user-input resolutions).
  • Chores

    • Added tests, extended test timeouts for response visibility, and updated local ignore rules.

ranvier2d2 and others added 2 commits March 25, 2026 17:45
Add `harness_pending_requests` table so in-flight approvals and
elicitations survive BEAM restarts. Previously, pending state lived
only in GenServer memory — a crash between request/opened and
request/resolved left the UI stuck with no approval prompt.

Changes:
- Storage: new table + insert/resolve/query API (INSERT OR IGNORE
  on open, DELETE on resolve — no zombie rows possible)
- Projector: handle both `request/resolved` AND `user-input/resolved`
  (Claude/OpenCode emit the latter for elicitations)
- SnapshotServer: persist pending requests in lockstep with events,
  recover and merge into sessions on startup
- CodexSession: fix System.cmd timeout bug (use Task.async + yield)
- E2E helpers: increase assistant message timeout 10s → 30s

23 tests, 0 failures (6 new: CRUD lifecycle, idempotency, recovery
after restart, user-input/resolved resolution).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 25, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@ranvier2d2 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 59 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 81ba4eaf-91a5-4c67-a14c-123492e12d17

📥 Commits

Reviewing files that changed from the base of the PR and between 4050079 and 533d67b.

📒 Files selected for processing (2)
  • apps/harness/lib/harness/providers/codex_session.ex
  • apps/harness/test/harness/storage_test.exs
📝 Walkthrough

Walkthrough

Adds durable persistence and recovery for harness pending requests, resolves them via extended notification methods, persists pending state on events, cancels pending requests on session termination, and expands tests. Also converts a codex version check to async, tweaks e2e timing, and updates .gitignore.

Changes

Cohort / File(s) Summary
Persistence & Storage
apps/harness/lib/harness/storage.ex, apps/harness/priv/repo/migrations/*, apps/harness/test/harness/storage_test.exs
New DB table and GenServer APIs: insert_pending_request/1, resolve_pending_request/1, get_pending_requests/1; migrations, SQL helpers, and comprehensive unit tests including idempotency, filtering, and reset behavior.
SnapshotServer recovery & persistence
apps/harness/lib/harness/snapshot_server.ex
On startup, loads pending rows (get_pending_requests/0) and merges into in-memory sessions (merge_pending_into_sessions/2); persists pending request inserts/deletes when events are stored via persist_pending_request/1.
Projector notification handling
apps/harness/lib/harness/projector.ex
Notification event matcher expanded to treat both "request/resolved" and "user-input/resolved" as resolution events and remove corresponding request_id from session pending_requests.
Provider sessions: codex, cursor, opencode
apps/harness/lib/harness/providers/codex_session.ex, .../cursor_session.ex, .../opencode_session.ex
Emit structured notifications (request/resolved, user-input/resolved) when resolving approvals/user-input; codex version check refactored to async Task + Task.yield/2; added termination helpers to cancel/emit resolution for pending approvals/permissions.
Dev tooling & inspection
apps/harness/lib/harness/dev/inspect.ex
Expose pending request stats via bridge/0 by calling Harness.Storage.get_pending_requests/0 and computing aggregates with best-effort error handling.
E2E & repo hygiene
.gitignore, apps/web/e2e/helpers.ts, apps/web/e2e-results/.last-run.json
Added demo artifacts to .gitignore; increased E2E assistant-response visibility timeout from 10s to 30s; removed .last-run.json contents.
Tests & integrations
apps/harness/test/..., (SnapshotServer integration tests extended)
Expanded integration tests verifying pending-request persistence across SnapshotServer restart and clearing via user-input/resolved notifications; Storage process teardown made best-effort.

Sequence Diagram

sequenceDiagram
    participant Client
    participant SnapshotServer as SnapshotServer<br/>(Harness)
    participant Storage as Storage<br/>(SQLite)
    participant Projector
    
    Note over Client,Storage: Request persisted and tracked
    Client->>SnapshotServer: send :request event
    SnapshotServer->>Storage: insert_pending_request(request)
    Storage-->>SnapshotServer: confirm stored
    SnapshotServer->>Projector: project event
    
    Note over Client,Projector: Resolution via notification
    Client->>SnapshotServer: send :notification (method: request/resolved or user-input/resolved)
    SnapshotServer->>Projector: deliver notification
    Projector->>Storage: resolve_pending_request(request_id)
    Storage-->>Projector: confirm deleted
    
    Note over SnapshotServer,Storage: Recovery on startup
    SnapshotServer->>Storage: get_pending_requests()
    Storage-->>SnapshotServer: unresolved rows
    SnapshotServer->>SnapshotServer: merge_pending_into_sessions()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

size:XL

Poem

🐰
I hopped through rows both new and old,
Kept pending requests from getting cold.
Notifications settle, neat and sound,
Recovery finds what once was bound.
Hooray — persisted carrots all around! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'feat(harness): SQLite pending requests for crash-durable approvals' clearly and concisely summarizes the main change—adding SQLite persistence for pending approval requests to survive BEAM restarts.
Description check ✅ Passed The PR description comprehensively covers what changed, why it was needed, design decisions, files affected, and test results, exceeding the template requirements despite the template being minimal.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sqlite-pending-requests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/harness/test/harness/storage_test.exs (1)

394-394: Replace Process.sleep/1 with :sys.get_state/1 for synchronization.

Per coding guidelines, use _ = :sys.get_state(SnapshotServer) to synchronize after casts instead of Process.sleep/1. This is more reliable and doesn't introduce arbitrary delays.

♻️ Proposed fix
-      Process.sleep(50)
+      # Synchronize: ensure SnapshotServer has processed all prior casts
+      _ = :sys.get_state(SnapshotServer)

Apply the same change to lines 453 and 469 in this test, and lines 338 and 494 in the other tests.

As per coding guidelines: "In tests, use _ = :sys.get_state/1 to ensure the process has handled prior messages instead of sleeping to synchronize before the next call"

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/harness/test/harness/storage_test.exs` at line 394, Replace the
arbitrary sleep with a synchronous state-read: wherever the test calls
Process.sleep(50) after sending casts to SnapshotServer, remove the sleep and
add _ = :sys.get_state(SnapshotServer) to ensure the process has handled prior
messages; apply the same replacement for the other occurrences of
Process.sleep/1 in the related tests so every synchronization after
SnapshotServer casts uses _ = :sys.get_state(SnapshotServer).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/harness/lib/harness/providers/codex_session.ex`:
- Around line 479-506: Handle Task exit tuples and make timeout/shutdown
explicit: call result = Task.yield(task, `@version_check_timeout_ms`), if result
is nil then call shutdown = Task.shutdown(task) and use that value for matching,
and add a new case clause {:exit, reason} -> {:error, "Codex CLI version check
task exited: #{inspect(reason)}"} so crash paths are handled; keep existing
clauses for {:ok, {:rescue, %ErlangError{original: :enoent}}}, {:ok, {:rescue,
e}}, {:ok, {output, 0}} (which uses parse_codex_version/1, version_supported?/1,
format_upgrade_message/1), {:ok, {output, code}} and nil -> {:error, "Codex CLI
version check timed out after #{`@version_check_timeout_ms`}ms"}, but ensure you
match the explicit shutdown result instead of relying on the || fallback and
reference binary_path in the enoent error message as before.

---

Nitpick comments:
In `@apps/harness/test/harness/storage_test.exs`:
- Line 394: Replace the arbitrary sleep with a synchronous state-read: wherever
the test calls Process.sleep(50) after sending casts to SnapshotServer, remove
the sleep and add _ = :sys.get_state(SnapshotServer) to ensure the process has
handled prior messages; apply the same replacement for the other occurrences of
Process.sleep/1 in the related tests so every synchronization after
SnapshotServer casts uses _ = :sys.get_state(SnapshotServer).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fe41481e-e5d3-4012-8a94-ec82bff010c1

📥 Commits

Reviewing files that changed from the base of the PR and between 40b6f0b and b56ebcd.

⛔ Files ignored due to path filters (40)
  • apps/web/e2e-results/Claude-01-provider-selected.png is excluded by !**/*.png
  • apps/web/e2e-results/Claude-02-message-sent.png is excluded by !**/*.png
  • apps/web/e2e-results/Claude-03-response-received.png is excluded by !**/*.png
  • apps/web/e2e-results/Claude-04-picker-verified.png is excluded by !**/*.png
  • apps/web/e2e-results/Codex-01-provider-selected.png is excluded by !**/*.png
  • apps/web/e2e-results/Codex-02-message-sent.png is excluded by !**/*.png
  • apps/web/e2e-results/Codex-03-response-received.png is excluded by !**/*.png
  • apps/web/e2e-results/Codex-04-picker-verified.png is excluded by !**/*.png
  • apps/web/e2e-results/Cursor-01-provider-selected.png is excluded by !**/*.png
  • apps/web/e2e-results/Cursor-02-message-sent.png is excluded by !**/*.png
  • apps/web/e2e-results/Cursor-03-response-received.png is excluded by !**/*.png
  • apps/web/e2e-results/Cursor-04-picker-verified.png is excluded by !**/*.png
  • apps/web/e2e-results/OpenCode-01-provider-selected.png is excluded by !**/*.png
  • apps/web/e2e-results/OpenCode-02-message-sent.png is excluded by !**/*.png
  • apps/web/e2e-results/OpenCode-03-response-received.png is excluded by !**/*.png
  • apps/web/e2e-results/OpenCode-04-picker-verified.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-Claude--44696-ssage-and-receives-response-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-Claude--44696-ssage-and-receives-response-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-Claude--44696-ssage-and-receives-response-chromium/video.webm is excluded by !**/*.webm
  • apps/web/e2e-results/providers-Provider-Claude--47a32-r-in-picker-after-selection-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-Claude--47a32-r-in-picker-after-selection-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-Claude--47a32-r-in-picker-after-selection-chromium/video.webm is excluded by !**/*.webm
  • apps/web/e2e-results/providers-Provider-Codex-s-7ddf5-r-in-picker-after-selection-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-Codex-s-7ddf5-r-in-picker-after-selection-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-Codex-s-7ddf5-r-in-picker-after-selection-chromium/video.webm is excluded by !**/*.webm
  • apps/web/e2e-results/providers-Provider-Codex-sends-message-and-receives-response-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-Codex-sends-message-and-receives-response-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-Codex-sends-message-and-receives-response-chromium/video.webm is excluded by !**/*.webm
  • apps/web/e2e-results/providers-Provider-Cursor--13dc4-ssage-and-receives-response-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-Cursor--13dc4-ssage-and-receives-response-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-Cursor--13dc4-ssage-and-receives-response-chromium/video.webm is excluded by !**/*.webm
  • apps/web/e2e-results/providers-Provider-Cursor--1f025-r-in-picker-after-selection-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-Cursor--1f025-r-in-picker-after-selection-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-Cursor--1f025-r-in-picker-after-selection-chromium/video.webm is excluded by !**/*.webm
  • apps/web/e2e-results/providers-Provider-OpenCod-ba8ae-r-in-picker-after-selection-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-OpenCod-ba8ae-r-in-picker-after-selection-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-OpenCod-ba8ae-r-in-picker-after-selection-chromium/video.webm is excluded by !**/*.webm
  • apps/web/e2e-results/providers-Provider-OpenCod-d31f9-ssage-and-receives-response-chromium/test-finished-1.png is excluded by !**/*.png
  • apps/web/e2e-results/providers-Provider-OpenCod-d31f9-ssage-and-receives-response-chromium/trace.zip is excluded by !**/*.zip
  • apps/web/e2e-results/providers-Provider-OpenCod-d31f9-ssage-and-receives-response-chromium/video.webm is excluded by !**/*.webm
📒 Files selected for processing (8)
  • .gitignore
  • apps/harness/lib/harness/projector.ex
  • apps/harness/lib/harness/providers/codex_session.ex
  • apps/harness/lib/harness/snapshot_server.ex
  • apps/harness/lib/harness/storage.ex
  • apps/harness/test/harness/storage_test.exs
  • apps/web/e2e-results/.last-run.json
  • apps/web/e2e/helpers.ts
💤 Files with no reviewable changes (1)
  • apps/web/e2e-results/.last-run.json

Comment thread apps/harness/lib/harness/providers/codex_session.ex Outdated
ranvier2d2 and others added 2 commits March 25, 2026 18:00
…bility

Adopt ADR recommendations for provider lifecycle normalization:

- CodexSession: emit `request/resolved` and `user-input/resolved`
  on approval/user-input response (was silently mutating local state)
- CodexSession: emit cancellation events in `reject_all_pending/2`
  on shutdown (was only replying to callers, leaving SQLite rows orphaned)
- CursorSession: add `cancel_all_pending/1` in terminate callback
- OpenCodeSession: add `cancel_all_pending/1` in terminate callback
  (distinguishes approvals vs user-inputs via question_id presence)
- Dev.Inspect: add `pending_requests` stats to bridge diagnostics
  (total count, by_provider, by_type, oldest_created_at)
- Fix flaky test teardown race in storage_test.exs

All 4 providers now emit canonical lifecycle events:
  request/opened → request/resolved | user-input/resolved

48 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CodexSession.assert_codex_version: separate Task.yield from
  Task.shutdown for explicit control; add {:exit, reason} clause
  for crash paths
- storage_test.exs: replace Process.sleep(50) with
  :sys.get_state(SnapshotServer) for deterministic synchronization
  after casts (tests now ~10x faster)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ranvier2d2
ranvier2d2 merged commit 6af5818 into main Mar 25, 2026
7 checks passed
ranvier2d2 added a commit that referenced this pull request Mar 30, 2026
…handler

- Guard against double subscription: lease_and_subscribe and
  subscribe_initial check if thread_id is already in subscribers
  before creating a new monitor, preventing ref count leak from
  duplicate monitors (#5).

- Handle :runtime_sse_degraded in session: emit session/degraded
  event when runtime SSE reconnect is exhausted, instead of silently
  dropping the message (#8).

- Add Logger.debug to event_relevant? catch-all so unrecognized SSE
  event shapes are logged instead of silently dropped (#10).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ranvier2d2 added a commit that referenced this pull request Mar 30, 2026
…handler

- Guard against double subscription: lease_and_subscribe and
  subscribe_initial check if thread_id is already in subscribers
  before creating a new monitor, preventing ref count leak from
  duplicate monitors (#5).

- Handle :runtime_sse_degraded in session: emit session/degraded
  event when runtime SSE reconnect is exhausted, instead of silently
  dropping the message (#8).

- Add Logger.debug to event_relevant? catch-all so unrecognized SSE
  event shapes are logged instead of silently dropped (#10).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ranvier2d2 added a commit that referenced this pull request Mar 30, 2026
…handler

- Guard against double subscription: lease_and_subscribe and
  subscribe_initial check if thread_id is already in subscribers
  before creating a new monitor, preventing ref count leak from
  duplicate monitors (#5).

- Handle :runtime_sse_degraded in session: emit session/degraded
  event when runtime SSE reconnect is exhausted, instead of silently
  dropping the message (#8).

- Add Logger.debug to event_relevant? catch-all so unrecognized SSE
  event shapes are logged instead of silently dropped (#10).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant