fix(llmloop): scope async memory compression to each RunPerFile conversation#395
fix(llmloop): scope async memory compression to each RunPerFile conversation#395chethanuk wants to merge 2 commits into
Conversation
|
🔍 OpenCodeReview found 1 issue(s) in this PR.
|
…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
47a9222 to
189c131
Compare
lizhengfeng101
left a comment
There was a problem hiding this comment.
Review
Excellent concurrency bug fix. The root cause analysis is thorough (four distinct defects cleanly decomposed), the fix eliminates the problem structurally (per-conversation isolation rather than more locks), and the tests are well-designed with channel synchronization instead of sleeps. A few minor nits below, none blocking.
1. copyMessages runs under st.mu in triggerAsyncCompression
st.mu.Lock()
if st.pendingJob != nil { st.mu.Unlock(); return }
msgSnapshot := copyMessages(messages) // still holding st.mu
...
st.mu.Unlock()Since st is per-conversation, the only contender is the background goroutine's completion handler, so there's no real performance concern. But copyMessages is unrelated to the mutex-protected state (pendingJob) — it could be moved outside the critical section (check-and-set a sentinel under the lock, unlock, copy, re-lock to install the real job). That said, the current approach is safe and simpler. Just a note, not a blocker.
2. Comment describes lock ordering that doesn't match the code
In the async goroutine:
// cancelPendingCompression clears pendingJob before cancellingBut the actual code in cancelPendingCompression calls cancel() first, then sets st.pendingJob = nil. Both happen under st.mu, so the goroutine can never observe the intermediate state — behavior is correct. The comment just describes the wrong order. Suggest rewording to something like "cancels and clears pendingJob under the lock".
3. Consider a brief note on the pre-append compression failure path
In addNextMessage, when pre-append sync compression fails, execution continues (appends new messages, may retry via the post-append check). This is the right degradation behavior — and an improvement over the old code which silently ignored the error (_, _ = r.runCompression). A one-line comment like "// compression failed; continue with over-limit messages, post-append will retry" would make the intent more discoverable, but this is optional.
Summary
Low-risk, structurally sound fix. The per-conversation compressionState eliminates cross-file interference by construction. Test coverage is thorough — gatedLLMClient is a clean pattern, the cross-file isolation test pins all four defects, and the race test guards against regression. LGTM.
…retry path Review feedback on alibaba#395: cancelPendingCompression cancels and then clears pendingJob, both under st.mu — the old comment described the reverse order. Also note in addNextMessage that a pre-append compression failure is retried by the post-append check.
|
Thanks for the thorough review @lizhengfeng101! Items 2 and 3 fixed in aa08bf4: the async-goroutine comment now reads "cancels and clears pendingJob under the lock" (matching the actual order), and For item 1 ( |
fix(llmloop): scope async memory compression to each RunPerFile conversation
Problem
One
Runnerserves a whole review session, and bothinternal/agentandinternal/scanfan 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 #384 (and confirmed by the maintainer), that shared slot breaks per-conversation semantics once concurrency > 1.Root cause — four concrete defects
tryApplyPendingCompressionhad 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.context canceled(rendered as an HTTP 500 by some gateways);triggerAsyncCompressionalso overwrote the slot unconditionally, burning the superseded request.addNextMessagecall canceled the job it had started microseconds earlier (the cancellation-within-milliseconds seen in the issue).pendingJob == nilfast-path read inaddNextMessagewas unlocked.Fix
compressionState { mu sync.Mutex; pendingJob *compressionJob }, created perRunPerFilecall and threaded throughtriggerAsyncCompression/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).triggerAsyncCompressionas an atomic check-and-set underst.mu(also fixes 4).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).RunPerFiledeferscancelPendingCompression(st), so an in-flight job is aborted when its conversation ends instead of running up to 5 minutes orphaned.internal/agentandinternal/scanare untouched.[ocr] Memory compression failedline is printed by the sync call sites and by the async job only while it still owns the slot. A deliberately cancelled job — superseded by sync compression at the warning threshold, or aborted byRunPerFile's deferred cancel at conversation end — dies silently (its session task record is still written, so nothing is hidden from debugging). Without this, the fix's own intentional cancels would keep printing the issue's exactcontext canceledsymptom whenever a job was still in flight at the warning threshold or when the model calledtask_donea round after a trigger.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):
TestCompression_CrossFileIsolation└ SummaryAppliesOnlyToOwningConversation(subtest)TestAddNextMessage_NoStartThenCancelSameCallTestRunPerFile_ConcurrentFilesCompression_RaceRunPerFilecalls on one Runner crossing both thresholds repeatedly; asserts-racecleanliness end to end (defect 4), and asserts compression requests were actually issued so the test can never silently go vacuousThe 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.
E2E
Mirroring the issue's setup: 2-file review at
--concurrency 2against 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 filesreview_item_done,memory_compression_taskrecords present for each file, and zerocontext canceled/Memory compression failedin stdout or session records.Fixes #384