Skip to content

fix(harness): make memory flush fire-and-forget to unblock conversation completion - #2777

Open
chcodex wants to merge 3 commits into
agentscope-ai:mainfrom
chcodex:fix-memory-flush-non-blocking/opencode-deepseek-v4-flash-free
Open

fix(harness): make memory flush fire-and-forget to unblock conversation completion#2777
chcodex wants to merge 3 commits into
agentscope-ai:mainfrom
chcodex:fix-memory-flush-non-blocking/opencode-deepseek-v4-flash-free

Conversation

@chcodex

@chcodex chcodex commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • MemoryFlushMiddleware used concatWith to chain the memory flush after the agent stream, so the downstream onComplete was delayed by a full LLM extraction round-trip plus disk writes on every call (default FlushMode.ALWAYS). Switched to doOnComplete + subscribeOn(boundedElastic()).subscribe() so the flush runs in the background and the conversation stream completes immediately.
  • Snapshot the conversation list (new ArrayList<>(state.getContext())) before the async flush to avoid concurrent modification if the next agent call clears the state while the flush is still running.
  • Applied the same fire-and-forget pattern to MemoryMaintenanceMiddleware, which had the identical concatWith structure.
  • Added MemoryFlushMiddlewareAsyncFlushTest to verify the agent stream completes before a never-finishing flush does, and that the flush is still triggered.

Test plan

  • New MemoryFlushMiddlewareAsyncFlushTest passes (verifies stream completes before flush + flush still triggered).
  • Existing MemoryFlushMiddlewareTriggerTest (17 tests) passes.
  • Existing MemoryMaintenanceMiddlewareStaleEntryTest (3 tests) passes.
  • Full agentscope-harness module test suite passes (831 tests, 0 failures).

Closes #2774

…s the conversation completion signal

MemoryFlushMiddleware used concatWith to chain the flush after the agent stream,
so the downstream onComplete was delayed by a full LLM extraction round-trip plus
disk writes on every call (default FlushMode.ALWAYS). Switch to doOnComplete +
subscribeOn(boundedElastic()).subscribe() so the flush runs in the background and
the conversation stream completes immediately.

Snapshot the conversation list (new ArrayList<>(state.getContext())) before the
async flush to avoid concurrent modification if the next agent call clears the
state while the flush is still running.

Also apply the same fire-and-forget pattern to MemoryMaintenanceMiddleware, which
had the identical concatWith structure.

Add MemoryFlushMiddlewareAsyncFlushTest to verify the agent stream completes
before a never-finishing flush does, and that the flush is still triggered.

Closes agentscope-ai#2774
…oid temp-dir race

The async flush runs in the background after the agent stream completes, so
HarnessAgent.close() could release the workspace while the flush was still
writing memory/*.md — racing with @tempdir deletion in tests (intermittent
"Failed to delete temp directory" under a full test run).

Track in-flight fire-and-forget memory tasks (flush + maintenance) via a new
process-wide MemoryBackgroundTasks counter, and wait for quiescence in
HarnessAgent.close() before releasing resources, mirroring the existing
SessionTree.awaitMirrorQuiescence pattern.

Also rework MemoryFlushMiddlewareAsyncFlushTest to gate (rather than never
complete) the flush stream so the background task is released at the end of
the test and does not hold the in-flight tracker open for other tests.
@chcodex

chcodex commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Update: graceful shutdown for the async flush (commit 5d99f005)

The first version made the flush fire-and-forget but did not handle shutdown: HarnessAgent.close() could release the workspace while the background flush was still writing memory/*.md, racing with @TempDir deletion in tests (intermittent Failed to delete temp directory under a full test run).

What changed

  • New MemoryBackgroundTasks (io.agentscope.harness.agent.memory): process-wide in-flight counter with begin()/end() and awaitQuiescence(timeout, unit). The middleware instances that dispatch the flush are created per agent call, so they are not reachable from close() — the counter is process-wide for the same reason SessionTree.awaitMirrorQuiescence already is.
  • MemoryFlushMiddleware / MemoryMaintenanceMiddleware: the async task chain now wraps doOnSubscribe(begin()) / doFinally(end()) so the counter is always paired (success, error, and cancel all release it).
  • HarnessAgent.close(): waits for memory-task quiescence (5s) before releasing resources, mirroring the existing SessionTree.awaitMirrorQuiescence call directly above it.
  • MemoryFlushMiddlewareAsyncFlushTest: previously used Flux.never(), which would hold the in-flight counter open forever and force every subsequent close() to wait the full 5s timeout. The test now gates the flush stream behind a latch, asserts the agent stream completes while the flush is in-flight, then releases the gate and asserts the tracker quiesces back to zero.

Why this is safe

  • The non-blocking behavior from the original fix is preserved: the conversation stream still completes immediately; close() is the only place that waits, and only for tasks that were already running.
  • The 5s cap means a genuinely stuck flush cannot hang shutdown indefinitely (same timeout as the session-tree drain).

Verification

  • LocalFilesystemPersonalAssistantExampleTest — 10 consecutive full runs, all green (was ~50% flaky before).
  • Full agentscope-harness suite — 831 tests, 0 failures.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.59574% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...pe/harness/agent/memory/MemoryBackgroundTasks.java 75.00% 5 Missing and 2 partials ⚠️
...arness/agent/middleware/MemoryFlushMiddleware.java 77.77% 2 Missing ⚠️
.../agent/middleware/MemoryMaintenanceMiddleware.java 80.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

…op flushes

Address two review findings from the fire-and-forget flush change:

1. begin()/end() were wrapped around the subscribed Mono via doOnSubscribe/
   doFinally, so they ran even when doFlush returned Mono.empty() (null state,
   empty messages, or trigger=skipped) — needless synchronized work on every
   call. Move begin() inside doFlush/doMaintenance so it only fires when there
   is actual work, and pair it with doFinally(end).

2. subscribeOn(boundedElastic()) schedules begin() asynchronously, leaving a
   tiny window where awaitQuiescence() could read a zero counter before begin()
   runs. Calling begin() synchronously before the Mono is returned eliminates
   that window.
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.

MemoryFlushMiddleware flush blocks the conversation completion signal

1 participant