Skip to content

chore(server): mentor capacity observability + dead-code cleanup - #1089

Closed
FelixTJDietrich wants to merge 1 commit into
mainfrom
mentor-observability-cleanup
Closed

chore(server): mentor capacity observability + dead-code cleanup#1089
FelixTJDietrich wants to merge 1 commit into
mainfrom
mentor-observability-cleanup

Conversation

@FelixTJDietrich

@FelixTJDietrich FelixTJDietrich commented May 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Retrospective housekeeping on the mentor pipeline after #1078 / #1080 / #1081. Three observability gaps + a small SPI hygiene rule, plus closing out three issues that #1081 already resolved in place.

  • interactive_sandbox.frame_ring.dropped_total{userId} — new counter. The ring buffer was bumping a global counter only; per-user attribution was unavailable. Per-user emission is debounced to <=1/s per (userId, sandboxId) and the userId tag is capped at 50 distinct values via a MeterFilter (MetricsCardinalityConfig). The 51st user still drops frames into the global counter; only attribution is lost. Default ringBufferFrames=512 is NOT bumped here — that decision waits on observed traffic.
  • interactive_sandbox.evicted_total{reason} — new counter under the SPI-aligned name. mentor.session.eviction{reason} stays for back-compat; both increment together.
  • MAX_LIFETIME reaperEvictionReason.MAX_LIFETIME was reserved-but-unwired. New property hephaestus.mentor.max-lifetime-minutes (default 60, range [5, 480]). Wired into the existing reap pass; checked before the idle check (a chatty user past lifetime should still evict). Backstop against runners that accumulate state, drift on context, or leak FDs over hours.
  • mentor.chat.outcome{CAPACITY_EXCEEDED} — new outcome distinguishing per-user/global cap rejections from genuine errors. Previously routed through ERROR and was invisible on dashboards. Backed by a new typed InteractiveSandboxCapacityExceededException so the routing is structural, not message-based.
  • ArchUnit gateagent.sandbox.spi.* may not depend on io.micrometer... Keeps the SPI transport-agnostic; metrics belong in docker.interactive.* and agent.mentor.chat.*.
  • docs/contributor/agent/observability.mdx — new page (sidebar position 6) cataloguing the new metrics and explaining how to read the per-user drop signal before raising the ring buffer default.

SPI changes

  • AttachedSandbox.createdAt() added with a sensible default. DockerAttachedSandboxAdapter already captured attachedAt internally — the SPI method now exposes it. The default keeps the contract additive (no SPI break for hypothetical consumers).
  • DockerAttachedSandboxAdapter.terminate(...) promoted from package-private to public (now implements the new InteractiveSandboxRegistry.ReapTarget interface — a narrow surface extracted so the reaper test can drive synthetic targets without mocking the final adapter).

Tests

  • FrameRingBufferTest rewritten to a Runnable onDrop callback (no more Counter coupling at the buffer layer).
  • FrameRingBufferMetricsTest (new) — debounce, distinct-user attribution, eviction reset, ceil-bound assertion across a 5.5s window.
  • InteractiveSandboxRegistryTest (new) — MAX_LIFETIME eviction, lifetime-beats-idle precedence, non-ATTACHED states skipped, IDLE path still works.
  • MentorChatServiceTest — two new cases for CAPACITY_EXCEEDED (per-user + global).
  • SandboxArchitectureTest — extended SPI boundary block with the Micrometer rule.
  • 2,666 unit + architecture tests pass locally.

Stranded issue housekeeping

Three issues from the #1081 epic that are obsolete:

I'll close these once this PR opens cleanly.

Test plan

  • Local: ./mvnw test -Dsurefire.includedGroups="unit,architecture" — 2,666 passed
  • New ArchUnit rule: agent.sandbox.spi.*io.micrometer.. verified by adding the rule (no SPI files import Micrometer today)
  • FrameRingBufferMetricsTest exercises debounce + cardinality at the unit level
  • InteractiveSandboxRegistryTest exercises MAX_LIFETIME with a fixed Clock
  • MentorChatServiceTest exercises CAPACITY_EXCEEDED routing for both PER_USER and GLOBAL scopes
  • Smoke-watch the interactive_sandbox.frame_ring.dropped_total{userId} counter on staging after merge — if drops show up at all, raise ringBufferFrames (this PR intentionally does not bump the default)
  • Confirm interactive_sandbox.evicted_total{reason=max_lifetime} fires after one hour of soak

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Sessions now support a configurable maximum lifetime cap, separate from idle timeout (default 60 minutes, configurable 5–480 minutes).
    • Capacity exceeded errors now distinguish between per-user and global capacity limits for clearer diagnostics.
  • Documentation

    • Added comprehensive observability documentation detailing Micrometer metrics, dimensions, and alerting guidance for the mentor pipeline and interactive sandbox layer.

