Skip to content

feat: bindings persistence, reactor concurrency, thread visibility - #14

Merged
ranvier2d2 merged 8 commits into
mainfrom
feat/session11-bindings-concurrency-threads
Mar 26, 2026
Merged

feat: bindings persistence, reactor concurrency, thread visibility#14
ranvier2d2 merged 8 commits into
mainfrom
feat/session11-bindings-concurrency-threads

Conversation

@ranvier2d2

@ranvier2d2 ranvier2d2 commented Mar 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • P2 — Cursor/OpenCode bindings: Added persist_binding to cursor_session.ex and opencode_session.ex so resume works for all harness providers (not just Codex). Fixed normalize_resume_cursor to re-encode Cursor/OpenCode cursors as JSON strings — their session modules call Jason.decode on resumeCursor, so passing a decoded map caused silent failures.

  • P0 — Reactor concurrency: Added optional concurrency parameter to DrainableWorker (default 1). Set to 8 in ProviderCommandReactor so independent thread operations (session starts, turn dispatches) run in parallel. Fixes the 12-session resume Phase 3 timeout where sequential session starts exceeded the 120s deadline.

  • P1 — Thread visibility: Stress test now reuses bootstrapProjectId from the welcome payload instead of creating a separate project. Threads persist under the main sidebar project across page refreshes.

Test plan

  • mix test test/harness/storage_test.exs — 38/38 (binding normalization)
  • bun vitest run packages/shared/src/DrainableWorker.test.ts — 2/2 (serial + concurrent)
  • bun vitest run apps/server/src/orchestration — 103/103
  • bun turbo run typecheck --filter=@t3tools/shared --filter=t3 — 4/4 clean
  • bun run scripts/stress-test-resume-multi.ts multi — validate 4-provider resume E2E

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Persist resume cursors immediately when sessions report ready and normalize Cursor/OpenCode cursors as JSON-encoded strings for reliable resume behavior.
    • Made session-stop handling resilient to interruptions to avoid reactor termination.
  • New Features

    • Auto-approve certain non-user RPC requests in one runtime mode to reduce manual approvals.
  • Performance Improvements

    • Increased domain-event processing concurrency (configurable, up to 8).
  • Tests

    • Updated cursor tests, added worker concurrency test, and included an end-to-end multi-session resume stress script.

…thread visibility

P2: Add persist_binding to cursor_session.ex and opencode_session.ex so
resume works for all harness providers, not just Codex. Fix
normalize_resume_cursor to re-encode Cursor/OpenCode cursors as JSON
strings (their session modules call Jason.decode on the resumeCursor).

P0: Add concurrency option to DrainableWorker (default 1, set to 8 in
ProviderCommandReactor). Independent thread operations now run in
parallel — 12 concurrent session starts no longer timeout sequentially.

P1: Stress test reuses bootstrapProjectId from welcome payload instead
of creating a separate project, so threads persist in the main sidebar.

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

coderabbitai Bot commented Mar 26, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Persist resume bindings for Cursor and OpenCode sessions immediately after emitting "session/ready"; normalize Cursor/OpenCode resume cursors to JSON-encoded strings; make DrainableWorker support configurable concurrency and add tests plus a multi-session resume stress-test script; adjust server session-stop/interrupt handling and domain-event worker concurrency.

Changes

