Skip to content

fix(groups): cadence claim had no expiry; a NATS publish failure could drop a turn - #657

Closed
ginccc wants to merge 3 commits into
mainfrom
fix/team-cadence-claim-lease
Closed

fix(groups): cadence claim had no expiry; a NATS publish failure could drop a turn#657
ginccc wants to merge 3 commits into
mainfrom
fix/team-cadence-claim-lease

Conversation

@ginccc

@ginccc ginccc commented Aug 9, 2026

Copy link
Copy Markdown
Member

1. A crashed pod wedged the cadence permanently

TeamCadenceService's Javadoc promised the run protocol was "crash-proof by construction: a pod crash mid-discussion loses nothing, because the next fire finds the terminal state and reconciles it."

That only holds if something moves the discussion to a terminal state. On a pod crash nothing does — no startup sweep touches an IN_PROGRESS GroupConversation, and HitlCrashRecoveryObserver handles only the AWAITING_* states. So reconcile read IN_PROGRESS, answered "still running", and did so on every subsequent fire forever. casRunningDiscussion was never released and the cadence stayed wedged until a human cancelled the discussion by hand.

A non-terminal, non-paused discussion whose progress heartbeat has not advanced within eddi.groups.cadence.abandoned-run-lease (default PT6H) is now cancelled, its tasks returned to the backlog, and the claim released.

The lease is measured on gc.getLastModified(), not on claim age. A claim-age lease cannot tell a dead pod from a healthy long-running discussion, and reclaiming a live one would orphan its outcomes and double-schedule its tasks — recreating, by design, the bug fixed in §2. The discussion loop persists the conversation at every phase boundary, so lastModified is a free liveness heartbeat.

AWAITING_* never expires, at any age. A discussion may legitimately wait on a human for days, and every surface that resolves one — resume, the cross-pod cancel CAS, the timeout policies re-armed at startup — works cross-pod, so a paused discussion on a dead pod still progresses. Expiring one would destroy a live pending approval.

CREATED is reclaimable — it is precisely the crash window between the claim CAS and the executor starting.

The abandoned discussion is cancelled before the claim is released, so a zombie loop that somehow survives cannot keep spending the cadence's budget on work nobody will collect.

2. A transient read error released the claim

reconcile caught every exception from conversationStore.read and treated it as "discussion gone", returning the pulled tasks to PENDING. A read failure while the discussion was genuinely running therefore let the next fire start a second discussion on the same backlog.

Narrowed to ResourceNotFoundException — provable absence — which is the distinction AgentDeploymentManagement.isAgentConfigMissing already makes one package over. Anything else skips the fire and keeps the claim.

3. A NATS publish failure could silently drop a conversation turn

publishAndExecute caught only IOException | JetStreamApiException, so any unchecked failure — the NATS client throws IllegalStateException on a closed or draining connection — escaped the method and skipped the submitCallable below it entirely. The turn was dropped with no execution, no callback and no dead-letter, while that very catch block logged "executing locally".

The publish is an ordering marker, never the work itself, so no publish failure may cost a conversation its turn.

Two invariants pinned that previously rested on prose

  • NatsCoordinatorInProcessInvariantTest asserts the NATS coordinator hands the callable to the local IRuntime rather than serializing it onto the stream. LiveDiscussionRegistry is per-node and its Javadoc names this dependency explicitly; if a member turn could ever run off-node the group task / artifact / recruit tools would not fail — they would silently not be assembled, and the model would lose them mid-discussion with nothing in the logs. The invariant was documented as verified in the changelog; it is now enforced.

  • StoreIfFieldEqualsContractTest pins that neither backend inherits the throwing default and that both declare both CAS outcomes. compareAndSetState, updateIfState and casRunningDiscussion are all implemented once over IResourceStorage.storeIfFieldEquals, so every CAS-based safety property in executeDiscussion and the claim protocol is exactly as good as that method is on the active backend.

    Reviewed in full as part of this work: the Mongo and Postgres implementations do agree — conditional update, rows == 0 → existence probe → 404-vs-409 — and the long overload correctly degrades to text on Postgres (where data->> renders a JSON number canonically) while Mongo needs typed BSON equality. Nothing tested any of it.

Disproved during review, not changed

Cadence.maxBacklogTasksPerRun was flagged as an unvalidated .limit() argument. It is not: the record's compact constructor clamps any non-positive value to the default, so .limit() can never receive a negative.

Testing

151 tests across TeamCadence / NATS / GroupWorkspace stay green. +14 new.

Summary by CodeRabbit

  • Bug Fixes

    • Improved cadence recovery by cancelling stale, non-terminal discussions while preserving active discussions and those awaiting human input.
    • Temporary storage-read failures no longer release active claims; only confirmed missing discussions are released.
    • Conversation turns continue locally when message publishing encounters an unexpected failure.
  • Improvements

    • Added configurable lease timing for abandoned discussions, with a six-hour fallback for invalid settings.
    • Added monitoring for abandoned-run recovery.
  • Tests

    • Expanded coverage for cadence recovery, local execution, and storage update behavior.

…ure dropping a turn

The claim protocol had no expiry. Its Javadoc promised crash-proofness via 'the
next fire finds the terminal state', but nothing moves a discussion to a
terminal state when its pod dies, so reconcile answered 'still running' forever
and the cadence was wedged until a human cancelled by hand. A non-terminal,
non-paused discussion whose progress heartbeat has not advanced within the lease
is now cancelled, its tasks returned, and the claim released.

The lease is measured on gc.getLastModified(), not claim age: a claim-age lease
cannot tell a dead pod from a healthy long-running discussion, and reclaiming a
live one would orphan its outcomes AND double-schedule its tasks. AWAITING_*
never expires — those resolve cross-pod. CREATED does, being the crash window
between the claim CAS and the executor starting.