Review Change Stack

- frame_ring.dropped_total{userId} counter with debounce + cardinality cap (50 users)
- interactive_sandbox.evicted_total{reason} counter
- MAX_LIFETIME reaper wired into InteractiveSandboxRegistry; previously dead enum value
- mentor.chat.outcome{CAPACITY_EXCEEDED} label distinguishes cap-rejections from errors
- ArchUnit: agent.sandbox.spi.* may not depend on Micrometer
- docs/contributor/agent/observability.mdx documents the new metrics

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR extends the interactive sandbox layer with structured capacity-exceeded error handling, max-lifetime session eviction, and refactored metrics infrastructure featuring per-user drop tracking with debounce. It introduces a new exception type, Clock-based reaping logic with testable interfaces, decouples ring-buffer drop counting from Micrometer, and adds comprehensive observability documentation.

Changes

Interactive Sandbox Capacity, Lifetime, and Metrics

Layer / File(s) Summary
Capacity-Exceeded Exception & Service Integration
server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/spi/InteractiveSandboxCapacityExceededException.java, server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatService.java, server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatMetrics.java, server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatServiceTest.java
New InteractiveSandboxCapacityExceededException with Scope enum (PER_USER/GLOBAL) replaces string-based capacity errors. MentorChatService explicitly handles the exception, logs, interrupts turn, and completes stream with scope-aware user message. MentorChatMetrics.Outcome.CAPACITY_EXCEEDED outcome is recorded. Tests verify per-user and global capacity rejections route correctly.
Configuration: Lifetime & Metrics Cardinality
server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/InteractiveSandboxProperties.java, server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/MetricsCardinalityConfig.java
maxLifetimeMinutes config property (default 60, range 5–480) enables absolute session lifetime caps. Spring bean MetricsCardinalityConfig enforces 50-userId cardinality ceiling on interactive_sandbox.frame_ring.dropped metric.
Registry Eviction: Clock, ReapTarget Interface, Max-Lifetime Logic
server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxRegistry.java, server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/spi/AttachedSandbox.java
InteractiveSandboxRegistry gains Clock field (with test seam constructor) and delegates reap() to new reapInternal(Iterable<ReapTarget>) helper. New ReapTarget interface abstracts eviction targets (state, createdAt, idleFor, sessionId, terminate). Reaping checks max-lifetime before idle timeout. AttachedSandbox SPI adds default createdAt() returning session attachment time.
Adapter: ReapTarget Implementation & Eviction Metrics
server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerAttachedSandboxAdapter.java
DockerAttachedSandboxAdapter implements ReapTarget, exposes createdAt() via attachedAt, makes terminate(EvictionReason) public. On close, records interactive eviction reason counter and clears per-session drop debounce entry.
Ring Buffer: Callback Abstraction from Micrometer Counter
server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBuffer.java
FrameRingBuffer refactored from Counter droppedCounter to generic Runnable onDrop parameter. Callback invoked on frame overflow; any RuntimeException swallowed to preserve buffer correctness. Javadoc expanded on non-blocking, monitor-held semantics.
Metrics: Interactive Evictions & Per-User Drop Debounce
server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxMetrics.java
Adds Clock test seam constructor, EnumMap for interactive eviction counters (reason-tagged), ConcurrentHashMap for per-user dropped counters, and debounce table (userId, sandboxId) using AtomicLong CAS for 1s interval throttling. Methods recordRingBufferDrop(userId, sandboxId) routes drops with debounce; evictDropDebounce(userId, sandboxId) clears state on close.
Sandbox Attachment: Capacity Exception & Drop Routing
server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerInteractiveSandboxAdapter.java
attach() throws new InteractiveSandboxCapacityExceededException (with Scope) instead of generic exception. buildSandbox() passes lambda to FrameRingBuffer capturing sandboxId and userId to route drops via metrics instead of using shared counter.
Test: Registry Eviction (Max-Lifetime & Idle)
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxRegistryTest.java
New test class validates MAX_LIFETIME precedence over IDLE, proper termination reasons, and CLOSING state guards. Uses deterministic Clock backed by AtomicReference and synthetic ReapTarget fake.
Test: Ring Buffer Metrics Debounce & Per-User Tracking
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferMetricsTest.java
New test class verifies per-user drop counter increments at most once per 1s window while global counter increments on every drop. Covers multiple users, time advancement, and debounce eviction. Includes deterministic clock helper.
Test: API Updates & Architecture Boundary
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferTest.java, server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/SubscribeOrderingPropertyTest.java, server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerInteractiveSandboxLiveTest.java, server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/SandboxArchitectureTest.java
Existing tests updated to pass Runnable onDrop callbacks instead of Counter instances. SandboxArchitectureTest adds SPI boundary rule ensuring no Micrometer imports leak into ..agent.sandbox.spi...
Documentation: Metrics Catalog & Observability Guidance
docs/contributor/agent/observability.mdx
New MDX page catalogs mentor and interactive-sandbox Micrometer metrics: eviction counters, capacity outcomes, active sessions, frame drops with debounce semantics, legacy names, cardinality capping, and tuning/alerting guidance.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The PR introduces multiple interlocking features: structured exception hierarchy, Clock-based time abstraction with testable seams, refactored metrics infrastructure with debounce logic, and registry eviction policy changes. The metrics refactoring (FrameRingBuffer + InteractiveSandboxMetrics) is dense with concurrent state management (ConcurrentHashMap, AtomicLong CAS). Broad test coverage includes deterministic Clock implementations and synthetic targets. Architectural boundary enforcement (SPI no-Micrometer rule) adds verification. The changes span capacity management, eviction scheduling, and metrics instrumentation with moderate logic density and cross-file dependencies requiring careful coordination.