Cohort / File(s) Summary
Cursor & OpenCode providers
apps/harness/lib/harness/providers/cursor_session.ex, apps/harness/lib/harness/providers/opencode_session.ex
Call new private persist_binding/1 immediately after emitting "session/ready"; build provider-specific resume cursor JSON and call Harness.Storage.upsert_binding(state.thread_id, state.provider, cursor_json).
Session normalization
apps/harness/lib/harness/session_manager.ex
normalize_resume_cursor/1 updated to JSON-encode decoded Cursor/OpenCode cursor maps via Jason.encode!/1; Codex/raw-string behavior preserved.
Storage tests
apps/harness/test/harness/storage_test.exs
Adjusted test expectations: for cursor and opencode, expect resumeCursor as a JSON-encoded string (Jason.encode!) instead of a raw map; codex case unchanged.
Codex provider RPC handling
apps/harness/lib/harness/providers/codex_session.ex
Auto-approve certain non-user-input RPC requests when runtime_mode == "full-access" by returning {"decision"=>"approve"}, emitting request/resolved, and avoiding creation of pending entries.
Server orchestration reactor
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Run providerService.stopSession({ threadId }) as uninterruptible (ignore interruption propagation) and initialize domain-event worker with makeDrainableWorker(..., { concurrency: 8 }).
Provider adapter stop behavior
apps/server/src/provider/Layers/ClaudeAdapter.ts
Spawn stream interrupt as a child fiber (Fiber.interrupt(...).pipe(Effect.forkChild)) instead of joining inline to avoid interruption propagation.
DrainableWorker implementation
packages/shared/src/DrainableWorker.ts
makeDrainableWorker signature gains optional options.concurrency (default 1, clamped ≥1); spawns concurrency parallel consumer fibers instead of a single consumer loop enabling concurrent processing.
DrainableWorker tests
packages/shared/src/DrainableWorker.test.ts
Added live test verifying concurrent processing with concurrency: 4, asserting multiple items start processing concurrently.
E2E stress test
scripts/stress-test-resume-multi.ts
New Bun/TypeScript script performing multi-thread resume stress tests over WebSocket orchestration with configurable sessions, providers, and polling phases.

Sequence Diagram(s)

sequenceDiagram
    participant Provider as Provider Session
    participant Harness as Harness Runtime
    participant Storage as Storage Layer

    Provider->>Harness: emit "session/ready"
    Harness->>Provider: (ack)
    Provider->>Provider: persist_binding(state)
    Provider->>Provider: build_resume_cursor() -> cursor_json
    Provider->>Storage: upsert_binding(state.thread_id, state.provider, cursor_json)
    Storage-->>Provider: persist result
    Provider->>Harness: continue with turns
Loading
sequenceDiagram
    participant EventQueue as Event Queue
    participant Reactor as ProviderCommandReactor
    participant Worker as DrainableWorker (concurrency: 8)
    participant Handler as Event Handler

    EventQueue->>Reactor: domain events enqueued
    Reactor->>Worker: init worker with concurrency=8
    par up to 8 parallel consumers
        Worker->>Handler: consumer processes event
        Handler-->>Worker: processed
        Worker->>Worker: finishOne() tracking
    end
    Worker-->>Reactor: drained
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐇 I tucked the cursor snug and tight,

Saved every thread before the night,
Fibers wake in fours and eights,
Threads resume — hooray, resumes are great! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% 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 describes the main changes: bindings persistence for Cursor/OpenCode, reactor concurrency improvements, and thread visibility enhancements across multiple systems.
Description check ✅ Passed The description provides clear sections for 'Summary' and 'Test plan', covering what changed and the validation performed, though it deviates from the template structure by using custom sections instead of the template's 'What Changed', 'Why', and 'Checklist' format.

✏️ 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/session11-bindings-concurrency-threads

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:XL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Mar 26, 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 (2)
scripts/stress-test-resume-multi.ts (1)

429-430: Hardcoded sleep may cause flakiness.

The 5-second sleep after stopping sessions is a heuristic. Under load or on slower systems, sessions may take longer to fully stop, causing race conditions in phase 3.

Consider polling for session stopped state or increasing the timeout for scale mode.

