Skip to content

fix(runtime): concurrency, lifecycle, cancellation, graceful shutdown, Dream wiring (wave 3) - #619

Merged
ginccc merged 21 commits into
mainfrom
fix/code-review-concurrency
Jul 30, 2026
Merged

fix(runtime): concurrency, lifecycle, cancellation, graceful shutdown, Dream wiring (wave 3)#619
ginccc merged 21 commits into
mainfrom
fix/code-review-concurrency

Conversation

@ginccc

@ginccc ginccc commented Jul 28, 2026

Copy link
Copy Markdown
Member

Stacked on #618#617#616. 37 files.

16 fixed, 1 partial. This is the wave where the obvious fix is often the wrong one, so the reasoning matters more than usual.

C1 — cancellation cancelled nothing

CompletableFuture.cancel(true) does not interrupt a runAsync/supplyAsync body. The JDK documents mayInterruptIfRunning as having no effect there. Five call sites in GroupConversationService relied on it, so "cancelled" agent threads kept mutating gc.getTaskList(), gc.getTranscript() and the errors list after the orchestrator had already persisted the document.

Replaced with a cooperative MemberTurnCancellation token checked at the agent turn's own await points, plus a bounded drain. This is also the root cause of C7resetStrandedInProgressTasks iterates a snapshot then mutates by id, so a falsely-"cancelled" thread could flip a task between those two steps and strand the very task the method exists to rescue.

C4 — a ~100-turn scalability cliff

The inner pipeline was submitted through the same bounded pool as the outer coordinator callable, which then blocked on future.get(). With no quarkus.thread-pool.* overrides that's the 200-thread default: at ~100 concurrent turns every thread is a waiter, no inner task can ever be scheduled, and every turn fails at the 60s watchdog. A cliff, not a gradual slowdown.

Nested submissions now route to a virtual-thread executor via a ThreadLocal marker scoped to the callable body.