Possibly related PRs

  • ls1intum/Hephaestus#1080: Both PRs extend interactive sandbox core—InteractiveSandboxRegistry, FrameRingBuffer, InteractiveSandboxMetrics—refactoring ring-buffer drop tracking, eviction behavior, and metrics emission.

Suggested labels

application-server, documentation, size:XXL, infrastructure, refactor, enhancement

Poem

🐰 Lifetimes now tick and debounce rings true,
Capacity doors close when queues are full too.
Metrics flow free from their Micrometer cage,
Per-user, per-session—observability's new stage.
The reaper knows time, the clock never lies,

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'chore(server): mentor capacity observability + dead-code cleanup' is clear, concise, and directly summarizes the main changes: adding observability (metrics and documentation) for mentor capacity handling and refactoring.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 mentor-observability-cleanup

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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 and usage tips.

@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation Preview

Preview has been removed (PR closed)

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (5)
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxRegistryTest.java (1)

60-127: ⚡ Quick win

Add exact-threshold tests (== maxLifetime, == idleTtl) to lock in boundary semantics.

Current cases validate strictly-over-threshold eviction. Adding equality cases will prevent off-by-one policy regressions and document intended > behavior explicitly.

🤖 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
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxRegistryTest.java`
around lines 60 - 127, Add tests in InteractiveSandboxRegistryTest that assert
equality-to-threshold does NOT trigger eviction (policy is strict '>'). Create
two new tests (e.g. evictsAtMaxLifetime and evictsAtIdleTtl) that use
clockOf/nowRef, buildRegistry, and propertiesWith to set maxLifetime and
idleTtl, construct FakeReapTarget instances whose age or idle duration is
exactly equal to the configured thresholds, call registry.reapInternal(...) and
assert the target.terminatedWith is null (not EvictionReason.MAX_LIFETIME or
EvictionReason.IDLE). Ensure you reuse FakeReapTarget, reapInternal,
InteractiveSandboxProperties, and EvictionReason symbols so the tests mirror
existing ones but check the == boundary.
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatServiceTest.java (1)

360-396: ⚡ Quick win

Assert scope-specific error text in the two capacity tests.

These tests prove the metric outcome, but they don’t verify the per-user vs global user-facing message split that was just introduced. Please assert the emitted error payload text for each scope so this contract can’t regress silently.

🤖 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
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatServiceTest.java`
around lines 360 - 396, Add assertions in the two failing tests
(runTurn_perUserCapExceeded_recordsCapacityExceeded and
runTurn_globalCapExceeded_recordsCapacityExceeded) to verify the emitted error
payload text matches the scope-specific message from the thrown
InteractiveSandboxCapacityExceededException; after calling runTurnSync() and
verifying persistence.interrupt(...), inspect the test emitter (use
emitter.recordedPayloads() or the equivalent recorded payload accessor) and
assert it contains "Per-user session cap exceeded" for Scope.PER_USER and
"Per-replica session cap exceeded" for Scope.GLOBAL so the user-facing message
contract is enforced alongside the outcome metric assertion (keep the existing
verify and assertOutcomeRecorded calls).
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferTest.java (1)

