Skip to content

fix(llmloop): scope async memory compression to each RunPerFile conversation#10

Closed
chethanuk wants to merge 1 commit into
mainfrom
fix/llmloop-per-file-compression
Closed

fix(llmloop): scope async memory compression to each RunPerFile conversation#10
chethanuk wants to merge 1 commit into
mainfrom
fix/llmloop-per-file-compression

Conversation

@chethanuk

@chethanuk chethanuk commented Jul 17, 2026

Copy link
Copy Markdown
Owner

fix(llmloop): scope async memory compression to each RunPerFile conversation

Problem

One Runner serves a whole review session, and both internal/agent and internal/scan fan out one goroutine per file over it (default concurrency 8). Async memory compression state, however, lived on the Runner as a single shared slot (compressionMu + pendingJob). As analyzed in alibaba#384 (and confirmed by the maintainer), that shared slot breaks per-conversation semantics once concurrency > 1.

Root cause — four concrete defects

  1. Cross-file applytryApplyPendingCompression had no owner check: file B could consume file A's completed job, splicing [frozen + A's summary + A's active rounds] into B's message history. A review-correctness bug, not just wasted work.
  2. Cross-file cancel / replace — the warning-threshold paths canceled whichever file's job happened to be pending, so another file's in-flight compression request died with context canceled (rendered as an HTTP 500 by some gateways); triggerAsyncCompression also overwrote the slot unconditionally, burning the superseded request.
  3. Same-call start-then-cancel — the soft-threshold trigger ran before the new assistant/tool messages were appended. If the append crossed the warning threshold, the same addNextMessage call canceled the job it had started microseconds earlier (the cancellation-within-milliseconds seen in the issue).
  4. Data race — the pendingJob == nil fast-path read in addNextMessage was unlocked.

Fix

  • New compressionState { mu sync.Mutex; pendingJob *compressionJob }, created per RunPerFile call and threaded through triggerAsyncCompression / tryApplyPendingCompression / cancelPendingCompression. Isolation holds by construction: a conversation physically cannot see another conversation's job. The state is GC'd with the call — no cleanup maps, no keying by file path (which would collide if the same path is reviewed twice).
  • The nil-pending gate moved from the caller's unlocked read into triggerAsyncCompression as an atomic check-and-set under st.mu (also fixes 4).
  • The async trigger moved to the end of addNextMessage, gated on the post-append count sitting strictly between the soft (60%) and warning (80%) thresholds — start-then-cancel within one update is now structurally impossible, and no job starts when the call is about to return false (also fixes 3).
  • RunPerFile defers cancelPendingCompression(st), so an in-flight job is aborted when its conversation ends instead of running up to 5 minutes orphaned.
  • Aggregate token counters, warnings, and tool-call counts stay Runner-level, exactly as before. No exported API changes; internal/agent and internal/scan are untouched.

Intentional behavior deltas (all strictly safer): the async snapshot now includes the just-appended round (strictly more history summarized); in-flight jobs are canceled at conversation end; no async job starts on a failing update.

Tests

New regression tests (channel-synchronized, no sleeps):

Test What it proves
TestCompression_CrossFileIsolation With file A's compression request held in flight by a gated fake client, file B's cancel + apply touch nothing: A's job stays pending, zero requests canceled (defect 2)
└ SummaryAppliesOnlyToOwningConversation (subtest) After A's job completes, B cannot consume it and B's messages stay byte-identical; A applies its own summary and a message appended after the snapshot survives (defect 1)
TestAddNextMessage_NoStartThenCancelSameCall Pre-append count in (soft, warn), post-append over warn — the alibaba#384 same-file case: exactly one sync compression, zero mid-flight cancellations, no dangling async job (defect 3)
TestRunPerFile_ConcurrentFilesCompression_Race 4 concurrent RunPerFile calls on one Runner crossing both thresholds repeatedly; asserts -race cleanliness end to end (defect 4)

The RED baseline was demonstrated on unmodified main: a gated-client test showed file B's cancel destroying file A's pending job (file A's job should still be pending after file B's cancel).