Why not CompletableFuture composition (the work order's first suggestion): the coordinator's ordering contract is "the callable returns ⇒ the turn is done". Making the outer non-blocking would require an IEventBus/IConversationCoordinator SPI change and would let the next turn of the same conversation start while the previous one is still running — trading a throughput bug for a correctness bug. Virtual threads are safe here: there is not a single @RequestScoped bean in src/main, and three existing callers (SlackEventHandler, GroupConversationService, RestGroupConversation) already drive this pipeline with no request context on virtual-thread executors. The marker clears before callbacks run, so submitNext still schedules on the managed executor exactly as before, and watchdog/timeout semantics are unchanged.

C9 / C13 — a completed turn could be re-executed

onComplete sat inside the try whose catch (Throwable) called onFailure — which resubmitted the already-executed callable as a retry. LLM calls, tool side effects and cost, all running twice. Compounded by C13: the coordinator retried 3× blindly, and since onFailure can only be raised from inside the executor task, every retry re-ran a turn that may already have spent money.

C5 — /rerun destroyed output and regenerated nothing

The component-cache key was built from a sublist-relative index while the cache stores under the absolute index. The output task then ran with component == null and no-op'd — after the prior output was already deleted. indexOffset was already threaded in and used only for HITL bookkeeping.

⚠️ Why this survived 12,000 tests: every existing LifecycleManagerTest stubs the component map empty — the exact condition that hides the bug. The new test populates it at absolute indices and fails if the offset is removed. This is the J4 problem (mocks that erase the defect) in its purest form.

B2 — the finding's premise was inverted, and I'd flag this one

The review claimed "interrupt flags swallowed in 18 of 20 handlers". Auditing all 28 sites individually found the opposite: 14 already restore the flag correctly, 9 rethrow, and only 4 genuinely swallowed it.

The audit found two things the finding didn't:

  • ScheduleFireExecutor — a broad catch (Exception) swallowing InterruptedException from latch.await(5, MINUTES), so the poller kept firing schedules after being interrupted for shutdown. Invisible to a catch (InterruptedException) grep.
  • NatsConversationCoordinator — the mirror bug: interrupt() called unconditionally on catch (InterruptedException | TimeoutException), so a drain timeout left the shutdown thread flagged and would abort the @PreDestroy steps running after it.

One restore is deliberately in a finally after the store round trips rather than at the top of the catch — the sync Mongo driver aborts with MongoInterruptedException when the calling thread is flagged, so an early restore would skip the very EXECUTION_INTERRUPTED write that branch exists to perform.

B3 — no graceful shutdown existed at all

grep -rn ShutdownEvent src/main matched nothing. A rolling deploy dropped every queued and in-flight turn, with no drain and no readiness flip.

I1 / G8 — Dream wired up

Per your decision, registered with ScheduleFireExecutor rather than deleted, with the ceiling switched from maxSummarizationCalls to the dollar-based maxCostPerRun the project's own guidance prescribes.

G8 matters much more now that Dream actually runs: consolidation upgraded self visibility to global whenever a group spanned multiple agents — and with summarizeGroupBy defaulting to category and preserveAgentProvenance defaulting to false, cross-agent grouping was the default path. Two agents' private memories became one entry every agent could read.

Partial

F6 — REST/pipeline half done (ConnectionCallback sets cancelled on client disconnect; cancellation checked at more points). The modules/llm half — checks inside the tool loop and cascade — is deferred to that workstream rather than reached across into.

Verification

  • Clean compile passed first attempt, no repairs needed, despite three cross-workstream signature changes.
  • Full suite: 12,633 tests, 0 non-environmental failures.
  • All four mutation checks bite — C1, C5, C9, B2 each fail a test when reverted. Verified against whole test classes, and surefire reports were checked to confirm the new test classes actually executed rather than being silently skipped.
  • Concurrency tests use latches/barriers to force the interleaving deterministically rather than sleeping, and carry @Timeout.

Summary by CodeRabbit

  • New Features

    • Added Dream Consolidation scheduling with estimated LLM cost tracking and optional cross-agent maintenance.
    • Added graceful shutdown gating with readiness health signaling and improved draining behavior.
    • Added cooperative cancellation for in-flight group member turns.
    • Added bounded TTL caching for REST semantic parsers.
  • Bug Fixes

    • Hardened schedule cross-user ownership checks, consistent timezone defaults, and Dream schedule validation.
    • Prevented stale persistence, processing-gauge leaks, stranded queued work, and ensured dead-lettering happens on first failure.
    • Improved REST/SSE resilience for client disconnects and capacity limits; preserved interrupt signals.
  • Documentation

    • Expanded Dream configuration/scheduling docs and updated changelog.
  • Tests

    • Added/updated coverage for shutdown, interrupts, cancellation, streaming disconnects, Dream behavior, and schedule authorization.

…ancellation, graceful shutdown, Dream wiring

16 fixed, 1 partial. The wave where the obvious fix is often the wrong one.

C1 (critical): cancellation cancelled nothing. CompletableFuture.cancel(true)
does not interrupt a runAsync/supplyAsync body — the JDK documents
mayInterruptIfRunning as having no effect there. Five call sites relied on it, so
"cancelled" agent threads kept mutating the task list, transcript and errors
after the orchestrator had already persisted the group document. Replaced with a
cooperative cancellation token checked at the turn's own await points. This is
also the root cause of C7, where resetStrandedInProgressTasks could strand the
very task it exists to rescue.

C4: a ~100-turn scalability cliff. The inner pipeline was submitted through the
same bounded pool as the outer coordinator callable and then blocked on get().
At the 200-thread default, ~100 concurrent turns leaves every thread a waiter, no
inner task can be scheduled, and every turn fails at the watchdog. Nested
submissions now route to a virtual-thread executor via a ThreadLocal marker
scoped to the callable body. Chosen over CompletableFuture composition because
the coordinator's contract is "callable returns => turn is done"; making the
outer non-blocking would need an SPI change and would let the next turn of the
same conversation start while the previous one still ran.

C9/C13: a completed turn could be re-executed. onComplete sat inside the try
whose catch(Throwable) called onFailure, which resubmitted the already-executed
callable — LLM calls, tool side effects and cost running twice. Completion moved
outside the guarded region and both callbacks gated behind a one-shot flag; the
coordinator's blind 3x retry removed.

C3: a timed-out turn still persisted over a newer one, because the
stale-completion guard used the interrupt flag and the pipeline cleared it.
Replaced with a per-submission abandonment token set before delegating cancel().

C10: a throwing submit left the callable queued with nothing scheduled to run it,
wedging that conversation permanently and leaking the map entry.

C5: /rerun destroyed output and regenerated nothing — the component-cache key was
built from a sublist-relative index while the cache stores under the absolute
index, so the output task ran with a null component after the prior output was
already deleted. It survived because every existing LifecycleManagerTest stubs
the component map empty, which is exactly the condition that hides it.

B3: no graceful shutdown existed anywhere — no ShutdownEvent observer in src/main
at all, so a rolling deploy dropped every queued and in-flight turn.

B2: the finding's premise was inverted. It claimed 18 of 20 handlers swallow the
interrupt; auditing all 28 sites found 14 already restore correctly and 9
rethrow, with only 4 genuine swallowers. Fixed those, plus a site hidden behind a
broad catch(Exception) in ScheduleFireExecutor (the poller kept firing after being
interrupted for shutdown) and the mirror bug in NatsConversationCoordinator,
which restored the flag unconditionally and would abort its own @PreDestroy.

I1/G8: DreamService wired to ScheduleFireExecutor per the owner's decision, with
a dollar-based maxCostPerRun ceiling. G8 matters much more now it actually runs:
consolidation upgraded self visibility to global whenever a group spanned
multiple agents, and cross-agent grouping was the default path.

F6 partial: the REST/pipeline half (ConnectionCallback sets cancelled on client
disconnect) is done; the in-modules/llm half is deferred to that workstream.

Clean compile passed first attempt despite three cross-workstream signature
changes. 12,633 tests, 0 non-environmental failures. C1, C5, C9 and B2 all
mutation-checked against whole test classes.
@ginccc
ginccc requested a review from rolandpickl as a code owner July 28, 2026 20:15
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c41a7fc8-2f39-4ca0-9dae-9a699b6da268

📥 Commits

Reviewing files that changed from the base of the PR and between 1bca6d3 and 8c53323.

📒 Files selected for processing (4)
  • docs/changelog.md
  • src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java
  • src/main/java/ai/labs/eddi/engine/runtime/client/workflows/WorkflowStoreClientLibrary.java
  • src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main/java/ai/labs/eddi/engine/runtime/client/workflows/WorkflowStoreClientLibrary.java
  • src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java
  • src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java

📝 Walkthrough

Walkthrough

The PR updates Dream consolidation, conversation cancellation and shutdown, runtime failure handling, group execution, lifecycle indexing, parser caching, schedule authorization, streaming disconnect handling, interrupt propagation, documentation, and regression tests.

Changes

Runtime orchestration and Dream consolidation

Layer / File(s) Summary
Dream configuration and consolidation
src/main/java/ai/labs/eddi/..., docs/*, src/test/java/ai/labs/eddi/.../DreamService*
Dream adds cross-agent scope control, cost tracking, ownership validation, failure classification, schedule processing, privacy boundaries, and deprecated call-count compatibility.
Conversation admission and shutdown
src/main/java/ai/labs/eddi/engine/internal/ConversationService.java, Conversation.java, GracefulShutdownService.java
Conversation admission, processing gauges, watchdog abandonment, cancellation persistence, bounded shutdown draining, and readiness state handling are updated.
Group execution and runtime coordination
src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java, src/main/java/ai/labs/eddi/engine/runtime/..., src/test/java/...
Cooperative member cancellation, transcript snapshots, nested submissions, callback gating, immediate dead-lettering, queue rollback, and concurrency validation are implemented.
Scheduling, REST, and streaming
src/main/java/ai/labs/eddi/engine/runtime/internal/Schedule*, src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java, RestAgentEngine*.java
Dream schedules use a dedicated fire path, ownership and timezone checks are centralized, interrupt-safe logging is added, capacity rejections return 503, and disconnected SSE clients trigger graceful cancellation.
Lifecycle, workflow, and parser handling
src/main/java/ai/labs/eddi/engine/lifecycle/..., runtime/client/workflows/..., modules/nlp/..., modules/rules/...
Selective execution uses absolute task indexes, interrupt signals propagate through lifecycle tasks, skipped workflow schemes no longer shift cache keys, and parser instances use bounded Caffeine TTL caching.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • labsai/EDDI#493: Dream consolidation and summarization paths overlap directly with this PR’s Dream scheduling, configuration, and cost/error handling changes.
  • labsai/EDDI#531: BaseRuntime interrupt-aware completion and failure routing overlap with this PR’s runtime submission changes.
  • labsai/EDDI#572: Group task execution and failure recording overlap with this PR’s GroupConversationService orchestration changes.

Suggested reviewers: rolandpickl, copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.33% 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 title accurately reflects the main runtime-focused changes: concurrency, lifecycle, cancellation, graceful shutdown, and Dream wiring.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/code-review-concurrency

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.

@ginccc

ginccc commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ginccc

ginccc commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 7 minutes.

@ginccc
ginccc requested a review from aisabella-ai July 28, 2026 20:37
Base automatically changed from fix/code-review-llm-memory to main July 29, 2026 18:00
…review fixes)

Both conflicts are two independent changes to the same code, where neither side
subsumes the other:

- ConversationService.processConversationStep — this branch extracted the body
  into runConversationStep so a try/finally could guarantee
  processingTurn.release() (C11); main added the caller-identity capture in the
  same method. Resolution keeps the C11 wrapper AND main's capture, with the
  capture staying OUTSIDE the returned lambda — it has to run on the REST
  request thread, since the lambda executes on a pool thread with no request to
  capture from. The bound callable is passed through runConversationStep into
  runGuardedConversationStep, which is exactly where main used it, so behaviour
  is unchanged in both directions.

- GroupConversationService parallel phase — this branch added cancellation
  propagation (the `cancellation` argument plus a MemberTurnCancelledException
  catch that surfaces it instead of fabricating a contribution); main wrapped
  the supplier in withIdentitySupplying so the caller follows the fan-out onto
  further virtual threads. Both kept. The cancellation catch must stay ABOVE
  the generic Exception catch, which would otherwise turn a cancellation back
  into an error transcript entry — the exact behaviour that catch exists to
  prevent.

The silent half, same as the #618 merge: main added a CallerIdentityContext
constructor parameter to both services. Git merged the production files
cleanly while three test files added by this branch no longer compiled against
the new signatures.

1,257 tests pass across ConversationService, GroupConversationService,
Conversation and CallerIdentity.
Copilot AI review requested due to automatic review settings July 29, 2026 18:17
@github-actions

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

return shuttingDown;
}

void onShutdown(@Observes ShutdownEvent shutdownEvent) {

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens EDDI’s runtime and lifecycle execution model around concurrency, cancellation, and shutdown semantics, and finishes wiring Dream consolidation into the cluster-aware schedule machinery. The focus is preventing “cancelled” or watchdog-abandoned work from continuing to mutate/persist state, avoiding executor starvation under load, and making shutdown behavior explicit and observable.

Changes:

  • Reworked runtime/coordinator execution to avoid nested thread-pool starvation, prevent post-timeout stale persistence, and enforce one-shot completion/failure callbacks.
  • Strengthened cooperative cancellation across lifecycle execution, REST streaming disconnects, group discussions, and long-term property persistence.
  • Added graceful shutdown readiness + drain support and wired Dream consolidation schedules through ScheduleFireExecutor.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/changelog.md Wave-3 changelog entry documenting the concurrency/lifecycle/shutdown/Dream work (includes one incorrect detail flagged in review).
docs/architecture.md Updates architecture docs to describe Dream’s schedule-based wiring and credential configuration.
src/main/java/ai/labs/eddi/configs/agents/model/AgentConfiguration.java Extends Dream config with parameters, deprecates call-count ceiling in favor of dollar budget.
src/main/java/ai/labs/eddi/engine/internal/ConversationService.java Adds shutdown accept-gate integration, in-flight tracking for cancellation/metrics, and watchdog/abandonment-related correctness fixes.
src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java Implements cooperative cancellation for member turns and multiple concurrency fixes around parallel phases/task execution waves.
src/main/java/ai/labs/eddi/engine/internal/RestAgentEngine.java Tightens cancel endpoint to a single, documented graceful mode and uses imported ControlSignal.
src/main/java/ai/labs/eddi/engine/internal/RestAgentEngineStreaming.java Detects SSE client disconnects via send-path checks and cancels the in-flight turn cooperatively.
src/main/java/ai/labs/eddi/engine/lifecycle/internal/LifecycleManager.java Fixes component-cache keying for selective execution via absolute task indices; strengthens cancellation checks.
src/main/java/ai/labs/eddi/engine/runtime/BaseRuntime.java Routes nested submissions to a virtual-thread executor; adds abandonment token + one-shot callback gating to prevent stale persistence and double-execution.
src/main/java/ai/labs/eddi/engine/runtime/client/workflows/WorkflowStoreClientLibrary.java Adds clarifying documentation about component cache key invariants (absolute workflow index).
src/main/java/ai/labs/eddi/engine/runtime/internal/Conversation.java Prevents cancelled turns/resumes from persisting long-term property side effects.
src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java Adds schedule entrypoint (processScheduledFire), parameterized summarization calls, privacy-safe grouping for self visibility, and improved metrics/error reporting.
src/main/java/ai/labs/eddi/engine/runtime/internal/GracefulShutdownService.java Introduces shutdown observer: readiness flip + accept gate + bounded drain (one interrupt-handling bug flagged in review).
src/main/java/ai/labs/eddi/engine/runtime/internal/InMemoryConversationCoordinator.java Removes retries-after-start; adds rejection rollback and drain-on-rejection behavior to avoid wedging/leaks.
src/main/java/ai/labs/eddi/engine/runtime/internal/NatsConversationCoordinator.java Fixes interrupt-flag handling so timeout during drain doesn’t poison the shutdown thread.
src/main/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutor.java Adds Dream consolidation fast-path; restores interrupt flag when latch-await is interrupted; logs Dream fire cost/error.
src/main/java/ai/labs/eddi/engine/runtime/internal/ShutdownReadinessHealthCheck.java Adds readiness check that flips DOWN when shutdown is signalled.
src/main/java/ai/labs/eddi/modules/nlp/InputParserTask.java Restores interrupt flag when catching InterruptedException so graceful-stop semantics propagate.
src/main/java/ai/labs/eddi/modules/nlp/impl/RestSemanticParser.java Replaces unsafe/unbounded parser cache with bounded Caffeine cache + TTL; adds invalidation and cache-size visibility for tests.
src/main/java/ai/labs/eddi/modules/rules/impl/RulesEvaluationTask.java Restores interrupt flag when catching InterruptedException so lifecycle interruption is not swallowed.
src/test/java/ai/labs/eddi/configs/agents/model/AgentConfigurationTest.java Adds coverage for Dream parameters defaults and null-safe setter behavior.
src/test/java/ai/labs/eddi/engine/internal/ConversationServiceHitlCoverage2Test.java Adds an interrupt-restore regression test ensuring interrupt restoration happens after state writes.
src/test/java/ai/labs/eddi/engine/internal/ConversationServiceProcessingGaugeTest.java Adds tests for processing gauge correctness under watchdog cancel, rejected turns, and shutdown accept-gate behavior.
src/test/java/ai/labs/eddi/engine/internal/ConversationServiceStaleTurnTest.java End-to-end test preventing watchdog-abandoned turns from persisting stale snapshots.
src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceConcurrencyTest.java Concurrency regression suite covering cooperative cancellation, transcript snapshot handoff, turn budget enforcement, and batch timeouts.
src/test/java/ai/labs/eddi/engine/internal/RestAgentEngineStreamingTest.java Tests that client disconnect cancels exactly once and does not cancel on unrelated send failures or terminal-frame timing.
src/test/java/ai/labs/eddi/engine/lifecycle/internal/LifecycleManagerTest.java Adds regression coverage for absolute component-cache indices in selective execution and cancellation timing windows.
src/test/java/ai/labs/eddi/engine/runtime/BaseRuntimeConcurrencyTest.java Concurrency tests for nested submission starvation avoidance, abandonment token behavior, and one-shot callback gating.
src/test/java/ai/labs/eddi/engine/runtime/internal/ConversationCancelPersistenceTest.java Verifies cancelled turns do not persist long-term properties, while non-cancel stop paths still persist.
src/test/java/ai/labs/eddi/engine/runtime/internal/DreamServiceExtendedTest.java Updates Dream tests for scheduled-fire path, agent-store resolution, cost ceiling, and visibility constraints.
src/test/java/ai/labs/eddi/engine/runtime/internal/GracefulShutdownServiceTest.java Tests readiness flip, accept gating, bounded drain, and shutdown-event triggering behavior.
src/test/java/ai/labs/eddi/engine/runtime/internal/InMemoryConversationCoordinatorTest.java Updates tests for “no retry after start” and rejection rollback/drain semantics.
src/test/java/ai/labs/eddi/engine/runtime/internal/ScheduleFireExecutorTest.java Adds coverage for interrupt restoration and Dream schedule dispatch behavior.
src/test/java/ai/labs/eddi/modules/nlp/InputParserTaskInterruptTest.java Ensures interrupt restoration and pipeline abort behavior when parser throws InterruptedException.
src/test/java/ai/labs/eddi/modules/nlp/impl/RestSemanticParserCacheTest.java Concurrency/bounding/invalidating tests for the new Caffeine-based parser cache.
src/test/java/ai/labs/eddi/modules/rules/impl/RulesEvaluationTaskInterruptTest.java Ensures interrupt restoration and pipeline abort behavior when rules evaluation throws InterruptedException.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +124 to +126
sleepQuietly(readinessGraceMillis);

long deadline = System.nanoTime() + drainTimeoutMillis * 1_000_000L;
Comment thread docs/changelog.md Outdated

### Partial

**F6** — the REST/pipeline half is done (a `ConnectionCallback` now sets cancelled on client disconnect, and cancellation is checked at more points). The in-`modules/llm` half — cancellation checks inside the tool loop and the cascade — is deferred, since that module is owned by another workstream.
Two Copilot findings on #619. A third — "the parameter 'shutdownEvent' is never
used" from the code-quality bot — is a false positive and is deliberately NOT
actioned: onShutdown(@observes ShutdownEvent) is a CDI observer, the parameter
IS the subscription, and removing it would silently unregister the shutdown
drain rather than tidy anything.

- drain() discarded sleepQuietly's return for the readiness grace while the poll
  loop right below it honoured the same value, and sleepQuietly's own javadoc
  says the caller should stop waiting. Now honoured, and interrupt is reported
  separately from timeout: "timed out after 30000 ms" for a drain cut short
  after 50 ms sends whoever reads that log hunting the wrong problem.

  Being precise about the size of this: it is a DIAGNOSIS fix, not a behavioural
  one, and Copilot's "will still proceed to wait/drain" overstates it. Because
  sleepQuietly restores the interrupt flag, the old code's next Thread.sleep
  threw immediately and it broke out just as promptly with the same boolean.
  Verified by mutation, not assumed: reverting the fix leaves all 9 tests green.
  The two new tests are therefore labelled as pinning the interrupt CONTRACT
  (prompt return, honest boolean, gate stays shut) and explicitly say they do
  not distinguish the fix — a confident javadoc over a test that proves nothing
  is exactly the test theatre this review pass keeps finding. What the fix does
  buy: accurate logs, and an exit that no longer depends on that restored-flag
  side effect.

- The F6 changelog entry claimed a ConnectionCallback sets cancelled on client
  disconnect. RestAgentEngineStreaming documents at the call site that
  ConnectionCallback is NOT invoked on this path and detects disconnects via
  SseEventSink.isClosed(). Corrected, and the correction says which approach was
  tried versus which shipped. Second time in this stack a changelog entry
  described intent rather than behaviour.
Copilot AI review requested due to automatic review settings July 29, 2026 18:34
Clean textual merge. Verified beyond that, because a clean auto-merge has
already broken compilation twice in this stack when main changed a constructor
signature this branch's new tests were written against.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/main/java/ai/labs/eddi/engine/runtime/internal/GracefulShutdownService.java:154

  • If the drain is interrupted during the poll sleep, inFlight is not re-checked before deciding success/failure. This can incorrectly return false (and log “still in flight”) when the last turn finished during the sleep and the interrupt arrives immediately after. Re-checking once on the interrupt path keeps the result/logging accurate without changing the shutdown contract.
        while (inFlight > 0 && System.nanoTime() < deadline) {
            if (!sleepQuietly(pollIntervalMillis)) {
                interrupted = true;
                break;
            }

Copilot AI review requested due to automatic review settings July 29, 2026 18:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 50 out of 50 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java:212

  • updateSchedule() validates the body but never applies defaults before computeInitialNextFire() and persisting. If the caller omits or blanks fields like timeZone/userId (which the ownership guard currently treats as exempt), this can either throw (ZoneId.of on blank timeZone) or store a schedule that later fires with an unowned/blank userId. Apply defaults on the update path too before computing nextFire and saving.
            validateSchedule(schedule);

            // Recompute nextFire
            computeInitialNextFire(schedule);

…on update

Copilot flagged that updateSchedule never applies defaults before persisting,
citing a blank timeZone throwing and an omitted userId being stored empty. Both
symptoms were real. One mechanism was not, and finding that out was the useful
part.

- The userId half is exactly as reported. Update skipped applyDefaults while
  create ran it, so an absent userId persisted as NULL rather than
  SCHEDULER_USER_ID — and since ownership treats null as unowned, an editor
  updating their OWN schedule silently made it writable by every other editor.
  applyDefaults now runs on update too, deliberately AFTER the ownership guards
  so they judge what the caller sent rather than what defaulting produced.

- The blank-timeZone half was NOT caused by the missing applyDefaults, and my
  first fix did not resolve it — the test I wrote to prove the fix kept failing,
  which is the only reason I looked further instead of shipping a plausible
  explanation. validateSchedule runs BEFORE defaulting on BOTH paths, and it was
  validateSchedule's own ZoneId.of that threw. The three ZoneId.of call sites
  disagreed about what "absent" means: one used the raw value, two null-checked
  but not blank — and a blank string is non-null, so it reached ZoneId.of("").

  So the defect was on CREATE as well as update, in a place the report did not
  name. Replaced with a single zoneOf() accessor holding one definition of
  absent (null OR blank), used by all three sites, and covered on both paths.
  Mutation-checked: reverting zoneOf to a null-only check fails both tests.

I corrected the code comment and the test javadoc that had recorded the wrong
mechanism, rather than leaving a confident explanation that happened to be
false — a misleading comment survives longer than the bug.

149 tests pass across the schedule surface.
Copilot AI review requested due to automatic review settings July 29, 2026 22:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 50 out of 50 changed files in this pull request and generated no new comments.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java (3)

2815-2818: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Propagate cancellation to nested group members.

The cancellation token is ignored for MemberType.GROUP; synchronous discuss(...) can therefore outlive a cancelled parallel batch or aborted task wave. The parent may reclaim its task after five seconds while the child discussion continues consuming resources. Track/cancel the child discussion or make nested execution cancellation-aware.

As per coding guidelines, “Conversation-related code must be thread-safe and non-blocking; ... task execution must not block for extended periods.”

🤖 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 `@src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java`
around lines 2815 - 2818, Update the MemberType.GROUP branch in
executeGroupMemberTurn so nested executeGroupMemberTurn/discuss execution
observes and propagates the parent cancellation token. Ensure cancellation
interrupts or terminates the child discussion promptly rather than allowing
synchronous discuss(...) to outlive the parent task or parallel batch.

Source: Coding guidelines


2066-2086: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Gate failure writes with the same cancellation lock as success writes.

Cancellation can arrive after the outer isCancelled() check but before handleTaskFailure(...). That handler can then fail a task reset by abortWave(...) and append a late error transcript entry. Re-check cancellation while holding taskList, with the same taskList → transcript lock order used by the success path.

🤖 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 `@src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java`
around lines 2066 - 2086, Update the failure handling around handleTaskFailure
in the GroupDiscussionException and IllegalStateException catch blocks to
re-check cancellation while holding taskList, preserving the taskList →
transcript lock order used by successful writes. Skip failure handling and error
recording when cancellation is observed, preventing writes after abortWave
resets the task.

2070-2077: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Abort the entire wave on quota or ABORT failures.

A quota failure only returns from its worker, and ABORT only breaks that member’s loop. Other workers continue until allOf finishes, so they can complete tasks and write results after a terminal failure. Signal the wave cancellation immediately, then run abortWave(...) to reclaim in-progress tasks before propagating the original error.

🤖 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 `@src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java`
around lines 2070 - 2077, Update the failure handling in the agent task
execution flow around the quota check and protocol.onAgentFailure() to signal
wave cancellation immediately for both QuotaExceededException and ABORT
failures. Invoke abortWave(...) to reclaim in-progress tasks before propagating
the original exception, and ensure sibling workers stop without completing tasks
or writing results after the terminal failure.
src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java (1)

642-658: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not classify an unknown configured host as transient.

A bad LLM endpoint hostname raises UnknownHostException, but this path skips the group and reports a successful cycle indefinitely. That contradicts the documented permanent-endpoint-failure behavior and prevents schedule retries/dead-lettering.

Proposed fix
-            if (current instanceof SocketTimeoutException || current instanceof TimeoutException
-                    || current instanceof ConnectException || current instanceof UnknownHostException) {
+            if (current instanceof SocketTimeoutException || current instanceof TimeoutException
+                    || current instanceof ConnectException) {
                 return true;
             }
🤖 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 `@src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java` around
lines 642 - 658, Update isTransientLlmFailure in DreamService to stop treating
UnknownHostException as transient; retain transient classification for socket
timeouts, general timeouts, connection failures, and matching transient messages
so unknown configured hosts follow the permanent endpoint-failure path and
enable retries/dead-lettering.
🧹 Nitpick comments (3)
src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java (1)

890-893: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the mandated string-validation helper while preserving the blank check.

Replace message == null || message.isBlank() with RuntimeUtilities.isNullOrEmpty(message) || message.isBlank(); RuntimeUtilities.isNullOrEmpty(string) does not cover blank-only strings, and this path intentionally keeps that behavior for exception messages.

🤖 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 `@src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java` around
lines 890 - 893, The describe(Throwable e) method must use
RuntimeUtilities.isNullOrEmpty(message) for the null/empty check while retaining
message.isBlank() to detect whitespace-only messages. Update only the
conditional deciding between the exception class name and class-plus-message
output.

Source: Coding guidelines

src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java (1)

414-499: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add metrics for authorization-guard rejections.

requireOwnUserId and requireOwnUserIdOfStoredSchedule are new authorization surfaces (403 on cross-user access, 500 on unverifiable ownership) but emit no Micrometer signal — only log lines. A counter (e.g. eddi_schedule_ownership_denied_count tagged by operation/reason) would let ops detect probing/attack patterns against this guard without grepping logs.

As per coding guidelines, "Add metrics to new features using Micrometer MeterRegistry, including counters, timers, or gauges as appropriate."

🤖 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 `@src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java`
around lines 414 - 499, Add Micrometer counters to the authorization rejection
paths in requireOwnUserId and requireOwnUserIdOfStoredSchedule, tagging each
increment with the operation and rejection reason (such as denied ownership or
ownership verification failure). Reuse the class’s existing MeterRegistry/metric
conventions, incrementing before returning the 403 or 500 response while leaving
allowed and not-found flows unchanged.

Source: Coding guidelines

src/test/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStoreTest.java (1)

537-546: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Import jakarta.ws.rs.NotFoundException instead of using an inline FQN.

The test imports other Jakarta REST types and has no conflicting NotFoundException, so use a top-level import and keep assertThrows(NotFoundException.class, ...) consistent with the coding guideline.

🤖 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 `@src/test/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStoreTest.java`
around lines 537 - 546, Update the imports in RestScheduleStoreTest to add
jakarta.ws.rs.NotFoundException, then replace the fully qualified
NotFoundException reference in
updateSchedule_ofMissingSchedule_stillReportsNotFound with the imported class
name while leaving the test behavior unchanged.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java`:
- Around line 2815-2818: Update the MemberType.GROUP branch in
executeGroupMemberTurn so nested executeGroupMemberTurn/discuss execution
observes and propagates the parent cancellation token. Ensure cancellation
interrupts or terminates the child discussion promptly rather than allowing
synchronous discuss(...) to outlive the parent task or parallel batch.
- Around line 2066-2086: Update the failure handling around handleTaskFailure in
the GroupDiscussionException and IllegalStateException catch blocks to re-check
cancellation while holding taskList, preserving the taskList → transcript lock
order used by successful writes. Skip failure handling and error recording when
cancellation is observed, preventing writes after abortWave resets the task.
- Around line 2070-2077: Update the failure handling in the agent task execution
flow around the quota check and protocol.onAgentFailure() to signal wave
cancellation immediately for both QuotaExceededException and ABORT failures.
Invoke abortWave(...) to reclaim in-progress tasks before propagating the
original exception, and ensure sibling workers stop without completing tasks or
writing results after the terminal failure.

In `@src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java`:
- Around line 642-658: Update isTransientLlmFailure in DreamService to stop
treating UnknownHostException as transient; retain transient classification for
socket timeouts, general timeouts, connection failures, and matching transient
messages so unknown configured hosts follow the permanent endpoint-failure path
and enable retries/dead-lettering.

---

Nitpick comments:
In `@src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java`:
- Around line 890-893: The describe(Throwable e) method must use
RuntimeUtilities.isNullOrEmpty(message) for the null/empty check while retaining
message.isBlank() to detect whitespace-only messages. Update only the
conditional deciding between the exception class name and class-plus-message
output.

In `@src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java`:
- Around line 414-499: Add Micrometer counters to the authorization rejection
paths in requireOwnUserId and requireOwnUserIdOfStoredSchedule, tagging each
increment with the operation and rejection reason (such as denied ownership or
ownership verification failure). Reuse the class’s existing MeterRegistry/metric
conventions, incrementing before returning the 403 or 500 response while leaving
allowed and not-found flows unchanged.

In `@src/test/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStoreTest.java`:
- Around line 537-546: Update the imports in RestScheduleStoreTest to add
jakarta.ws.rs.NotFoundException, then replace the fully qualified
NotFoundException reference in
updateSchedule_ofMissingSchedule_stillReportsNotFound with the imported class
name while leaving the test behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 674733c9-c966-464c-8072-551145457ff6

📥 Commits

Reviewing files that changed from the base of the PR and between 45fad7c and 2185024.

📒 Files selected for processing (8)
  • docs/changelog.md
  • docs/user-memory.md
  • src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java
  • src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java
  • src/main/java/ai/labs/eddi/engine/runtime/internal/NatsConversationCoordinator.java
  • src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java
  • src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceConcurrencyTest.java
  • src/test/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStoreTest.java
💤 Files with no reviewable changes (1)
  • docs/user-memory.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/ai/labs/eddi/engine/runtime/internal/NatsConversationCoordinator.java

…e host is permanent

CodeRabbit's second review of #619. Two of its four Major findings are addressed
here; two are heavy-lift behavioural changes tracked separately.

- DreamService.isTransientLlmFailure classified UnknownHostException as
  transient, contradicting its own javadoc two lines above, which names "wrong
  endpoint" as the example of PERMANENT. A hostname that does not resolve is a
  misconfiguration: treating it as transient made every cycle skip its groups
  and report SUCCESS forever, so the schedule never retried, never dead-lettered,
  and nobody learned the endpoint was wrong. ConnectException stays transient by
  contrast — the host resolved and refused, which is a restarting service.

- The failure paths in the task-execution wave read the cancellation token
  WITHOUT the task-list monitor while the success path reads it inside. My own
  review pass refuted this on the grounds that abortWave is cancel() THEN
  allOf(futures).get(...), so the sweep cannot start until every worker returned.

  That refutation was WRONG, and I only found out by verifying the claim I was
  about to write into a comment: the join is bounded by
  MEMBER_TURN_CANCEL_DRAIN_SECONDS and proceeds to resetStrandedInProgressTasks
  on TIMEOUT. The timeout is reachable — a member parked in
  tryResolveMemberToolPause or in a nested GROUP discuss() never observes the
  token. So a late failure write really can race the sweep, marking a task FAILED
  that the sweep just reset and appending an error entry to a finished wave.

  Both failure paths now take the monitor across the check AND the write.
  handleTaskFailure is split into recordTaskFailure (document mutation, called
  under the monitor) and notifyTaskFailure (SSE listener, called outside it) —
  holding the monitor across a client write would stall sibling workers, which is
  why the success path emits its event outside the lock too. Lock order stays
  taskList -> transcript, and recordTaskFailure never touches taskList itself, so
  there is no inversion.

  Four tests reached handleTaskFailure by reflection and so failed at RUNTIME
  rather than compile time; repointed at recordTaskFailure, which is the half they
  actually assert on.

Also corrected MEMBER_TURN_CANCEL_DRAIN_SECONDS' javadoc, which claimed
cancellation releases turns at their await points so the timeout is "only a
safety bound". It is not: the two await points named above ignore the token, so
for a turn parked there the timeout is the mechanism, not the backstop. Third
docs-describe-intent finding in this stack.

578 tests pass across the group and Dream surfaces.
Copilot AI review requested due to automatic review settings July 29, 2026 22:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java (2)

2704-2709: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use the transcript monitor for every parallel-phase append.

The snapshot locks gc.getTranscript(), but success, timeout, cancellation, and error entries are appended without that lock. A delayed worker can snapshot while the orchestrator writes, reintroducing ConcurrentModificationException/inconsistent reads. Wrap every append in this phase with the same monitor (ideally via one helper).

As per coding guidelines, “Conversation-related code must be thread-safe and non-blocking.”

Also applies to: 2764-2800

🤖 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 `@src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java`
around lines 2704 - 2709, Ensure every transcript append in the parallel phase
uses the monitor of gc.getTranscript(), including success, timeout,
cancellation, and error paths near the orchestration logic. Centralize the
synchronized append in a helper if practical, and update all relevant call sites
while preserving the existing snapshot locking and non-blocking behavior.

Source: Coding guidelines


3012-3024: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Cancellation releases the waiter but leaves the member execution running.

Completing responseFuture exceptionally exits this worker, but it does not stop the already-started conversationService.say(...). Its later callback can still call propagateDynamicAgentTracking(...) and mutate gc after the abort sweep, terminal persistence, and ephemeral-agent cleanup—potentially losing cleanup tracking or racing the document state. Register cancellation ownership for the underlying member execution and ensure late callbacks cannot mutate the group after cancellation.

As per coding guidelines, “Conversation-related code must be thread-safe and non-blocking.”

🤖 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 `@src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java`
around lines 3012 - 3024, Update the member execution flow around responseFuture
and conversationService.say so cancellation owns and stops the underlying member
execution, not only the waiting future. Guard late completion callbacks,
including propagateDynamicAgentTracking, so they cannot mutate gc or related
group state after cancellation; keep the flow thread-safe and non-blocking while
preserving normal completion behavior.

Source: Coding guidelines

🧹 Nitpick comments (1)
src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java (1)

175-181: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the project string-validation helper, keeping whitespace-user rejection.

Replace agentId == null || agentId.isBlank() and userId == null || userId.isBlank() with isNullOrEmpty(...) from ai.labs.eddi.utils.RuntimeUtilities, then still reject userId when whitespace-only or equal to SCHEDULER_PLACEHOLDER_USER_ID per the schedule contract.

🤖 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 `@src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java` around
lines 175 - 181, Update the validation in the dream scheduling flow around the
agentId and userId checks to use RuntimeUtilities.isNullOrEmpty(...) for null or
empty detection. Preserve the existing rejection of whitespace-only userId
values and SCHEDULER_PLACEHOLDER_USER_ID, while keeping the current rejection
messages and behavior for invalid agentId values.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java`:
- Around line 2704-2709: Ensure every transcript append in the parallel phase
uses the monitor of gc.getTranscript(), including success, timeout,
cancellation, and error paths near the orchestration logic. Centralize the
synchronized append in a helper if practical, and update all relevant call sites
while preserving the existing snapshot locking and non-blocking behavior.
- Around line 3012-3024: Update the member execution flow around responseFuture
and conversationService.say so cancellation owns and stops the underlying member
execution, not only the waiting future. Guard late completion callbacks,
including propagateDynamicAgentTracking, so they cannot mutate gc or related
group state after cancellation; keep the flow thread-safe and non-blocking while
preserving normal completion behavior.

---

Nitpick comments:
In `@src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java`:
- Around line 175-181: Update the validation in the dream scheduling flow around
the agentId and userId checks to use RuntimeUtilities.isNullOrEmpty(...) for
null or empty detection. Preserve the existing rejection of whitespace-only
userId values and SCHEDULER_PLACEHOLDER_USER_ID, while keeping the current
rejection messages and behavior for invalid agentId values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 23b6e730-d292-4e8b-9da2-94cea5b7741b

📥 Commits

Reviewing files that changed from the base of the PR and between 2185024 and 1bca6d3.

📒 Files selected for processing (5)
  • src/main/java/ai/labs/eddi/engine/internal/GroupConversationService.java
  • src/main/java/ai/labs/eddi/engine/runtime/internal/DreamService.java
  • src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceHitlCoverage3Test.java
  • src/test/java/ai/labs/eddi/engine/internal/GroupConversationServiceTaskForceTest.java
  • src/test/java/ai/labs/eddi/engine/runtime/internal/DreamServiceTest.java

Changelog-only conflict, both entries kept — the collision I flagged when both
PRs carried a top entry. #621 merged first, so this branch absorbs it.

Compiles clean; no source overlap between the two PRs.
Copilot AI review requested due to automatic review settings July 29, 2026 23:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/main/java/ai/labs/eddi/engine/runtime/client/workflows/WorkflowStoreClientLibrary.java:96

  • The new warn log for skipped workflow steps logs documentDescriptor.getResource() and extensionType without sanitization. These values come from stored workflow configuration and can contain untrusted data (including newlines), which risks log-forging. Consider sanitizing before logging to match the existing LogSanitizer usage elsewhere in the codebase.
                if (!URI_SCHEME_ID.equals(extensionType.getScheme())) {
                    // Not an error — historically these were skipped silently and a
                    // stored config that still contains one must keep loading. But it
                    // disables a pipeline step, so say so instead of no-opping quietly.
                    LOGGER.warnf("Workflow '%s' declares step '%s', which does not use the '%s' URI scheme — "
                            + "the step is skipped and will not run.",
                            documentDescriptor.getResource(), extensionType, URI_SCHEME_ID);
                    continue;

… extra lock

CodeRabbit round 3, finding 1 of 2 — declined, with the reasoning recorded at the
site so it stops cycling.

The claim: the snapshot locks gc.getTranscript() but the success/timeout/
cancellation/error appends do not, so a delayed worker could snapshot mid-write
and hit ConcurrentModificationException.

Verified instead of complied with: GroupConversation guarantees the transcript is
always a Collections.synchronizedList — the field initializer wraps it and
setTranscript re-wraps, with no path assigning a bare list. That wrapper's mutex
IS the wrapper object, which is precisely what the snapshot block locks. So add()
and the snapshot already exclude one another; the explicit monitor is needed only
because List.copyOf ITERATES, which the wrapper cannot make atomic. Wrapping
every append would add lock scope in a hot path without removing a race.

Worth being explicit that this is the OPPOSITE conclusion from the taskList guard
fixed in the previous commit, where a near-identical-looking asymmetry was a real
bug. The difference is what the two sides are: there, a cancellation read and a
document write ordered only by the monitor; here, two operations on a single
synchronized collection. Recorded in the comment so the next reviewer gets the
distinction rather than the pattern match.

The second finding (a late conversationService.say callback mutating gc after the
abort sweep) is real and heavy-lift; tracked separately rather than redesigned as
round-7 feedback on an approved PR.
Copilot AI review requested due to automatic review settings July 29, 2026 23:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/main/java/ai/labs/eddi/engine/schedule/rest/RestScheduleStore.java:497

  • requireOwnUserIdOfStoredSchedule() reads the schedule from the store, and updateSchedule() also calls requireAdminForHitl() which performs its own scheduleStore.readSchedule(scheduleId). On the update path this results in two store round-trips per request (plus any later reads), which is avoidable overhead.

Consider reading the stored schedule once in updateSchedule() and reusing it for BOTH the HITL guard and the stored-owner guard (e.g., by refactoring the helpers to accept a preloaded ScheduleConfiguration, or by introducing a single helper that returns the loaded schedule + any guard Response).

    private Response requireOwnUserIdOfStoredSchedule(String scheduleId, String operation) {
        ScheduleConfiguration stored;
        try {
            stored = scheduleStore.readSchedule(scheduleId);
        } catch (IResourceStore.ResourceNotFoundException e) {
            return null; // no schedule to protect — let the downstream op surface its 404
        } catch (Exception e) {
            LOGGER.error("Failed to verify schedule ownership for " + sanitize(scheduleId) + " (" + sanitize(operation) + ")", e);
            return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
                    .entity("Unable to verify schedule authorization; refusing to " + operation + " schedule.")
                    .build();
        }
        if (stored == null) {
            return null;
        }
        return requireOwnUserId(stored.getUserId(), operation);

Copilot flagged the new skipped-workflow-step WARN, which logged
documentDescriptor.getResource() and extensionType raw. Both come from stored
workflow configuration, so this is the same log-forging class fixed eight times
earlier in this stack — this time in code added by the review-fix pass itself.

Rather than wait for a bot to find the rest one at a time, I swept the PR's own
diff with a statement-aware scan (the first grep over-reported, because
multi-line LOGGER calls put their arguments on continuation lines, and because
counts/costs/enum names are not forging vectors).

The scan separated inherited from introduced, which is what decided the scope:
79 of the flagged statements exist verbatim on main already, and 10 were
introduced or reworded by this PR — all of them in DreamService. Only the latter
belong in this PR.

DreamService's LOGGER statements now route userId, agentId, group.getKey() and
the rejection reason through LogSanitizer (19 statements in that file, being all
of them rather than only the 10 newly added — the file is already substantially
rewritten here, and leaving half its logs unsanitized would just invite the same
finding next round). Counts, costs and durations are untouched; only arguments
were rewritten, never the format strings.

The 79 pre-existing sites in other files are NOT touched here. They are a real
codebase-wide gap, but sanitizing them would balloon an already-approved 52-file
PR at round seven of review, and they are tracked separately.

122 tests pass across the Dream and schedule surfaces.
Copilot AI review requested due to automatic review settings July 29, 2026 23:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.

@ginccc
ginccc merged commit eb6e495 into main Jul 30, 2026
24 checks passed
@ginccc
ginccc deleted the fix/code-review-concurrency branch July 30, 2026 06:41
ginccc added a commit that referenced this pull request Jul 30, 2026
…e 6.2 polish)

#620 branched before three waves of security and correctness fixes landed, so
this merge had 20 conflicted files / 58 hunks — and the conflicting files were
precisely the ones whose current main versions ARE those fixes. Taking the wrong
side anywhere would have reverted shipped security work while still compiling,
and in several cases while still passing tests.

Resolution was per-hunk, with both sides preserved unless they were genuinely
irreconcilable. Two files show why no single rule would have worked:

- RestAuditStore: both sides had a head-anchor check. #620 used
  `skip <= 0 && entries.size() < limit`; main uses
  `entries.size() == countByConversation(id)`. They are not variants — main's IS
  the fix for #620's, because getEntries pages NEWEST-first, so skip==0 is the
  most recent page rather than the start of the chain. Combining them either way
  provably breaks something: OR reopens the false-BROKEN regression that reported
  ~990 entries deleted, AND drops prefix detection when count==limit exactly. So
  main's anchor was taken whole, while #620's DEFAULT_VERIFY_LIMIT and its
  undelivered-attribution path (INCOMPLETE for gaps the ledger itself caused) were
  kept.

- AuditLedgerService: the opposite shape. #620 refactored the queue-full check
  into reserveQueueSlot so a back-pressure drop can no longer burn a chain
  sequence and manufacture a BROKEN verdict; main had added LogSanitizer to the
  one log line that refactor deletes. Both applied — taking #620 alone would have
  silently dropped the log-injection hardening.

Also resolved: an add/add collision where #618 and #620 each independently
created LlmTaskStreamingDowngradeTest.java (merged into one file keeping every
distinct test from both), and OutputEntry, where the two compareTo designs are
contradictory by construction — main's declaration-order behaviour won, because
that is what governs the order of chat bubbles the end user sees.

RestAuditStoreTest auto-merged as a UNION of both sides and so got no conflict
and no scrutiny — which left #620's deletedHeadEntryIsDetected asserting against
the superseded heuristic without stubbing countByConversation. Mockito returned
0, the anchor never engaged, and the report came back INTACT. The test was stale,
not the code; fixed by adding the stub rather than by restoring the old anchor,
which is the tempting "fix" that would revert #617. Kept rather than deleted as
a duplicate, because only that copy asserts tamperingSuspected().

Verification, because a green build proves very little on a merge like this:
- full suite 13,312 tests — the only non-environmental failure was the audit test
  above, now fixed (734 tests green across every resolved area);
- all 20 shipped fixes explicitly checked still present, by pattern where
  possible and by reading where not: the @?? jsonpath escape, the 63-byte index
  truncation, A2 caller-ownership on attachments, clampSkip, the
  whole-conversation anchor, MAX_REPORTED_MISSING, duplicates-are-BROKEN,
  SEQUENCE_ORIGIN, supportsSequence gating, global entries keeping their owning
  agent, most_accessed recency reservation, LlmTask credential isolation,
  cross-server MCP dedupe, delegation-depth propagation, the identity capture
  outside the lambda, the C11 release in a finally, the shutdown accept gate, the
  v6 rename's existing-empty-target handling, and OutputEntry's declaration order.

One improvement came out of #620's side rather than main's: the delegation
context is now handed to InputData as a mutable copy instead of the immutable
Map.of, which closes the risk flagged when that fix was written.
ginccc added a commit that referenced this pull request Jul 30, 2026
…xemption

chunkStrategy has no reader — ingestion always builds a recursive splitter — so
an unsupported value is inert. Rejecting it at save time so the author hears
about it is right, but it was implemented twice, at two layers, with opposite
intentions:

  - RestRagStore.prepareForWrite (create/update): normalise aliases, else 400
  - RestRagStore.duplicateRag: normalise only, deliberately no rejection,
    because "a copy of an existing document must not be refused just because
    the rules tightened after it was stored"
  - RagStore.validate (store, every write): normalise aliases, else throw

The store hook runs on create and update, so it silently overrode the duplicate
exemption one layer down, breaking two paths:

  - duplicateRag refused to copy a document the same store serves via readRag
  - ZIP import via RestImportService.createNewRags writes through
    createResourceDirect, straight to the store with no REST layer in front,
    and catches only ResourceStoreException — so an IllegalArgumentException
    escaped and rolled back the ENTIRE agent import over an inert field.
    UpgradeExecutor replays documents the same way.

Both layers were tested and both tests passed.
RestRagStoreWriteValidationTest asserts duplicate returns 201 but builds
RestRagStore with mock(IRagStore.class), so create was a stub and the store's
hook never ran; RagStoreValidationTest asserted the store rejects that exact
value. Neither crossed the boundary, so the contradiction was invisible.

The store now normalises but never rejects. Legacy aliases are still rewritten
to the recursive they always meant — that has to live in the store because
import bypasses REST — while an unsupported value is left verbatim so a
duplicate is a faithful copy of its original. The author-facing 400 stays at
prepareForWrite, the only layer that can tell author input from a replayed
document; same layering as RestMcpCallsStore in #619.

New RagStoreLayeringTest wires the real RagStore behind the real RestRagStore,
mocking only storage, and pins the division of labour. Mutation-checked: with
content.validate() restored it fails 3 of 6 cases with the real
IllegalArgumentException, while RestRagStoreWriteValidationTest stays green —
which is precisely why the defect survived. RagStoreValidationTest's two
rejection cases are rewritten to assert leniency, recording that their original
assertion was the defect.
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