66-66: ⚡ Quick win

Align the new test name with should...When....

throwingCallbackIsSwallowed should follow the standard naming convention for unit tests in this module.

As per coding guidelines: server/application-server/src/test/java/**/*.java: Use should[ExpectedBehavior]When[Condition] naming convention for test methods.

🤖 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
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferTest.java`
at line 66, Rename the test method throwingCallbackIsSwallowed in
FrameRingBufferTest to follow the module convention, e.g.
shouldSwallowThrowingCallbackWhenInvoked (or similar
should[ExpectedBehavior]When[Condition] form); update the method name on the
test (and any references or test runners) so the test still runs and keep the
implementation and `@Test` annotation unchanged.
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/SandboxArchitectureTest.java (1)

58-58: ⚡ Quick win

Use should...When... naming for the new architecture test method.

The new method name is clear, but it doesn’t match the configured test naming convention.

As per coding guidelines: server/application-server/src/test/java/**/*.java: Use should[ExpectedBehavior]When[Condition] naming convention for test methods.

🤖 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
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/SandboxArchitectureTest.java`
at line 58, Rename the test method spiHasNoMicrometerDeps in class
SandboxArchitectureTest to follow the should[ExpectedBehavior]When[Condition]
pattern (e.g., shouldHaveNoMicrometerDependenciesWhenInspectingSpi or similar),
update its method declaration name accordingly, and ensure any references (IDE
run configurations or annotations) remain consistent after the rename so the
test still runs.
server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferMetricsTest.java (1)

31-31: ⚡ Quick win

Rename test methods to the repository’s should...When... convention.

Current method names are descriptive, but they don’t follow the enforced naming pattern for tests under src/test/java.

As per coding guidelines: server/application-server/src/test/java/**/*.java: Use should[ExpectedBehavior]When[Condition] naming convention for test methods.

Also applies to: 57-57, 94-94, 114-114

