fix(harness): detach memory flush and maintenance from agent call response - #2617
fix(harness): detach memory flush and maintenance from agent call response#2617birdie7761 wants to merge 1 commit into
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR fixes a real and impactful latency bug: MemoryFlushMiddleware and MemoryMaintenanceMiddleware previously used concatWith to append their LLM-backed work onto the returned Flux, forcing callers (e.g. ReActAgent.callInternal with takeLast(1)) to block until the memory flush/maintenance LLM call completed. The fix correctly switches to doOnComplete(() -> subscribe()) for genuine fire-and-forget behavior, adds AutoCloseable lifecycle management with bounded drain on shutdown, and includes thorough test coverage for the async semantics. The concurrency design (synchronized pending-set pattern, double-check on closed flag, timeout caps) is well thought-out. Two areas warrant attention: a subtle race in the subscribe-then-add-to-pending pattern, and missing stack traces in close-failure logs.
(inline comments could not be attached — line numbers fell outside PR hunks. See archived report.)
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR fixes a real and impactful latency bug: MemoryFlushMiddleware and MemoryMaintenanceMiddleware previously used concatWith to append their LLM-backed work onto the returned Flux, forcing callers (e.g. ReActAgent.callInternal with takeLast(1)) to block until the memory flush/maintenance LLM call completed. The fix correctly switches to doOnComplete(() -> subscribe()) for genuine fire-and-forget behavior, adds AutoCloseable lifecycle management with bounded drain on shutdown, and includes thorough test coverage for the async semantics. The concurrency design (synchronized pending-set pattern, double-check on closed flag, timeout caps) is well thought-out. Two areas warrant attention: a subtle race in the subscribe-then-add-to-pending pattern, and missing stack traces in close-failure logs.
(inline comments could not be attached — line numbers fell outside PR hunks. See archived report.)
f4aa01b to
991632d
Compare
|
Thank you for the thorough review — both points were valid and are fixed in the updated commit. 1. subscribe-then-add race: Confirmed. The failure mode: a fast-completing task (e.g. maintenance rejected by the throttle gate returns in microseconds) could run The fix restructures both 2. Missing stack traces: All Additionally, while re-verifying the fix I hardened two adjacent paths in the same commit:
New regression tests: Full harness suite: 797 tests, 0 failures (JDK 17), spotless clean. |
|
Hi guys, when this PR will be merged ? any deadline ? |
6b91b9d to
af09aee
Compare
…ponse MemoryFlushMiddleware and MemoryMaintenanceMiddleware appended their LLM-backed work via concatWith onto the returned Flux. Because ReActAgent.callInternal ends with takeLast(1), which can't emit until the upstream signals onComplete, callers consuming the agent response to completion (blockLast, takeLast(1), WebFlux controllers awaiting Mono<Msg>) ended up waiting for the full memory flush LLM call (19-27s per call) and consolidation LLM call (~44s first run). The original implementation used doOnComplete (fire-and-forget). A later commit (PR agentscope-ai#1802, RC4) swapped it for concatWith, introducing this regression. This fix restores the fire-and-forget behavior: both middlewares now subscribe their work independently of the returned Flux via doOnComplete().subscribe(), so the Flux completes as soon as the underlying agent call does. Engineering safeguards: - Both middlewares implement AutoCloseable and track pending Disposables in a Set guarded by synchronized(pending); a pre-subscribe closed check prevents new work, a post-subscribe check disposes any subscription that raced with close(). HarnessAgent.close() drains them (bounded by 5s) before workspace teardown to prevent races with temp dir deletion in tests and CLI shutdown. - Detached work is bounded (MAX_PENDING_FLUSHES / MAX_PENDING_MAINTENANCE = 4): with a slow memory model the backlog is capped; excess runs are skipped and logged instead of accumulating without bound. Skipping is safe because a flush always extracts from the full conversation context - the next successful flush covers the same messages. - New opt-out MemoryConfig.asyncFlush(boolean), default true, for callers relying on "response completed == memory persisted" durability semantics: false chains the per-call memory work back onto the response stream via concatWith (bounded by the same 5-minute timeout; failures are logged and never fail the completed response). - captureFlushRequest is wrapped in try/catch so exceptions cannot escape into the doOnComplete callback and turn an already-completed Flux into an error. - Flush and consolidation LLM calls are capped by a 5-minute timeout (after subscribeOn, measuring execution not queue wait) so a hung model provider cannot tie up a boundedElastic worker. - A FlushRequest record snapshots messages (List.copyOf) on the complete thread before background execution, avoiding races with mutable AgentState context. - English and Chinese memory guides document the detached default, the backlog cap, and the asyncFlush(false) opt-out. New regression tests (TDD): - onAgent_completesBeforeSlowFlushFinishes / completesBeforeSlowConsolidationFinishes: mock a slow LLM, assert Flux completes in <1s while the LLM is still running in the background. - onAgent_flushError_doesNotPropagateToFlux / maintenanceError_doesNotPropagateToFlux: verify errors in detached work never reach the caller. - onAgent_afterClose_doesNotScheduleNewFlush / doesNotScheduleNewMaintenance: verify close() prevents new work; uses polling (not fixed sleep) for deterministic verification on slow CI. - close_waitsForPendingFlush_thenReturns / waitsForPendingMaintenance_thenReturns: verify close() drains in-flight work before returning. - close_disposesHungFlush_andReturnsPromptly: verifies close() disposes a hanging model (Flux.never) and returns within CLOSE_AWAIT_TIMEOUT. - onAgent_syncMode_waitsForFlush / onAgent_syncMode_waitsForMaintenance: with asyncFlush=false the response completion waits for the memory work. - scheduleFlush_backlogLimit_skipsBeyondCap: excess flushes beyond the cap are skipped, not queued (model invoked exactly MAX_PENDING_FLUSHES times). - memoryConfig_asyncFlush_defaultsTrue_andOptOut. Fixes agentscope-ai#2276 Fixes agentscope-ai#2225
af09aee to
9f04c09
Compare
|
Closing in favor of #2777, which landed the core detach (flush + maintenance) with per-conversation coalescing and close-time quiescence — congratulations @chcodex, nice work. I plan to follow up with a small hardening PR building on MemoryBackgroundTasks: (1) a bounded timeout on the flush/consolidation LLM calls so a hung model can't pin a conversation's coalescing slot forever, (2) disposing hung tasks at close (awaitQuiescence currently waits-then-gives-up, so a hung flush keeps writing after teardown — the race #2935 patched at the test level), and (3) a MemoryConfig.asyncFlush(false) opt-out for callers needing completion-order persistence. Thanks all for the reviews here — the subscribe-then-add discussion materially improved that follow-up. |
Fixes #2225
Fixes #2276
Also addresses #2774 and #2821 (same root cause).
Problem
MemoryFlushMiddleware/MemoryMaintenanceMiddlewareappended their LLM-backed work viaconcatWithonto the returnedFlux. BecauseReActAgent.callInternalends withtakeLast(1)— which can't emit until upstream signalsonComplete— callers consuming the response to completion (blockLast(),takeLast(1), WebFlux controllers awaitingMono<Msg>) waited for the full memory-flush LLM round-trip (19–27s per call) and consolidation (~44s first run).This is a regression: the original implementation was fire-and-forget (
doOnComplete); PR #1802 (RC4) swapped it forconcatWith.Fix
Both middlewares subscribe their work independently via
doOnComplete().subscribe(), restoring the historical detached behavior, with production hardening:AutoCloseable; pendingDisposables are tracked undersynchronized(pending)with pre/post-subscribe closed checks.HarnessAgent.close()drains them (5s bound) before workspace teardown — no writes racing temp-dir deletion.MemoryConfig.asyncFlush(false)chains the per-call memory work back onto the response stream for callers relying on "response completed == memory persisted" — bounded by the same 5-minute timeout, failures logged and never fail the response. Default istrue(the restored behavior).subscribeOn, excluding queue wait).FlushRequestcopies messages on the completion thread, immune to later context mutation.Relationship to #2777 and #2833
Same root cause cluster (#2225/#2276/#2774/#2821). Differences:
asyncFlushflag that defaults to synchronous — the regression stays on the default path, maintenance is not covered, and its static single-worker scheduler has no timeout (one hung flush stalls the whole pipeline) and is shared across all agents in the JVM.asyncFlush(false)opt-out and docs — a superset of both.Test evidence
17 middleware tests (TDD, red on main before the fix): completes-before-slow-flush/consolidation, errors-don't-propagate, close-prevents-schedules/drains/disposes-hung, sync-mode waits (×2), backlog cap skips beyond limit, config default + opt-out. Full harness suite 861/0 failures.
Checklist
mvn spotless:applymvn test)