💡 Alternative: poll for session stop confirmation
-  // Wait for sessions to fully stop
-  await sleep(5000);
+  // Wait for sessions to fully stop
+  const stopDeadline = Date.now() + 15_000;
+  while (Date.now() < stopDeadline) {
+    const allStopped = [...phase1Trackers.values()].every((t) => t.sessionStopped);
+    if (allStopped) break;
+    await sleep(500);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/stress-test-resume-multi.ts` around lines 429 - 430, Replace the
hardcoded await sleep(5000) with a deterministic wait that polls each session
until it's actually stopped (or a longer configurable timeout) to avoid
flakiness; implement or call a helper like waitForSessionsStopped(sessionIds,
timeout) or loop using getSessionState(sessionId) / isStopped() with short
backoff retries and a max timeout, and use that in place of the sleep in the
phase-3 shutdown logic so the test proceeds only after all sessions report
stopped.
apps/harness/lib/harness/providers/opencode_session.ex (1)

1216-1225: Consider whether storing port in the binding is useful for resume.

The stored port will be stale if OpenCode restarts or the OS reclaims it. Since resume typically involves spawning a fresh OpenCode server, the persisted port may not be reusable.

However, this doesn't cause harm — the port is simply ignored during resume (OpenCode session creates a new server on a new port). If the intent is purely for diagnostics or if there's a future use case for reconnecting to a still-running server, this is fine.

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

In `@apps/harness/lib/harness/providers/opencode_session.ex` around lines 1216 -
1225, persist_binding currently writes the ephemeral opencode server port into
the stored binding (cursor_json) which will be stale after server restarts and
is not used during resume; update the persist_binding function in
opencode_session.ex to stop persisting "port" (remove "port" from the JSON) or,
if you want to keep it for diagnostics only, add a clear comment and/or a
separate diagnostics field so resume logic ignores it; adjust
Harness.Storage.upsert_binding call accordingly and ensure any consumers of the
stored binding don't rely on the removed "port" value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/stress-test-resume-multi.ts`:
- Around line 155-164: The current handler for the "id === 'unknown'" schema
error only rejects the oldest pending promise (inside the block handling id ===
"unknown"), which can leave unrelated in-flight requests unresolved; update that
block to iterate over this.pending and reject every pending entry with a
descriptive Error(`Schema error: ${errMsg}`), then clear the map (or call
this.pending.clear()) so no promises remain outstanding; alternatively, if you
prefer to keep the heuristic, add a clear comment above the existing loop
explaining the limitation and why only the oldest request is rejected (but
prefer rejecting all in this stress-test script).

---

Nitpick comments:
In `@apps/harness/lib/harness/providers/opencode_session.ex`:
- Around line 1216-1225: persist_binding currently writes the ephemeral opencode
server port into the stored binding (cursor_json) which will be stale after
server restarts and is not used during resume; update the persist_binding
function in opencode_session.ex to stop persisting "port" (remove "port" from
the JSON) or, if you want to keep it for diagnostics only, add a clear comment
and/or a separate diagnostics field so resume logic ignores it; adjust
Harness.Storage.upsert_binding call accordingly and ensure any consumers of the
stored binding don't rely on the removed "port" value.

In `@scripts/stress-test-resume-multi.ts`:
- Around line 429-430: Replace the hardcoded await sleep(5000) with a
deterministic wait that polls each session until it's actually stopped (or a
longer configurable timeout) to avoid flakiness; implement or call a helper like
waitForSessionsStopped(sessionIds, timeout) or loop using
getSessionState(sessionId) / isStopped() with short backoff retries and a max
timeout, and use that in place of the sleep in the phase-3 shutdown logic so the
test proceeds only after all sessions report stopped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e82abce3-0abf-4070-a8ca-e9aec8aaa004

📥 Commits

Reviewing files that changed from the base of the PR and between 38e6708 and 324f426.

📒 Files selected for processing (8)
  • apps/harness/lib/harness/providers/cursor_session.ex
  • apps/harness/lib/harness/providers/opencode_session.ex
  • apps/harness/lib/harness/session_manager.ex
  • apps/harness/test/harness/storage_test.exs
  • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
  • packages/shared/src/DrainableWorker.test.ts
  • packages/shared/src/DrainableWorker.ts
  • scripts/stress-test-resume-multi.ts

Comment thread scripts/stress-test-resume-multi.ts
ranvier2d2 and others added 2 commits March 26, 2026 10:20
…inistic wait

- Reject all pending promises on schema error (id="unknown"), not just oldest
- Drop ephemeral port from OpenCode persist_binding (stale after restart)
- Replace sleep(5000) with deterministic wait polling sessionStopped tracker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
processSessionStopRequested calls providerService.stopSession() which
for Claude interrupts the stream fiber via Fiber.interrupt(). This
interruption propagated through processDomainEventSafely (which
re-throws interrupt-only causes) and killed the DrainableWorker and
the entire Node server.

Fix: wrap stopSession in Effect.uninterruptible + Effect.ignore so
the stop completes without propagating the fiber interrupt upward.

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