🤖 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
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferMetricsTest.java`
at line 31, Rename the test method debouncesWithinOneSecondWindow in
FrameRingBufferMetricsTest to follow the repository convention
should[ExpectedBehavior]When[Condition], e.g. change
debouncesWithinOneSecondWindow to shouldDebounceWhenWithinOneSecondWindow; also
apply the same renaming pattern to the other test methods referenced (lines 57,
94, 114) so each becomes should<ExpectedBehavior>When<Condition> (use clear
verbs like "Debounce", "Drop", "Aggregate" as appropriate) and update any
related `@Test` annotations or references to the method names accordingly.
🤖 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 `@docs/contributor/agent/observability.mdx`:
- Around line 14-35: Update the metric documentation to use the actual emitted
tag value: change the header and any inline references from
"mentor.chat.outcome{outcome=CAPACITY_EXCEEDED}" to
"mentor.chat.outcome{outcome=capacity_exceeded}" and update the explanatory text
to mention outcome=capacity_exceeded (keeping the guidance to distinguish it
from outcome=error); locate the metric block for mentor.chat.outcome and adjust
the tag value casing to snake_case to match other documented tag values like
reason=idle.

In
`@server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerAttachedSandboxAdapter.java`:
- Around line 408-412: The metrics.evictDropDebounce(userId, sessionId) call is
only executed on the success path in DockerAttachedSandboxAdapter and can be
skipped if runClose(...) throws; move the debounce-entry cleanup into a finally
block so it always runs (even on exceptions). Update the try/catch in the method
that calls runClose(...) inside DockerAttachedSandboxAdapter to call
metrics.evictDropDebounce(userId, sessionId) from a finally block, and apply the
same change to the other similar block (around lines handling the other runClose
path referenced in the comment) so both code paths always evict the
per-(user,session) debounce entry.

In
`@server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxMetrics.java`:
- Around line 60-66: The unbounded ConcurrentHashMap userDroppedCounters in
InteractiveSandboxMetrics can grow indefinitely; replace it with a bounded cache
(e.g., Caffeine Cache<String, Counter> with a sensible maximumSize and
expireAfterAccess) and change lookups to use cache.get(key, k ->
createCounterForUser(k)) or cache.asMap() where computeIfAbsent was used, so
counters auto-evict and you maintain thread-safety and lazy creation; apply the
same replacement to the other per-user counter map used later in the class (the
per-user counter block at lines ~212-219) so both caches are size-capped and use
the same eviction policy.

In
`@server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/spi/AttachedSandbox.java`:
- Around line 54-63: The default createdAt() implementation on AttachedSandbox
currently returns Instant.now() and masks missing SPI implementations; change
the contract so AttachedSandbox.createdAt() is abstract (remove the default
method) so implementors must provide a stable creation instant, or alternatively
replace the default with a fail-fast implementation that throws
UnsupportedOperationException referencing createdAt() to surface missing
overrides; update all classes implementing AttachedSandbox to supply a concrete
createdAt() that captures and returns the creation Instant.

---

Nitpick comments:
In
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatServiceTest.java`:
- Around line 360-396: Add assertions in the two failing tests
(runTurn_perUserCapExceeded_recordsCapacityExceeded and
runTurn_globalCapExceeded_recordsCapacityExceeded) to verify the emitted error
payload text matches the scope-specific message from the thrown
InteractiveSandboxCapacityExceededException; after calling runTurnSync() and
verifying persistence.interrupt(...), inspect the test emitter (use
emitter.recordedPayloads() or the equivalent recorded payload accessor) and
assert it contains "Per-user session cap exceeded" for Scope.PER_USER and
"Per-replica session cap exceeded" for Scope.GLOBAL so the user-facing message
contract is enforced alongside the outcome metric assertion (keep the existing
verify and assertOutcomeRecorded calls).

In
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferMetricsTest.java`:
- Line 31: Rename the test method debouncesWithinOneSecondWindow in
FrameRingBufferMetricsTest to follow the repository convention
should[ExpectedBehavior]When[Condition], e.g. change
debouncesWithinOneSecondWindow to shouldDebounceWhenWithinOneSecondWindow; also
apply the same renaming pattern to the other test methods referenced (lines 57,
94, 114) so each becomes should<ExpectedBehavior>When<Condition> (use clear
verbs like "Debounce", "Drop", "Aggregate" as appropriate) and update any
related `@Test` annotations or references to the method names accordingly.

In
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferTest.java`:
- Line 66: Rename the test method throwingCallbackIsSwallowed in
FrameRingBufferTest to follow the module convention, e.g.
shouldSwallowThrowingCallbackWhenInvoked (or similar
should[ExpectedBehavior]When[Condition] form); update the method name on the
test (and any references or test runners) so the test still runs and keep the
implementation and `@Test` annotation unchanged.

