feat(harness): SQLite pending requests for crash-durable approvals - #10
Conversation
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>
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds 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
Sequence DiagramsequenceDiagram
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()
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
apps/harness/test/harness/storage_test.exs (1)
394-394: ReplaceProcess.sleep/1with:sys.get_state/1for synchronization.Per coding guidelines, use
_ = :sys.get_state(SnapshotServer)to synchronize after casts instead ofProcess.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/1to 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
⛔ Files ignored due to path filters (40)
apps/web/e2e-results/Claude-01-provider-selected.pngis excluded by!**/*.pngapps/web/e2e-results/Claude-02-message-sent.pngis excluded by!**/*.pngapps/web/e2e-results/Claude-03-response-received.pngis excluded by!**/*.pngapps/web/e2e-results/Claude-04-picker-verified.pngis excluded by!**/*.pngapps/web/e2e-results/Codex-01-provider-selected.pngis excluded by!**/*.pngapps/web/e2e-results/Codex-02-message-sent.pngis excluded by!**/*.pngapps/web/e2e-results/Codex-03-response-received.pngis excluded by!**/*.pngapps/web/e2e-results/Codex-04-picker-verified.pngis excluded by!**/*.pngapps/web/e2e-results/Cursor-01-provider-selected.pngis excluded by!**/*.pngapps/web/e2e-results/Cursor-02-message-sent.pngis excluded by!**/*.pngapps/web/e2e-results/Cursor-03-response-received.pngis excluded by!**/*.pngapps/web/e2e-results/Cursor-04-picker-verified.pngis excluded by!**/*.pngapps/web/e2e-results/OpenCode-01-provider-selected.pngis excluded by!**/*.pngapps/web/e2e-results/OpenCode-02-message-sent.pngis excluded by!**/*.pngapps/web/e2e-results/OpenCode-03-response-received.pngis excluded by!**/*.pngapps/web/e2e-results/OpenCode-04-picker-verified.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-Claude--44696-ssage-and-receives-response-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-Claude--44696-ssage-and-receives-response-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-Claude--44696-ssage-and-receives-response-chromium/video.webmis excluded by!**/*.webmapps/web/e2e-results/providers-Provider-Claude--47a32-r-in-picker-after-selection-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-Claude--47a32-r-in-picker-after-selection-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-Claude--47a32-r-in-picker-after-selection-chromium/video.webmis excluded by!**/*.webmapps/web/e2e-results/providers-Provider-Codex-s-7ddf5-r-in-picker-after-selection-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-Codex-s-7ddf5-r-in-picker-after-selection-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-Codex-s-7ddf5-r-in-picker-after-selection-chromium/video.webmis excluded by!**/*.webmapps/web/e2e-results/providers-Provider-Codex-sends-message-and-receives-response-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-Codex-sends-message-and-receives-response-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-Codex-sends-message-and-receives-response-chromium/video.webmis excluded by!**/*.webmapps/web/e2e-results/providers-Provider-Cursor--13dc4-ssage-and-receives-response-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-Cursor--13dc4-ssage-and-receives-response-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-Cursor--13dc4-ssage-and-receives-response-chromium/video.webmis excluded by!**/*.webmapps/web/e2e-results/providers-Provider-Cursor--1f025-r-in-picker-after-selection-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-Cursor--1f025-r-in-picker-after-selection-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-Cursor--1f025-r-in-picker-after-selection-chromium/video.webmis excluded by!**/*.webmapps/web/e2e-results/providers-Provider-OpenCod-ba8ae-r-in-picker-after-selection-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-OpenCod-ba8ae-r-in-picker-after-selection-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-OpenCod-ba8ae-r-in-picker-after-selection-chromium/video.webmis excluded by!**/*.webmapps/web/e2e-results/providers-Provider-OpenCod-d31f9-ssage-and-receives-response-chromium/test-finished-1.pngis excluded by!**/*.pngapps/web/e2e-results/providers-Provider-OpenCod-d31f9-ssage-and-receives-response-chromium/trace.zipis excluded by!**/*.zipapps/web/e2e-results/providers-Provider-OpenCod-d31f9-ssage-and-receives-response-chromium/video.webmis excluded by!**/*.webm
📒 Files selected for processing (8)
.gitignoreapps/harness/lib/harness/projector.exapps/harness/lib/harness/providers/codex_session.exapps/harness/lib/harness/snapshot_server.exapps/harness/lib/harness/storage.exapps/harness/test/harness/storage_test.exsapps/web/e2e-results/.last-run.jsonapps/web/e2e/helpers.ts
💤 Files with no reviewable changes (1)
- apps/web/e2e-results/.last-run.json
…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>
…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>
…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>
…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>
Summary
harness_pending_requestsSQLite table so in-flight approvals and elicitations survive BEAM restartsrequest/resolvedANDuser-input/resolved(Claude/OpenCode emit the latter for elicitations)CodexSession.assert_codex_version—System.cmddoesn't support:timeout, replaced withTask.async+Task.yieldDesign decisions
harness_pending_requests) instead of two separate tables for approvals vs elicitations — they share the same event lifecycle (request/opened→request/resolved|user-input/resolved)harness_sessionswhich may be stale)Files changed
storage.exprojector.exuser-input/resolvedalongsiderequest/resolvedsnapshot_server.expersist_pending_request/1+merge_pending_into_sessions/2for recoverycodex_session.exSystem.cmdtimeout →Task.async+Task.yieldstorage_test.exshelpers.ts.gitignoreTest plan
user-input/resolvedcorrectly clears pending rowsreset!clears pending table🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Chores