Skip to content

feat(harness): SQLite bindings table for thread resume#12

Merged
ranvier2d2 merged 4 commits intomainfrom
feat/sqlite-bindings-table
Mar 25, 2026
Merged

feat(harness): SQLite bindings table for thread resume#12
ranvier2d2 merged 4 commits intomainfrom
feat/sqlite-bindings-table

Conversation

@ranvier2d2
Copy link
Collaborator

@ranvier2d2 ranvier2d2 commented Mar 25, 2026

Summary

  • Adds harness_bindings table to persist opaque resume cursors (resume_cursor_json) across session close, enabling Codex thread/resume without full conversation replay
  • Integrates with CodexSession: upserts binding after thread/start or thread/resume succeeds, deletes stale binding on any resume failure before retry (prevents livelock from permanently invalid cursors)
  • 8 new unit tests covering CRUD, idempotent delete, nil cursor, multi-provider coexistence, and reset! cleanup

Design decisions

  • Bindings survive session close — that's the whole point; a closed session with a valid cursor is the state we want to resume from
  • resume_cursor_json is opaque pass-through — harness never parses it, just round-trips the JSON blob the adapter produces
  • DELETE on all resume failures (recoverable and non-recoverable) before any retry — if resume failed, the cursor is suspect. Recoverable path falls back to thread/start which upserts a fresh binding on success
  • Timestamps as TEXT (ISO 8601) — matches every other table in storage.ex
  • created_at column added for future stale-binding cleanup queries

Meiotic review findings incorporated

  • Livelock prevention (critical): stale binding + non-recoverable error = infinite resume-fail loop. Fixed by deleting binding before retry
  • reset! updated to include DELETE FROM harness_bindings for test isolation
  • get_binding/1 returns provider so callers can verify provider match before using cursor

Test plan

  • mix test test/harness/storage_test.exs — 31/31 pass (8 new binding tests)
  • mix compile --warnings-as-errors — clean
  • Full unit test suite — 48/48 pass
  • Manual: start Codex session → verify binding row created → close session → restart → verify thread/resume uses persisted cursor

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Session persistence: thread session cursors and provider info are now durably stored on start/resume for more reliable resumption.
    • Storage APIs: added persistent binding storage (upsert/get/delete) and DB table support for bindings.
  • Bug Fixes
    • Improved recovery: failed resume attempts now remove stale bindings and apply safer fallback logic.
  • Tests
    • Added tests for upsert/get/delete, provider isolation, idempotent delete, and reset behavior.

Open with Devin

Add harness_bindings table to persist opaque resume cursors across
session close. Codex thread IDs survive BEAM restarts, enabling
thread/resume without full conversation replay.