@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: 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/server/src/orchestration/Layers/ProviderCommandReactor.ts`:
- Around line 709-713: The stopSession call currently uses Effect.ignore which
hides real failures; replace the ignore with logic that captures the failure
cause from providerService.stopSession(threadId) and uses
Cause.hasInterruptsOnly to distinguish interrupt-only failures from real
failures: if Cause.hasInterruptsOnly(cause) then swallow/ignore, otherwise call
appendProviderFailureActivity with "provider.session.stop.failed" (include the
cause/details) and then rethrow or fail the effect so the non-interrupt failure
is not silently dropped and the subsequent unconditional marking of the thread
as "stopped" does not occur. Use providerService.stopSession,
Cause.hasInterruptsOnly, and appendProviderFailureActivity to implement this
behavior.
- Around line 780-784: The current worker created by
makeDrainableWorker(processDomainEventSafely, { concurrency: 8 }) allows events
for the same threadId to run concurrently and can race lifecycle operations;
modify processing to serialize per-threadId by sharding or mutexing: introduce a
per-thread queue/lock keyed by threadId (used by ProviderSessionDirectory and
ProviderService operations) and ensure processDomainEventSafely first acquires
the threadId shard or mutex before handling events and releases it after, or
replace the flat worker with a set of N shard workers where events are
dispatched to shard = hash(threadId) so each shard runs with single concurrency;
update references to makeDrainableWorker, processDomainEventSafely,
ProviderSessionDirectory, ProviderService, and threadId accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 88fab8ec-9ddb-4128-bb8c-2e320fe3f4bd

📥 Commits

Reviewing files that changed from the base of the PR and between 6749e23 and 9e67c33.

📒 Files selected for processing (1)
  • apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Comment on lines +709 to +713
// Run stop as uninterruptible to prevent Claude SDK fiber interruptions
// from propagating up and killing the reactor worker.
yield* providerService
.stopSession({ threadId: thread.id })
.pipe(Effect.uninterruptible, Effect.ignore);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify current stop handling in reactor
rg -n -C4 'stopSession\(\{ threadId: thread\.id \}\)|Effect\.uninterruptible|Effect\.ignore' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Verify stopSession side effects that should not be silently dropped
rg -n -C6 'const stopSession|adapter\.stopSession|directory\.remove|analytics\.record' \
  apps/server/src/provider/Layers/ProviderService.ts

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 6303


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if appendProviderFailureActivity exists and where it's used
rg -n 'appendProviderFailureActivity|hasInterruptsOnly' \
  apps/server/src/orchestration/ apps/server/src/provider/

# Check broader error handling in reactor for similar patterns
rg -n 'Effect\.catch|Effect\.tapError|Effect\.handle' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Verify thread session state setting
rg -n -A2 'setThreadSession' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | head -20

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1953


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Examine appendProviderFailureActivity definition and signature
rg -n -A15 'const appendProviderFailureActivity = ' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Check one of the existing error handling patterns using appendProviderFailureActivity + Effect.catchCause
rg -n -B2 -A8 'yield\* appendProviderFailureActivity\({' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | head -40

# Verify what happens after stopSession in the reactor
sed -n '708,725p' apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 848


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check existing error handling patterns with appendProviderFailureActivity
rg -n -B2 -A8 'appendProviderFailureActivity\({' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | head -60

# Verify what happens after stopSession in the reactor
sed -n '708,725p' apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Check if there are similar stop patterns elsewhere in the codebase
rg -n -B3 -A3 'Effect\.catchCause.*hasInterruptsOnly' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check existing error handling patterns with appendProviderFailureActivity
rg -n -B2 -A8 'appendProviderFailureActivity' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | head -80

# Verify what happens after stopSession in the reactor
sed -n '708,725p' apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Check if there are similar stop patterns elsewhere in the codebase
rg -n -B3 -A5 'hasInterruptsOnly' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 4711


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Compare the error handling pattern at line 770 with what's proposed
sed -n '767,780p' apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Check if Cause is imported from Effect
rg -n '^import.*Cause|from.*effect' apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | head -15

# Verify the stopSession function definition for exact side effects
sed -n '442,461p' apps/server/src/provider/Layers/ProviderService.ts

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1443


Don't ignore non-interrupt stopSession failures.

Effect.ignore hides real stop/cleanup failures (adapter stop, directory cleanup, analytics), but the thread session is unconditionally marked "stopped" afterward (line 716), risking provider/orchestration state desync.

The codebase already uses Cause.hasInterruptsOnly (line 770) to distinguish interrupt-only failures from real failures, and appendProviderFailureActivity already supports "provider.session.stop.failed" (line 175). Apply the same pattern here.

Proposed fix
-      yield* providerService
-        .stopSession({ threadId: thread.id })
-        .pipe(Effect.uninterruptible, Effect.ignore);
+      yield* providerService
+        .stopSession({ threadId: thread.id })
+        .pipe(
+          Effect.uninterruptible,
+          Effect.catchCause((cause) =>
+            Cause.hasInterruptsOnly(cause)
+              ? Effect.void
+              : appendProviderFailureActivity({
+                  threadId: thread.id,
+                  kind: "provider.session.stop.failed",
+                  summary: "Provider session stop failed",
+                  detail: Cause.pretty(cause),
+                  turnId: null,
+                  createdAt: now,
+                }).pipe(Effect.zipRight(Effect.failCause(cause))),
+          ),
+        );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` around lines
709 - 713, The stopSession call currently uses Effect.ignore which hides real
failures; replace the ignore with logic that captures the failure cause from
providerService.stopSession(threadId) and uses Cause.hasInterruptsOnly to
distinguish interrupt-only failures from real failures: if
Cause.hasInterruptsOnly(cause) then swallow/ignore, otherwise call
appendProviderFailureActivity with "provider.session.stop.failed" (include the
cause/details) and then rethrow or fail the effect so the non-interrupt failure
is not silently dropped and the subsequent unconditional marking of the thread
as "stopped" does not occur. Use providerService.stopSession,
Cause.hasInterruptsOnly, and appendProviderFailureActivity to implement this
behavior.