All seven existing compression unit tests were updated mechanically to the per-conversation API; the remaining compression edge cases (empty template no-op, LLM error → messages unchanged, empty summary → unchanged, snapshot-suffix preservation, fence stripping) are unchanged and still green.

go test ./internal/llmloop/ -race -count=1   # green
make check && make test && make coverage      # green, coverage 81.1% (≥ 80% gate)
go build ./cmd/opencodereview                 # green

E2E

Mirroring the issue's setup: 2-file review at --concurrency 2 against a local OpenAI-compatible stub, with responses sized so each conversation crosses the soft threshold (async compression) and the warning threshold (sync compression). Result: both files review_item_done, memory_compression_task records present for each file, and zero context canceled / Memory compression failed in stdout or session records.

Fixes alibaba#384

Summary by CodeRabbit

  • Bug Fixes

    • Improved conversation compression when multiple files are processed concurrently.
    • Prevented compression jobs from being canceled, applied, or replaced across separate conversations.
    • Avoided starting background compression when the conversation is about to stop.
    • Ensured completed summaries are applied only to the conversation that created them.
  • Tests

    • Added coverage for concurrent compression, cancellation, and cross-conversation isolation.

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Background compression bookkeeping moved from shared Runner fields to per-conversation state. RunPerFile now owns compression lifecycle cleanup, threshold handling was refined, and tests cover concurrent isolation, cancellation, application, and immediate completion.

Changes

Conversation compression isolation

Layer / File(s) Summary
Compression state and job lifecycle
internal/llmloop/compression.go
A mutex-protected compressionState tracks each conversation’s pending job and coordinates asynchronous triggering, application, failure cleanup, and cancellation.
RunPerFile compression wiring
internal/llmloop/loop.go
RunPerFile creates and cleans up conversation state, while addNextMessage uses it for compression operations and threshold-based decisions.
Concurrency and lifecycle validation
internal/llmloop/runner_test.go
Tests validate state-local job handling, cross-file isolation, cancellation, asynchronous coordination, and immediate-completion behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RunPerFile
  participant addNextMessage
  participant compressionState
  participant compressionJob
  RunPerFile->>compressionState: create per-conversation state
  RunPerFile->>addNextMessage: pass compressionState
  addNextMessage->>compressionState: check pendingJob
  addNextMessage->>compressionJob: trigger async compression
  compressionJob->>compressionState: update completed or failed job
  addNextMessage->>compressionState: apply or cancel pending compression
  RunPerFile->>compressionState: cancel pending job on conversation exit
Loading

Suggested reviewers: lizhengfeng101

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately captures the main change: scoping async compression per RunPerFile conversation.
Description check ✅ Passed It covers the problem, fix, tests, and related issue, though it doesn't use the template's exact checklist formatting.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/llmloop-per-file-compression

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.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request refactors the background memory compression in the LLM tool-use loop to be scoped per conversation (within RunPerFile) rather than shared globally across the Runner session. This is done by introducing a compressionState struct to manage the pending job and mutex, ensuring cross-file isolation and preventing race conditions during concurrent file reviews. Additionally, async compression is now triggered only after all message appends are completed to avoid immediate cancellation. Feedback on the PR suggests adding a defensive check job.snapshotLen <= len(*messages) before applying rebuilt messages to prevent potential out-of-bounds panics if the message history is modified or shrunk while a background job is in flight.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