Key design decisions:
- Bindings survive session close (that's the point — resume from closed sessions)
- resume_cursor_json is opaque pass-through — harness never parses it
- DELETE on any resume failure (before retry) prevents livelock from stale cursors
- created_at/updated_at as TEXT (ISO 8601) for consistency with existing schema

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

coderabbitai bot commented Mar 25, 2026

Warning

Rate limit exceeded

@ranvier2d2 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 47 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: f2cd9ab1-1638-4189-a8fb-ee032fc91acf

📥 Commits

Reviewing files that changed from the base of the PR and between 4b79399 and 467af49.

📒 Files selected for processing (1)
  • README.md
📝 Walkthrough

Walkthrough

Persists Codex thread resume cursors as bindings: storage gains binding CRUD and a DB table; Codex session upserts bindings after successful thread/start/thread/resume and deletes bindings on thread/resume RPC errors before fallback or failure handling.

Changes

Cohort / File(s) Summary
Storage binding persistence
apps/harness/lib/harness/storage.ex, priv/repo/migrations/*
Adds upsert_binding/3, get_binding/1, delete_binding/1 GenServer APIs and handlers; DB helpers create harness_bindings table and implement UPSERT/SELECT/DELETE; reset!/0 cleared to remove bindings.
Codex session integration
apps/harness/lib/harness/providers/codex_session.ex
Adds private persist_binding/1 to encode cursor and call Harness.Storage.upsert_binding/3; calls added after handling thread/start and successful thread/resume; on thread/resume RPC errors deletes existing binding before fallback/fail logic.
Storage binding tests
apps/harness/test/harness/storage_test.exs
Adds tests covering upsert_binding/3, get_binding/1, delete_binding/1, nil cursor persistence, idempotent delete, reset!/0 clearing bindings, and provider isolation.

Sequence Diagram

sequenceDiagram
    actor "Codex RPC"
    participant "Codex Session"
    participant "Harness.Storage"
    participant "SQLite DB"

    "Codex RPC"->>"Codex Session": thread/start or thread/resume (success)
    "Codex Session"->>"Codex Session": persist_binding() — encode cursor
    "Codex Session"->>"Harness.Storage": upsert_binding(thread_id, provider, cursor_json)
    "Harness.Storage"->>"SQLite DB": INSERT ... ON CONFLICT(thread_id) DO UPDATE
    "SQLite DB"-->>"Harness.Storage": OK
    "Harness.Storage"-->>"Codex Session": OK

    alt resume RPC error
        "Codex RPC"-->>"Codex Session": thread/resume error
        "Codex Session"->>"Harness.Storage": delete_binding(thread_id)
        "Harness.Storage"->>"SQLite DB": DELETE FROM harness_bindings WHERE thread_id=...
        "SQLite DB"-->>"Harness.Storage": OK
        "Harness.Storage"-->>"Codex Session": OK
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibbled cursors, tucked them neat and warm,
Threads may wander, but bindings keep them from harm,
Start, resume, or hiccup — the warren knows the way,
Small hops, safe state, ready for the play. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.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 title clearly summarizes the main change: adding a SQLite bindings table to persist thread resume cursors.
Description check ✅ Passed The description comprehensively covers what changed, why, and includes design decisions, test results, and a test plan—exceeding the basic template requirements.

✏️ 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-bindings-table

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
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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/storage.ex`:
- Around line 72-75: Update the `@doc` for get_binding to match the actual return
value (a map) instead of a tuple: state that get_binding(thread_id) returns a
map like %{thread_id: tid, provider: provider, resume_cursor_json: cursor_json};
locate the doc above the get_binding/1 function and do_get_binding/2 to ensure
both descriptions match the implementation and tests expecting atom-keyed map.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 17e78a57-a4ff-4b17-b04d-ae309e221123

📥 Commits

Reviewing files that changed from the base of the PR and between 6610011 and 8784724.

📒 Files selected for processing (3)
  • apps/harness/lib/harness/providers/codex_session.ex
  • apps/harness/lib/harness/storage.ex
  • apps/harness/test/harness/storage_test.exs

Copy link

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 3 additional findings in Devin Review.

Open in Devin Review

GenServer.call(__MODULE__, {:upsert_binding, thread_id, provider, resume_cursor_json})
end

@doc "Get the binding for a thread. Returns {thread_id, provider, resume_cursor_json} or nil."

Choose a reason for hiding this comment

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

🟡 get_binding @doc claims tuple return type but implementation returns a map

The @doc on get_binding/1 at apps/harness/lib/harness/storage.ex:72 states Returns {thread_id, provider, resume_cursor_json} or nil, which in Elixir denotes a 3-element tuple. However, the actual implementation at apps/harness/lib/harness/storage.ex:478 returns %{thread_id: tid, provider: provider, resume_cursor_json: cursor_json} — a map. Any caller relying on the documented tuple contract (e.g., pattern matching with {tid, provider, cursor}) would crash with a MatchError at runtime.

Suggested change
@doc "Get the binding for a thread. Returns {thread_id, provider, resume_cursor_json} or nil."
@doc "Get the binding for a thread. Returns %{thread_id, provider, resume_cursor_json} map or nil."
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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/storage.ex`:
- Around line 271-273: The harness_bindings table and binding operations
currently use thread_id as the sole primary key so one provider can overwrite
another; update the schema to use a composite primary key (thread_id, provider)
and adjust all binding operations (the upsert path and the lookup/delete paths
referenced around lines 451-460 and 474-485) to include provider in
WHERE/DELETE/INSERT/UPSERT logic so bindings are provider-scoped; ensure the
column names (thread_id, provider) and table name (harness_bindings) are used
consistently and any existing queries/functions that fetch or remove bindings
accept and pass the provider parameter.
- Around line 77-79: The docstring for delete_binding is outdated: it says
deletion is only for non-recoverable resume failures but
Harness.Providers.CodexSession.handle_rpc_error/3 deletes bindings for both
recoverable and non-recoverable thread/resume failures. Update the comment(s)
(the `@doc` for delete_binding and the other comment near the second mention) to
state that delete_binding/1 is invoked when a thread resume fails (both
recoverable and non-recoverable cases) and briefly note that callers such as
Harness.Providers.CodexSession.handle_rpc_error/3 may call it for either outcome
so the documentation matches runtime behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b9cd80fe-5ea5-4484-856d-1b8946e2b641

📥 Commits

Reviewing files that changed from the base of the PR and between 8784724 and daf6aab.

📒 Files selected for processing (1)
  • apps/harness/lib/harness/storage.ex

Comment on lines +271 to +273
CREATE TABLE IF NOT EXISTS harness_bindings (
thread_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Binding keying currently breaks multi-provider coexistence.

With thread_id as the sole key (Line 272) and lookup/delete keyed only by thread_id (Lines 474-485), one provider’s upsert will overwrite another provider’s cursor for the same thread. That conflicts with the PR objective to support multi-provider coexistence.

💡 Proposed fix (provider-scoped bindings)
-  `@doc` "Get the binding for a thread. Returns %{thread_id, provider, resume_cursor_json} or nil."
-  def get_binding(thread_id) do
-    GenServer.call(__MODULE__, {:get_binding, thread_id})
+  `@doc` "Get the binding for a thread+provider. Returns %{thread_id, provider, resume_cursor_json} or nil."
+  def get_binding(thread_id, provider) do
+    GenServer.call(__MODULE__, {:get_binding, thread_id, provider})
   end

-  `@doc` "Delete a binding. Called when resume fails non-recoverably (before fresh start)."
-  def delete_binding(thread_id) do
-    GenServer.call(__MODULE__, {:delete_binding, thread_id})
+  `@doc` "Delete a binding for a thread+provider."
+  def delete_binding(thread_id, provider) do
+    GenServer.call(__MODULE__, {:delete_binding, thread_id, provider})
   end
-  def handle_call({:get_binding, thread_id}, _from, %{conn: conn} = state) do
-    result = do_get_binding(conn, thread_id)
+  def handle_call({:get_binding, thread_id, provider}, _from, %{conn: conn} = state) do
+    result = do_get_binding(conn, thread_id, provider)
     {:reply, result, state}
   end

-  def handle_call({:delete_binding, thread_id}, _from, %{conn: conn} = state) do
-    result = do_delete_binding(conn, thread_id)
+  def handle_call({:delete_binding, thread_id, provider}, _from, %{conn: conn} = state) do
+    result = do_delete_binding(conn, thread_id, provider)
     {:reply, result, state}
   end
-    CREATE TABLE IF NOT EXISTS harness_bindings (
-      thread_id TEXT PRIMARY KEY,
+    CREATE TABLE IF NOT EXISTS harness_bindings (
+      thread_id TEXT NOT NULL,
       provider TEXT NOT NULL,
       resume_cursor_json TEXT,
       created_at TEXT NOT NULL,
-      updated_at TEXT NOT NULL
+      updated_at TEXT NOT NULL,
+      PRIMARY KEY (thread_id, provider)
     )
-    ON CONFLICT(thread_id) DO UPDATE SET
+    ON CONFLICT(thread_id, provider) DO UPDATE SET
       provider = excluded.provider,
       resume_cursor_json = excluded.resume_cursor_json,
       updated_at = excluded.updated_at
-  defp do_get_binding(conn, thread_id) do
-    sql = "SELECT thread_id, provider, resume_cursor_json FROM harness_bindings WHERE thread_id = ?1"
+  defp do_get_binding(conn, thread_id, provider) do
+    sql = """
+    SELECT thread_id, provider, resume_cursor_json
+    FROM harness_bindings
+    WHERE thread_id = ?1 AND provider = ?2
+    """
-    case query_one(conn, sql, [thread_id]) do
+    case query_one(conn, sql, [thread_id, provider]) do
       [tid, provider, cursor_json] -> %{thread_id: tid, provider: provider, resume_cursor_json: cursor_json}
       nil -> nil
     end
   end

-  defp do_delete_binding(conn, thread_id) do
-    sql = "DELETE FROM harness_bindings WHERE thread_id = ?1"
+  defp do_delete_binding(conn, thread_id, provider) do
+    sql = "DELETE FROM harness_bindings WHERE thread_id = ?1 AND provider = ?2"
...
-      :ok = Sqlite3.bind(stmt, [thread_id])
+      :ok = Sqlite3.bind(stmt, [thread_id, provider])

Also applies to: 451-460, 474-485

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

In `@apps/harness/lib/harness/storage.ex` around lines 271 - 273, The
harness_bindings table and binding operations currently use thread_id as the
sole primary key so one provider can overwrite another; update the schema to use
a composite primary key (thread_id, provider) and adjust all binding operations
(the upsert path and the lookup/delete paths referenced around lines 451-460 and
474-485) to include provider in WHERE/DELETE/INSERT/UPSERT logic so bindings are
provider-scoped; ensure the column names (thread_id, provider) and table name
(harness_bindings) are used consistently and any existing queries/functions that
fetch or remove bindings accept and pass the provider parameter.

ranvier2d2 and others added 2 commits March 25, 2026 19:56
delete_binding is called on all resume failures (recoverable and
non-recoverable), not just non-recoverable ones. Update @doc and
migration comment to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ranvier2d2 ranvier2d2 merged commit 69acad4 into main Mar 25, 2026
7 checks passed
ranvier2d2 added a commit that referenced this pull request Mar 26, 2026
SessionManager now reads Storage.get_binding on session start and
injects resumeCursor into params when the provider matches. Completes
the bindings table feature: write path (PR #12) + read path (this).

Provider mismatch check prevents injecting a Codex cursor into a
Cursor session. Caller-supplied resumeCursor is never overwritten.

7 new tests including multi-session concurrent and cross-provider.

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