Skip to content

test(harness): drain fire-and-forget memory flush before @TempDir teardown to stop flaky temp-dir deletion - #2935

Merged
Buktal merged 2 commits into
mainfrom
fix/harness-tempdir-quiescence
Sep 2, 2026
Merged

test(harness): drain fire-and-forget memory flush before @TempDir teardown to stop flaky temp-dir deletion#2935
Buktal merged 2 commits into
mainfrom
fix/harness-tempdir-quiescence

Conversation

@jujn

@jujn jujn commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Problem

Two CI runs fail intermittently with JUnit errors that are not test-logic failures but teardown failures:

Both surface only when a test builds a transient HarnessAgent, drives it to completion via .block() / .stream()...block(), and uses @TempDir for its workspace/state home.

Root cause

This is a long-standing race, not a regression of any single commit. The harness memory flush has always been asynchronous: when an agent stream completes, MemoryFlushMiddleware#onAgent dispatches the flush on Schedulers.boundedElastic() via subscribe(...) — i.e. fire-and-forget. The calling test's .block() only waits for the business stream, not for that background flush.

So the timeline is:

  1. Test calls .block() and returns.
  2. JUnit begins @TempDir teardown and deletes the temp directory.
  3. The async flush on boundedElastic is still writing session/transcript mirror files into that same @TempDir (or still holds open file handles).

The result: directory/file deletion fails with IOException → wrapped as JUnitException. It is timing-dependent (depends on IO speed, scheduler, and how many files are written), which is why it flakes rather than failing deterministically, and why Windows (stricter file locking) fails more readily than Linux.

The normal production path avoids this because HarnessAgent#close() calls SessionTree.awaitMirrorQuiescence(...) + MemoryBackgroundTasks.awaitQuiescence(...). The flaky tests never call close() on their transient agent.

Fix

Add test-side quiescence that mirrors what HarnessAgent#close() already does, run after each test method but before the TempDir extension deletes the directory:

  • HarnessBackgroundTaskQuiescenceExtension — an AfterEachCallback that calls SessionTree.awaitMirrorQuiescence(5s) + MemoryBackgroundTasks.awaitQuiescence(5s). When nothing is in flight both calls return immediately, making it a no-op for tests that never trigger a flush.
  • @HarnessQuiescence — a composed meta-annotation (@ExtendWith(HarnessBackgroundTaskQuiescenceExtension.class)) so at-risk tests only need a one-line annotation.
  • Applied @HarnessQuiescence to 16 harness test classes that match the at-risk pattern (HarnessAgent.builder() + .call()/.stream() + @TempDir), including the three classes that flaked in CI: JsonSessionDefaultLocationTest, HarnessAgentIntegrationExampleTest, HarnessAgentDynamicHookBuilderTest.

Production code is unchanged — the flush remains fire-and-forget so conversation completion is never blocked.

Copilot AI lite review requested due to automatic review settings September 2, 2026 05:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new quiescence extension currently ignores timeout/interrupt failures from the await methods, which can allow the original teardown flake to persist without a deterministic, actionable failure.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR addresses intermittent JUnit @TempDir teardown failures in harness tests by ensuring fire-and-forget background writes (session/transcript mirrors and memory background tasks) have quiesced before temporary directories are deleted.

Changes:

  • Added a JUnit Jupiter AfterEachCallback extension to await harness background task quiescence after each test method.
  • Added @HarnessQuiescence as a composed annotation to apply the extension with a one-liner.
  • Applied @HarnessQuiescence to multiple @TempDir-using harness integration tests that build/transiently run HarnessAgent instances.
File summaries
File Description
agentscope-harness/src/test/java/io/agentscope/harness/agent/tools/HarnessAgentToolsConfigTest.java Applies @HarnessQuiescence to prevent teardown races in this test.
agentscope-harness/src/test/java/io/agentscope/harness/agent/testing/HarnessQuiescence.java Introduces composed annotation to register the quiescence extension.
agentscope-harness/src/test/java/io/agentscope/harness/agent/testing/HarnessBackgroundTaskQuiescenceExtension.java Adds AfterEachCallback extension that drains mirror + memory background tasks.
agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/SubagentIsolationIntegrationTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/PlanModeSubagentPropagationTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/JsonSessionDefaultLocationTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentSubagentStreamEventsTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentModelStringTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentIntegrationExampleTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDynamicHookBuilderTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDistributedSandboxTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/gateway/SubagentRegistryRecoveryIntegrationTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/example/SandboxFilesystemIsolationScopeExampleTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/example/RemoteFilesystemIsolationScopeExampleTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemUserIsolationExampleTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java Applies @HarnessQuiescence to avoid @TempDir deletion races.
Review details
  • Files reviewed: 18/18 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Buktal Buktal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@Buktal
Buktal merged commit 6cdb468 into main Sep 2, 2026
7 checks passed
@jujn
jujn deleted the fix/harness-tempdir-quiescence branch September 2, 2026 08:24
birdie7761 added a commit to birdie7761/agentscope-java that referenced this pull request Sep 2, 2026
Since agentscope-ai#2777 the per-call memory flush and maintenance run detached from
the agent response, tracked by the JVM-wide MemoryBackgroundTasks
counter and drained by HarnessAgent.close() via awaitQuiescence. A hung
model call, however, has no bound and no escape:

- runFlush's pipeline has no timeout, so a hung flush keeps the
  conversation's coalescing slot in FLUSH_QUEUES running forever -
  every later flush of that conversation queues behind the dead slot
  and memory extraction stops for it;
- MemoryBackgroundTasks.inFlight never returns to zero, so every
  subsequent awaitQuiescence call (e.g. HarnessAgent.close()) waits its
  full budget, gives up, and leaves the stuck task running against the
  torn-down workspace - the race agentscope-ai#2935 patched at the test level;
- maintenance consolidation ran consolidator.consolidate(rc).block()
  inside Mono.fromRunnable, so a hung consolidation leaked a
  boundedElastic worker for the rest of the process: the inner block
  subscription is unreachable from any outer dispose, and once the
  model eventually returned, the same worker would keep writing
  retention/prune results into the torn-down workspace.

Fixes:

- Both pipelines get a 5-minute timeout. The budget runs from
  subscription, so it also covers any boundedElastic pickup wait: under
  scheduler saturation a healthy-but-slow run may be skipped and
  logged - the safe direction for fire-and-forget work.
- doMaintenance is now a reactive composition (expire ->
  consolidator.consolidate -> prune), so a timeout or quiescence cancel
  propagates down the chain into the consolidation model stream and the
  worker is actually freed; consolidation failures log and let the
  retention steps still run, matching the previous try/catch semantics.
- MemoryBackgroundTasks gains register/unregister: each task's
  subscription is listed under the task-tracking monitor (with a
  disposed re-check, so a terminated subscription can never linger),
  and awaitQuiescence disposes still-stuck tasks when its timeout
  elapses instead of leaving them running. Disposal runs outside the
  monitor so the cancelled task's cleanup can re-enter it safely.

Known bound: the quiescence sweep disposes one task per key; that
task's doFinally drains the queue and may start the next queued flush
for that key before the sweep finishes - at most one flush per key per
close escapes cancellation.

New MemoryBackgroundHardeningTest: awaitQuiescence cancels stuck tasks;
a hung flush times out, releases the quiescence count and the
conversation queue recovers; a hung consolidation times out and the
cancellation is asserted to reach the consolidation subscription itself
(the old .block() variant released the counter while the worker stayed
stuck). The timeout is injectable via package-private test hooks;
production uses the 5-minute default.
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.

3 participants