r.compressionMu.Lock()
if r.pendingJob == job && job.rebuilt != nil {
st.mu.Lock()
if st.pendingJob == job && job.rebuilt != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To ensure robust defensive programming, we should add a guard to verify that job.snapshotLen <= len(*messages) before attempting to apply the rebuilt messages. Although messages is currently only expected to grow during the active conversation loop, if any future refactoring or error-handling path shrinks the message history while a background compression job is in flight, job.snapshotLen > len(*messages) could cause a slice bounds out-of-range panic when executing (*messages)[job.snapshotLen:], or lead to inconsistent history state by restoring previously deleted messages.

Suggested change
if st.pendingJob == job && job.rebuilt != nil {
if st.pendingJob == job && job.rebuilt != nil && job.snapshotLen <= len(*messages) {

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/llmloop/loop.go`:
- Around line 444-446: Update the compression boundary logic around
CountMessagesTokens and the related finalCount handling so an exact warnLimit
value triggers synchronous compression before the stop decision, while
preserving asynchronous compression for values above the threshold. Add a
regression test covering finalCount == warnLimit and verify compression is
attempted before the function returns false.
🪄 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: e9f0c5c1-5cf5-4667-9832-d3049c23a269

📥 Commits

Reviewing files that changed from the base of the PR and between 0b226fa and b634d96.

📒 Files selected for processing (3)
  • internal/llmloop/compression.go
  • internal/llmloop/loop.go
  • internal/llmloop/runner_test.go

Comment thread internal/llmloop/loop.go
Comment on lines +444 to 446
if CountMessagesTokens(*messages) > warnLimit {
r.cancelPendingCompression(st)
*messages, _ = r.runCompression(ctx, *messages, filePath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Compress at the exact warning threshold before stopping.

When finalCount == warnLimit, synchronous compression is skipped, async compression is skipped, and the function returns false. This contradicts the contract that stopping occurs only after compression cannot bring the conversation below the threshold.

Proposed boundary fix
-	if CountMessagesTokens(*messages) > warnLimit {
+	if CountMessagesTokens(*messages) >= warnLimit {
 		r.cancelPendingCompression(st)
 		*messages, _ = r.runCompression(ctx, *messages, filePath)
 	}
...
-	if finalCount > warnLimit {
+	if finalCount >= warnLimit {
 		r.cancelPendingCompression(st)
 		*messages, _ = r.runCompression(ctx, *messages, filePath)

Please add an exact-warnLimit regression case.

Also applies to: 461-473

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/llmloop/loop.go` around lines 444 - 446, Update the compression
boundary logic around CountMessagesTokens and the related finalCount handling so
an exact warnLimit value triggers synchronous compression before the stop
decision, while preserving asynchronous compression for values above the
threshold. Add a regression test covering finalCount == warnLimit and verify
compression is attempted before the function returns false.

…rsation

The Runner is shared by all concurrent per-file review goroutines, but it
held a single compressionMu/pendingJob slot for async memory compression.
With concurrency > 1 that shared slot caused four defects (alibaba#384):

1. Cross-file apply: tryApplyPendingCompression had no owner check, so
   file B could splice file A's rebuilt history into its own messages.
2. Cross-file cancel/replace: the warning-threshold paths canceled
   whichever file's job happened to be pending, surfacing spurious
   "context canceled" errors at the gateway; triggerAsyncCompression
   overwrote the slot unconditionally, wasting the superseded request.
3. Same-call start-then-cancel: the soft-threshold trigger fired before
   the new messages were appended, so an append that crossed the warning
   threshold canceled the job started microseconds earlier.
4. The pendingJob == nil fast-path read in addNextMessage was unlocked.

Fix: move the bookkeeping into a compressionState owned by each
RunPerFile call and thread it through trigger/apply/cancel. The
nil-pending gate is now an atomic check-and-set inside
triggerAsyncCompression under st.mu, and the async trigger moved to the
end of addNextMessage, gated on the post-append count sitting strictly
between the soft and warning thresholds. RunPerFile defers a cancel so
no job outlives its conversation. Aggregate token counters and warnings
stay Runner-level.

Intentional behavior deltas, all strictly safer: the async snapshot now
includes the just-appended round; an in-flight job is canceled when
RunPerFile returns instead of running up to 5 minutes orphaned; no async
job starts when the call is about to return false.

Verified: new regression tests (cross-file isolation via a channel-gated
fake client, owner-only summary apply with post-snapshot suffix
preserved, no start-then-cancel in one update, 4 concurrent RunPerFile
calls under -race); all existing compression tests updated to the
per-conversation API; full suite green with -race; coverage 81.1%.
E2E: 2-file concurrent review against a local OpenAI-compatible stub
with compression exercised — zero "context canceled", both files done.

Fixes alibaba#384
@chethanuk
chethanuk force-pushed the fix/llmloop-per-file-compression branch from b634d96 to 47a9222 Compare July 17, 2026 10:46
@chethanuk chethanuk closed this Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Async memory compression state is shared across concurrent file reviews

1 participant