reconcile also caught every read exception as 'discussion gone', so a transient
error double-scheduled the backlog; narrowed to ResourceNotFoundException.

NatsConversationCoordinator.publishAndExecute caught only the two checked types,
so an unchecked publish failure escaped and skipped the execution below it —
dropping the turn with no callback and no dead-letter, while the catch block
promised 'executing locally'.

+14 tests, including two that pin invariants previously resting on prose: NATS
callables execute in-process, and neither storage backend inherits the throwing
storeIfFieldEquals default.
@ginccc
ginccc requested a review from rolandpickl as a code owner August 9, 2026 13:56
@github-actions

github-actions Bot commented Aug 9, 2026

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

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@ginccc, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 36 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 22fbc580-435b-4705-9821-d69aee7df1e7

📥 Commits

Reviewing files that changed from the base of the PR and between 0303b82 and 7da9fdc.

📒 Files selected for processing (2)
  • src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java
  • src/test/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceClaimLeaseTest.java
📝 Walkthrough

Walkthrough

The PR adds abandoned-discussion recovery to team cadence processing, preserves claims on transient reads, and keeps local execution after NATS publish failures. It also adds storage CAS contract tests and runtime invariants.

Changes

Runtime reliability

Layer / File(s) Summary
Cadence claim recovery
src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java, src/test/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceClaimLeaseTest.java, src/test/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceServiceTest.java, docs/changelog.md
TeamCadenceService parses a positive abandoned-run lease, exempts human-waiting states, reclaims stale discussions, and releases claims only for confirmed missing resources. Tests cover recovery, liveness, read failures, and fallback configuration.
NATS publish fallback
src/main/java/ai/labs/eddi/engine/runtime/internal/NatsConversationCoordinator.java, src/test/java/ai/labs/eddi/engine/runtime/internal/NatsCoordinatorInProcessInvariantTest.java
Unchecked NATS publish failures are logged, then the original callable executes locally in the current JVM.
Storage CAS contract validation
src/test/java/ai/labs/eddi/datastore/StoreIfFieldEqualsContractTest.java, docs/changelog.md
The contract test validates storeIfFieldEquals defaults, overloads, backend implementations, and declared exceptions.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TeamCadenceService
  participant ConversationStore
  participant WorkspaceStore
  TeamCadenceService->>ConversationStore: Read claimed discussion
  ConversationStore-->>TeamCadenceService: Return discussion or read error
  TeamCadenceService->>TeamCadenceService: Check state and lease
  TeamCadenceService->>ConversationStore: Cancel stale discussion and write failure
  TeamCadenceService->>WorkspaceStore: Release claim
Loading

Possibly related PRs

  • labsai/EDDI#644: Introduced the cadence claim reconciliation and NATS execution behavior extended by this PR.

Suggested reviewers: rolandpickl

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% 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 clearly summarizes the two primary fixes: cadence claim expiry and local recovery after NATS publish failures.
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/team-cadence-claim-lease

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.

… log statements

Matches the file's existing convention (groupId was already sanitized) and
pre-empts the log-injection class CodeQL flagged on the sibling branches.
@ginccc

ginccc commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 9, 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.

@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: 1

🤖 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 `@src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java`:
- Around line 367-370: Update the last-progress check in TeamCadenceService so a
null gc.getLastModified() is treated as stale and eligible for reclamation
rather than active; only a non-null timestamp newer than the abandonedRunLease
threshold should return false. Add coverage for recovery of a group conversation
with null lastModified.
🪄 Autofix

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 Plus

Run ID: e6bd3642-dda4-4433-a1e4-07a8504881b8

📥 Commits

Reviewing files that changed from the base of the PR and between d5294a6 and 0303b82.

📒 Files selected for processing (7)
  • docs/changelog.md
  • src/main/java/ai/labs/eddi/engine/runtime/internal/NatsConversationCoordinator.java
  • src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java
  • src/test/java/ai/labs/eddi/datastore/StoreIfFieldEqualsContractTest.java
  • src/test/java/ai/labs/eddi/engine/runtime/internal/NatsCoordinatorInProcessInvariantTest.java
  • src/test/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceClaimLeaseTest.java
  • src/test/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceServiceTest.java

Comment thread src/main/java/ai/labs/eddi/engine/runtime/internal/TeamCadenceService.java Outdated
…ussion

CodeRabbit on #657 (Major): reclaimIfAbandoned treated a null lastModified as
'still advancing', which reinstates exactly the deadlock the lease exists to
break for any record whose lastModified was never written.

Falls back to the creation stamp — which the discussion-creation path always
sets, so a discussion created moments ago is not mistaken for an abandoned one —
and reclaims when neither timestamp exists, since that is the definition of no
progress ever recorded.

+2 tests: null-everything reclaims, null-lastModified-but-recently-created does
not.
@aisabella-ai
aisabella-ai self-requested a review August 10, 2026 17:39
@ginccc

ginccc commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Superseded — closing without merging.

While this branch was in review, main moved on: #648 (agent-lifecycle-and-group-deadlines), #649 (dynamic-agent-guardrails), #650 (cadence-claim-expiry) and #651 (deployment-wait-machinery) landed and address the same findings, in places more thoroughly than this PR did. Merging it now would duplicate or regress those.

I re-checked every finding in this PR against current main rather than assuming. What was already fixed there is dropped; what was genuinely still missing has been rebuilt on top of current main in:

No work is lost; the review threads here remain readable for the reasoning.

@ginccc ginccc closed this Aug 10, 2026
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.

2 participants