Comment on lines +780 to +784
// Allow up to 8 concurrent event processors so independent thread operations
// (session starts, turn dispatches) don't block each other. Events for the
// same thread are still safe: ensureSessionForThread and sendTurnForThread
// operate on per-thread state and the provider layer serializes per-session.
const worker = yield* makeDrainableWorker(processDomainEventSafely, { concurrency: 8 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect DrainableWorker ordering/concurrency semantics
rg -n -C5 'makeDrainableWorker|concurrency|enqueue|dequeue|Queue|take|drain|fork' \
  packages/shared/src/DrainableWorker.ts

# Inspect tests for any per-key/per-thread ordering guarantees
rg -n -C5 'concurrency|order|fifo|thread|enqueue' \
  packages/shared/src/DrainableWorker.test.ts

# Confirm all handled events here are thread-scoped (for safe sharding by threadId)
rg -n -C3 'type ProviderIntentEvent|threadId|thread\.' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 25720


🏁 Script executed:

# Check orchestrationEngine.dispatch for per-thread serialization
rg -n -C8 'dispatch.*function|dispatch.*Effect|orchestrationEngine\.dispatch' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts \
  | head -60

# Search for Mutex, Ref, or other synchronization per threadId
rg -n 'Mutex|Ref\.make.*threadId|Lock|Semaphore|serialization' \
  apps/server/src/orchestration/

# Check providerManager.ts for per-thread coordination
fd -t f providerManager.ts apps/server/src/ 2>/dev/null || echo "Searching..."
rg -l 'providerManager|ProviderManager' apps/server/src/orchestration/

# Look for how events are enqueued (single queue vs sharded)
rg -n -C5 'worker\.enqueue|selectWorker|enqueue.*event' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 2244


🏁 Script executed:

# Find orchestrationEngine implementation
fd -t f -e ts 'orchestrationEngine|Orchestration.*Engine' apps/server/src/ | head -20

# Directly check for files that define OrchestrationEngine
rg -l 'export.*OrchestrationEngine|class OrchestrationEngine|interface OrchestrationEngine' apps/server/src/

# Look at how ProviderCommandReactor is initialized and what orchestrationEngine is
rg -n -B10 'const.*orchestrationEngine' apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | head -30

# Search for providerManager in server src
fd -t f providerManager apps/server/src/

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1471


🏁 Script executed:

# Read OrchestrationEngine.ts Services implementation to check dispatch
cat -n apps/server/src/orchestration/Services/OrchestrationEngine.ts | head -200

# Search for dispatch definition and any per-threadId synchronization
rg -n -A15 'dispatch.*Effect|dispatch.*function' \
  apps/server/src/orchestration/Services/OrchestrationEngine.ts | head -80

# Search for Mutex, Ref, or Map patterns around threadId in OrchestrationEngine
rg -n 'Mutex|Ref\.make|Map.*threadId|per-thread|serialize' \
  apps/server/src/orchestration/Services/OrchestrationEngine.ts

# Find providerManager or providerService
fd -t f 'provider.*[Mm]anager|[Pp]rovider.*[Ss]ervice' apps/server/src/ | grep -E 'Service|Manager'

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 3549


🏁 Script executed:

# Find ProviderService or providerManager
rg -l 'providerService|ProviderService' apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Check the ProviderService implementation
cat -n apps/server/src/provider/Services/ProviderService.ts | head -150

# Search for Mutex/Ref/serialization around threadId in ProviderCommandReactor
rg -n -B5 -A5 'Mutex|Ref\.make|Map.*threadId' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

# Check if ensureSessionForThread is wrapped in Mutex or per-thread locking
rg -n -B3 -A20 'const ensureSessionForThread' \
  apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | head -50

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 5243


🏁 Script executed:

# Search for any reference to providerManager in codebase
rg -l 'providerManager|ProviderManager' apps/server/src/

# List all files in provider directory
find apps/server/src/provider -type f -name '*.ts' | head -20

# Check if there's a manager or coordinator anywhere
fd -t f 'manager|Manager|coordinator|Coordinator' apps/server/src/provider/ apps/server/src/orchestration/

# Check the guideline file path and content again
cat -n coding_guidelines || echo "No coding_guidelines file at root"

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 1315


🏁 Script executed:

# Read ProviderSessionDirectory to check per-session/thread locking
cat -n apps/server/src/provider/Layers/ProviderSessionDirectory.ts | head -200

# Search for Mutex or per-thread locking in ProviderSessionDirectory
rg -n 'Mutex|Ref\.make|Queue|serialize|lock|Lock' \
  apps/server/src/provider/Layers/ProviderSessionDirectory.ts | head -30

# Read ProviderService Layer implementation to see dispatch coordination
rg -n -A30 'export const.*make|Effect.gen.*function' \
  apps/server/src/provider/Layers/ProviderService.ts | head -100

Repository: Ranvier-Technologies/t3code-OTP

Length of output: 11508


Concurrency: 8 needs per-thread ordering guarantees to prevent lifecycle event races.

Same-thread events can execute concurrently and cause race conditions (e.g., stop vs turn-start). The ProviderSessionDirectory and ProviderService lack per-threadId serialization. Recommend sharding by threadId (single-concurrency per shard) or wrapping handlers in per-thread Mutex.

Safer pattern (sharded workers, per-thread serialization)
+  const EVENT_WORKER_COUNT = 8;
+  const workers = yield* Effect.all(
+    Array.from({ length: EVENT_WORKER_COUNT }, () =>
+      makeDrainableWorker(processDomainEventSafely, { concurrency: 1 }),
+    ),
+  );
+  const hashThreadId = (threadId: ThreadId): number =>
+    [...threadId].reduce((acc, ch) => ((acc * 31 + ch.charCodeAt(0)) | 0), 0);
+  const selectWorker = (threadId: ThreadId) =>
+    workers[Math.abs(hashThreadId(threadId)) % EVENT_WORKER_COUNT]!;
-  const worker = yield* makeDrainableWorker(processDomainEventSafely, { concurrency: 8 });
-      return worker.enqueue(event);
+      return selectWorker(event.payload.threadId).enqueue(event);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/server/src/orchestration/Layers/ProviderCommandReactor.ts` around lines
780 - 784, The current worker created by
makeDrainableWorker(processDomainEventSafely, { concurrency: 8 }) allows events
for the same threadId to run concurrently and can race lifecycle operations;
modify processing to serialize per-threadId by sharding or mutexing: introduce a
per-thread queue/lock keyed by threadId (used by ProviderSessionDirectory and
ProviderService operations) and ensure processDomainEventSafely first acquires
the threadId shard or mutex before handling events and releases it after, or
replace the flat worker with a set of N shard workers where events are
dispatched to shard = hash(threadId) so each shard runs with single concurrency;
update references to makeDrainableWorker, processDomainEventSafely,
ProviderSessionDirectory, ProviderService, and threadId accordingly.

ranvier2d2 and others added 3 commits March 26, 2026 11:24
ClaudeAdapter.stopSessionInternal used yield* Fiber.interrupt(streamFiber)
which joins the dying fiber and propagates the interruption Exit up through
the fiber tree, crashing the entire Node server. Changed to forkChild so
the interrupt fires without joining.

Also added --exclude <provider> flag to stress test script for isolating
provider-specific issues during debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex CLI sends exec_approval_request even with approvalPolicy: "never"
on resumed sessions (especially when the model changes, e.g. gpt-5.3-codex
→ gpt-5.4). The harness now auto-approves all non-user-input RPC requests
in full-access runtime mode, matching OpenCode's auto-approve pattern.

This was the root cause of the Codex resume test failure — the turn hung
waiting for an approval response that never came because the event was
unmapped in the Node layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Codex CLI's CommandExecutionRequestApprovalResponse serde deserializer
expects the variant "approve" (without trailing 'd'). Our auto-approve
was sending "approved" which caused: "failed to deserialize: unknown
variant 'approved'" — the approval was rejected by the sandbox.

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

@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.

🧹 Nitpick comments (1)
apps/harness/lib/harness/providers/codex_session.ex (1)

843-846: Emitting "request/resolved" without a prior pending entry creates an orphan resolve event.

When auto-approving, the code emits "request/resolved" without ever adding an entry to state.pending or emitting a "request opened" event. While downstream handlers safely handle this (Map.delete on non-existent key is a no-op, DELETE with zero rows succeeds), this creates a semantic inconsistency:

  • The projector receives a resolve for a request that was never tracked
  • The TypeScript adapter (HarnessClientAdapter) emits a request.resolved runtime event without a corresponding request.opened event

This is likely intentional (auto-approved requests shouldn't appear pending to users), but worth documenting in a brief comment for future maintainers, or omitting the resolve event entirely since no request was ever surfaced.

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

In `@apps/harness/lib/harness/providers/codex_session.ex` around lines 843 - 846,
The code emits a "request/resolved" event via emit_event(state, :notification,
"request/resolved", ...) for auto-approved requests even though no entry is
added to state.pending or a "request opened" event is ever emitted; update the
code around emit_event (and the auto-approve branch) to either remove the
resolve emission entirely or add a concise comment explaining this is
intentional (auto-approved requests are not tracked in state.pending and
therefore have no corresponding "request.opened" event), referencing emit_event,
"request/resolved", and state.pending so future maintainers understand the
semantic choice.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/harness/lib/harness/providers/codex_session.ex`:
- Around line 843-846: The code emits a "request/resolved" event via
emit_event(state, :notification, "request/resolved", ...) for auto-approved
requests even though no entry is added to state.pending or a "request opened"
event is ever emitted; update the code around emit_event (and the auto-approve
branch) to either remove the resolve emission entirely or add a concise comment
explaining this is intentional (auto-approved requests are not tracked in
state.pending and therefore have no corresponding "request.opened" event),
referencing emit_event, "request/resolved", and state.pending so future
maintainers understand the semantic choice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ed341303-63ac-4f57-8288-29f9c90853c4

📥 Commits

Reviewing files that changed from the base of the PR and between 7e1ad4c and 7cd4021.

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

ranvier2d2 and others added 2 commits March 26, 2026 12:11
The Codex app-server protocol defines CommandExecutionRequestApprovalResponse
as a Rust serde enum with variants: accept, acceptForSession, decline, cancel.
Neither "approve" nor "approved" are valid — the deserializer rejects both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
oxfmt --check flagged docs/index.html, docs/pitch.html, and
scripts/stress-test-resume-multi.ts. Also added a comment explaining
why the auto-approve branch emits request/resolved without a prior
request.opened event — intentional since auto-approved requests bypass
state.pending entirely.

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:XXL 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