In
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxRegistryTest.java`:
- Around line 60-127: Add tests in InteractiveSandboxRegistryTest that assert
equality-to-threshold does NOT trigger eviction (policy is strict '>'). Create
two new tests (e.g. evictsAtMaxLifetime and evictsAtIdleTtl) that use
clockOf/nowRef, buildRegistry, and propertiesWith to set maxLifetime and
idleTtl, construct FakeReapTarget instances whose age or idle duration is
exactly equal to the configured thresholds, call registry.reapInternal(...) and
assert the target.terminatedWith is null (not EvictionReason.MAX_LIFETIME or
EvictionReason.IDLE). Ensure you reuse FakeReapTarget, reapInternal,
InteractiveSandboxProperties, and EvictionReason symbols so the tests mirror
existing ones but check the == boundary.

In
`@server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/SandboxArchitectureTest.java`:
- Line 58: Rename the test method spiHasNoMicrometerDeps in class
SandboxArchitectureTest to follow the should[ExpectedBehavior]When[Condition]
pattern (e.g., shouldHaveNoMicrometerDependenciesWhenInspectingSpi or similar),
update its method declaration name accordingly, and ensure any references (IDE
run configurations or annotations) remain consistent after the rename so the
test still runs.
🪄 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: 5e193ec4-9988-4c49-8a71-9aaa9e21ce9b

📥 Commits

Reviewing files that changed from the base of the PR and between f8fdb02 and 6032ffb.

📒 Files selected for processing (19)
  • docs/contributor/agent/observability.mdx
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatMetrics.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatService.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/InteractiveSandboxProperties.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerAttachedSandboxAdapter.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerInteractiveSandboxAdapter.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBuffer.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxMetrics.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxRegistry.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/spi/AttachedSandbox.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/spi/InteractiveSandboxCapacityExceededException.java
  • server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/MetricsCardinalityConfig.java
  • server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/mentor/chat/MentorChatServiceTest.java
  • server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/SandboxArchitectureTest.java
  • server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerInteractiveSandboxLiveTest.java
  • server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferMetricsTest.java
  • server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/FrameRingBufferTest.java
  • server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxRegistryTest.java
  • server/application-server/src/test/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/SubscribeOrderingPropertyTest.java

Comment on lines +14 to +35
### `interactive_sandbox.evicted_total{reason}`

Counter, one increment per sandbox eviction.

| Tag value | Meaning |
| --- | --- |
| `idle` | Session evicted because it crossed `hephaestus.mentor.idle-ttl-seconds`. |
| `max_lifetime` | Session evicted because it crossed `hephaestus.mentor.max-lifetime-minutes` (absolute lifetime cap, default 60 min). |
| `manual` | Explicit `close()` from the chat layer (e.g., poisoned sandbox after a `-32002`). |
| `error` | Forced eviction due to a pump/writer fault. |
| `natural_exit` | The runner subprocess exited cleanly. |
| `daemon_unhealthy` | Docker daemon health check failed during reap. |

`mentor.session.eviction{reason}` carries the same data with the legacy name retained for
back-compat — both increment together. Alert on the SPI-aligned name.

### `mentor.chat.outcome{outcome=CAPACITY_EXCEEDED}`

Counter, increments when a turn is rejected because `InteractiveSandboxRegistry.tryRegister`
returned `MAX_SESSIONS_PER_USER` or `MAX_SESSIONS_TOTAL`. **Distinguish from
`outcome=ERROR` when alerting** — capacity rejections are policy-driven and expected under load
(answer: add a replica or raise the cap); `ERROR` is a genuine failure that needs investigation.

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify eviction reason enum values and chat outcome enum values match documented tag formats.

# Check EvictionReason enum definition
echo "=== EvictionReason enum ===" 
rg -n "enum EvictionReason" -A 20 --type java

# Check for CAPACITY_EXCEEDED in chat outcome enum
echo -e "\n=== Chat outcome enum with CAPACITY_EXCEEDED ==="
rg -n "CAPACITY_EXCEEDED" -B 5 -A 5 --type java

# Check for eviction reason usage in metrics recording
echo -e "\n=== Eviction reason metric recording ==="
rg -n "evicted_total|session.eviction" -B 2 -A 2 --type java

Repository: ls1intum/Hephaestus

Length of output: 25098


Change mentor.chat.outcome{outcome=CAPACITY_EXCEEDED} to mentor.chat.outcome{outcome=capacity_exceeded}

The eviction reason values are correctly documented with snake_case tag values (idle, max_lifetime, etc.), but the chat outcome is documented with the enum constant name in SCREAMING_SNAKE_CASE (CAPACITY_EXCEEDED) instead of the actual snake_case tag value that is emitted (capacity_exceeded). For consistency and clarity, use the actual tag value format.

🤖 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 `@docs/contributor/agent/observability.mdx` around lines 14 - 35, Update the
metric documentation to use the actual emitted tag value: change the header and
any inline references from "mentor.chat.outcome{outcome=CAPACITY_EXCEEDED}" to
"mentor.chat.outcome{outcome=capacity_exceeded}" and update the explanatory text
to mention outcome=capacity_exceeded (keeping the guidance to distinguish it
from outcome=error); locate the metric block for mentor.chat.outcome and adjust
the tag value casing to snake_case to match other documented tag values like
reason=idle.

Comment on lines +408 to 412
metrics.interactiveEvictedBy(reason).increment();
// Free the per-(user,sandbox) debounce entry: the 50-userId cardinality cap bounds
// the absolute table size, but evicting per session keeps churn under control.
metrics.evictDropDebounce(userId, sessionId);

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Run debounce-entry cleanup in finally so error-path closes don't retain stale entries.

metrics.evictDropDebounce(userId, sessionId) currently runs only on the happy path. If runClose(...) throws and falls into the catch block, the per-session debounce entry is never evicted.

Proposed fix
-            metrics.interactiveEvictedBy(reason).increment();
-            // Free the per-(user,sandbox) debounce entry: the 50-userId cardinality cap bounds
-            // the absolute table size, but evicting per session keeps churn under control.
-            metrics.evictDropDebounce(userId, sessionId);
+            metrics.interactiveEvictedBy(reason).increment();
@@
         } finally {
+            // Ensure per-session debounce state is released on both success and error paths.
+            metrics.evictDropDebounce(userId, sessionId);
             MDC.clear();
         }

Also applies to: 425-438

🤖 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
`@server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/DockerAttachedSandboxAdapter.java`
around lines 408 - 412, The metrics.evictDropDebounce(userId, sessionId) call is
only executed on the success path in DockerAttachedSandboxAdapter and can be
skipped if runClose(...) throws; move the debounce-entry cleanup into a finally
block so it always runs (even on exceptions). Update the try/catch in the method
that calls runClose(...) inside DockerAttachedSandboxAdapter to call
metrics.evictDropDebounce(userId, sessionId) from a finally block, and apply the
same change to the other similar block (around lines handling the other runClose
path referenced in the comment) so both code paths always evict the
per-(user,session) debounce entry.

Comment on lines +60 to +66
* Per-user counter for {@code interactive_sandbox.frame_ring.dropped_total}. The cardinality
* cap (50 distinct userIds across the JVM) is enforced upstream by a {@code MeterFilter} —
* any further userId triggers a {@code denyAll} for THIS specific counter only, so the rest
* of the registry is unaffected.
*/
private final ConcurrentHashMap<String, Counter> userDroppedCounters = new ConcurrentHashMap<>();

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound the in-memory per-user counter cache to avoid unbounded growth.

userDroppedCounters is never evicted/capped, so it can grow with unique userId churn over process lifetime even if registry cardinality is limited. This creates a memory-growth path in a hot metric path.

💡 Suggested bounded-cache approach
+    private static final int USER_DROP_COUNTER_CACHE_MAX = 50;
+
     private Counter userDroppedCounter(String userId) {
+        Counter existing = userDroppedCounters.get(userId);
+        if (existing != null) {
+            return existing;
+        }
+
+        // Keep local cache bounded; registry-side cardinality capping is separate.
+        if (userDroppedCounters.size() >= USER_DROP_COUNTER_CACHE_MAX) {
+            return Counter.builder("interactive_sandbox.frame_ring.dropped")
+                .tag("userId", userId)
+                .description("Per-user frame ring overflow drops (debounced to <=1/s per session, capped at 50 distinct users)")
+                .register(registry);
+        }
+
         return userDroppedCounters.computeIfAbsent(userId, u ->
             Counter.builder("interactive_sandbox.frame_ring.dropped")
                 .tag("userId", u)
                 .description("Per-user frame ring overflow drops (debounced to <=1/s per session, capped at 50 distinct users)")
                 .register(registry)
         );
     }

Also applies to: 212-219

🤖 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
`@server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/docker/interactive/InteractiveSandboxMetrics.java`
around lines 60 - 66, The unbounded ConcurrentHashMap userDroppedCounters in
InteractiveSandboxMetrics can grow indefinitely; replace it with a bounded cache
(e.g., Caffeine Cache<String, Counter> with a sensible maximumSize and
expireAfterAccess) and change lookups to use cache.get(key, k ->
createCounterForUser(k)) or cache.asMap() where computeIfAbsent was used, so
counters auto-evict and you maintain thread-safety and lazy creation; apply the
same replacement to the other per-user counter map used later in the class (the
per-user counter block at lines ~212-219) so both caches are size-capped and use
the same eviction policy.

Comment on lines +54 to +63
/**
* Wall-clock instant the session was attached. Used by the registry's lifetime reaper to
* evict sessions that have accumulated state past a configured maximum. Every implementation
* captures this at construction time — the {@code default} fallback here exists only so a
* future SPI consumer that forgets to implement it returns a sensible value (caller treats
* "now" as a brand-new session, so the reaper simply won't fire).
*/
default Instant createdAt() {
return Instant.now();
}

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

createdAt() default currently masks missing SPI implementation and bypasses lifetime eviction.

Because the default returns Instant.now() on each call, Duration.between(createdAt(), now) stays near zero, so MAX_LIFETIME never triggers for implementations that forget to override this method. Prefer enforcing this contract (abstract method) or fail-fast default instead of silent fallback.

Proposed contract-tightening diff
-    default Instant createdAt() {
-        return Instant.now();
-    }
+    Instant createdAt();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Wall-clock instant the session was attached. Used by the registry's lifetime reaper to
* evict sessions that have accumulated state past a configured maximum. Every implementation
* captures this at construction timethe {@code default} fallback here exists only so a
* future SPI consumer that forgets to implement it returns a sensible value (caller treats
* "now" as a brand-new session, so the reaper simply won't fire).
*/
default Instant createdAt() {
return Instant.now();
}
/**
* Wall-clock instant the session was attached. Used by the registry's lifetime reaper to
* evict sessions that have accumulated state past a configured maximum. Every implementation
* captures this at construction timethe {`@code` default} fallback here exists only so a
* future SPI consumer that forgets to implement it returns a sensible value (caller treats
* "now" as a brand-new session, so the reaper simply won't fire).
*/
Instant createdAt();
🤖 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
`@server/application-server/src/main/java/de/tum/in/www1/hephaestus/agent/sandbox/spi/AttachedSandbox.java`
around lines 54 - 63, The default createdAt() implementation on AttachedSandbox
currently returns Instant.now() and masks missing SPI implementations; change
the contract so AttachedSandbox.createdAt() is abstract (remove the default
method) so implementors must provide a stable creation instant, or alternatively
replace the default with a fail-fast implementation that throws
UnsupportedOperationException referencing createdAt() to surface missing
overrides; update all classes implementing AttachedSandbox to supply a concrete
createdAt() that captures and returns the creation Instant.

@FelixTJDietrich

Copy link
Copy Markdown
Collaborator Author

Folding into #1086 (mentor sticky cookies) — the observability items belong with the replica-affinity work, not as a standalone follow-up.

FelixTJDietrich added a commit that referenced this pull request May 17, 2026
Items folded in from closed PRs #1089, #1090, #1091 — they belong here
alongside the sticky-cookie + replica-affinity work, not as standalone
follow-ups.

- MentorReplicaAffinityCheck HealthIndicator (refuses to start on
  multi-replica without operator-asserted sticky routing)
- WorkspaceContextBuilder mentor striping: hash (contributorId, workspaceId)
  instead of returning null and collapsing every mentor session onto
  stripe 0
- AttachedSandbox.subscribe(Cursor) — collapse the default-method
  subscribeFromNow seam; reused sandboxes were replaying prior turn's
  agent_end
- Per-thread frame routing pushed into the SPI as Predicate<JsonNode>
- JsonlStdinWriter synchronized → ReentrantLock (JEP 444 pin risk under
  virtual-thread callers)
- FrameRingBuffer dropped_total metric with bounded cardinality
- EvictionReason.MAX_LIFETIME wired (165 MB RSS × N idle users)
- MentorChatMetrics.Outcome.CAPACITY_EXCEEDED so dashboards stop being
  blind to per-user-cap rejections

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

1 participant