Skip to content

fix(approval): clean up waiters dropped by turn timeouts#4786

Open
samrusani wants to merge 1 commit into
tinyhumansai:mainfrom
samrusani:codex/4774-approval-waiter-drop-cleanup
Open

fix(approval): clean up waiters dropped by turn timeouts#4786
samrusani wants to merge 1 commit into
tinyhumansai:mainfrom
samrusani:codex/4774-approval-waiter-drop-cleanup

Conversation

@samrusani

@samrusani samrusani commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Cleans up parked approval waiters when an outer turn deadline drops the waiting future.
  • Removes chat and live-meeting routing entries before durable cleanup, so late replies cannot target a dead request.
  • Marks still-pending approval rows denied without overwriting a concurrently committed decision.
  • Adds chat and in-call regressions that externally abort the parked future and verify all cleanup layers.

Problem

ApprovalGate::intercept_audited previously cleaned up its waiter, routing entries, and pending approval row only after tokio::time::timeout(...).await returned. When the surrounding turn was torn down by a wall-clock backstop, the future was dropped before those match arms ran. That left stale in-memory routes and a durable pending row until TTL expiry, so a later yes/no reply could be routed to a request that no longer had a live turn.

Solution

An armed Drop guard now owns cleanup responsibility while the approval is parked. Normal decision and timeout paths disarm it after their existing cleanup. If the future is dropped externally, the guard:

  1. evicts the oneshot waiter;
  2. clears chat and meeting routing entries;
  3. conditionally records a durable denial.

The in-memory cleanup happens before the SQLite write so a persistence error cannot leave a reply route pointing at a dead waiter. The existing conditional store::decide update preserves any decision that won a concurrent race.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage >= 80% - the new abort regressions exercise every cleanup step and existing approval-gate tests exercise the normal disarm path; CI remains the authoritative percentage check because cargo-llvm-cov is not installed locally.
  • Coverage matrix updated - N/A: behavior-only lifecycle cleanup; no feature row was added, removed, or renamed.
  • All affected feature IDs from the matrix are listed in the PR description under ## Related - N/A: no matrix feature row changed.
  • No new external network dependencies introduced (mock backend used per Testing Strategy)
  • Manual smoke checklist updated if this touches release-cut surfaces - N/A: internal approval lifecycle cleanup only.
  • Linked issue closed via Closes #NNN in the ## Related section

Impact

  • Runtime/platform: Rust core approval gate across desktop/web chat and live-meeting approval routes.
  • Security/reliability: externally cancelled turns fail closed and no longer leave actionable stale approval state.
  • Performance/migration/compatibility: no migration or dependency change; normal approval, denial, and TTL behavior is unchanged.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: codex/4774-approval-waiter-drop-cleanup
  • Commit SHA: dbcc54bc9a03b6a0b67654117254ac4477795351

Validation Run

  • pnpm --filter openhuman-app format:check - passed in the pre-push hook.
  • pnpm typecheck - passed in the pre-push hook.
  • Focused tests: env GGML_NATIVE=OFF cargo test --manifest-path Cargo.toml --lib openhuman::approval::gate::tests:: (31 passed, 0 failed).
  • Rust fmt/check (if changed): cargo fmt --manifest-path Cargo.toml --all --check and env GGML_NATIVE=OFF cargo clippy -p openhuman passed; clippy reported the existing warning baseline.
  • Tauri fmt/check (if changed): N/A for source scope; the pre-push pnpm --filter openhuman-app rust:check completed successfully.

Validation Blocked

  • command: cargo llvm-cov --version
  • error: no such command: llvm-cov
  • impact: Local diff coverage was not generated; CI must verify the required 80% changed-line threshold before review-ready status.

Behavior Changes

  • Intended behavior change: dropping a parked approval future externally evicts the waiter, clears reply routes, and denies the pending row.
  • User-visible effect: late yes/no messages are no longer consumed by a dead approval request after a turn timeout.

Parity Contract

  • Legacy behavior preserved: normal approve, deny, channel-drop, and TTL paths keep their existing outcomes and cleanup behavior.
  • Guard/fallback/dispatch parity checks: chat and live-meeting routing are both covered; conditional durable denial preserves concurrent decisions.

Duplicate / Superseded PR Handling

Summary by CodeRabbit

  • Bug Fixes
    • Aborted or cancelled approval requests are now cleaned up reliably.
    • Stale chat and in-call routing entries are removed when approval storage fails or a request is interrupted.
    • Interrupted approvals now receive a durable denial decision, preventing requests from remaining indefinitely pending.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

ApprovalGate::intercept_audited now cleans up externally dropped parked approvals, including waiters, routing mappings, pending rows, and durable denial decisions. Normal completion disarms the guard, and tests cover chat and meeting routing.

Changes

Parked approval cleanup

Layer / File(s) Summary
Drop-based cleanup guard
src/openhuman/approval/gate.rs
ParkedApprovalCleanupGuard removes abandoned waiters and routing mappings, then persists a terminal Deny decision.
Interception lifecycle wiring
src/openhuman/approval/gate.rs
The guard is installed before routing, meeting mappings are cleared when pending-row persistence fails, and normal completion disarms the guard.
External-abort regression tests
src/openhuman/approval/gate.rs
Tests cover cleanup and durable denial for externally aborted chat-routed and meeting-routed approvals.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit who guards every wait,
Dropped approval? I tidy the gate.
Threads vanish, meetings clear,
Deny is recorded, crisp and sincere.
Then hops the test suite—great!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: cleanup of parked approval waiters when they are dropped.
Linked Issues check ✅ Passed The Drop-based cleanup, deny persistence, and regression tests address the leak described in #4774.
Out of Scope Changes check ✅ Passed The changes stay focused on approval-gate cleanup and related regression tests, with no unrelated functionality added.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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.

@samrusani samrusani marked this pull request as ready for review July 12, 2026 07:58
@samrusani samrusani requested a review from a team July 12, 2026 07:58

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dbcc54bc9a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +251 to +252
self.gate.clear_thread(&self.thread_id);
self.gate.clear_meeting(&self.in_call_ctx);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid clearing newer approval routes

When an old parked approval is cancelled because a newer turn starts, the old task is only signalled and can be dropped after the new turn has already parked its own approval on the same thread/meeting. These key-only clears then remove whatever request is currently stored for that thread/meeting, so a fresh approval's yes/no reply no longer routes through pending_for_thread/pending_for_meeting and the new request can hang until TTL. Please remove the mapping only if it still points at this guard's request_id.

Useful? React with 👍 / 👎.

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

🧹 Nitpick comments (2)
src/openhuman/approval/gate.rs (2)

1400-1479: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid regression coverage for the external-abort cleanup path.

Both tests correctly validate waiter eviction, routing-map cleanup, and durable Deny persistence for chat- and meeting-routed parks. One gap worth considering: there's no test exercising the race this design is built to survive — decide() committing an Approve* decision concurrently with the abort, verifying the guard's store::decide(Deny) becomes a no-op (per the WHERE decided_at IS NULL conditional) rather than clobbering the already-persisted decision. That's the core correctness guarantee cited in the PR objectives ("conditional updates preserve concurrently committed decisions") and isn't directly covered here.

Want me to draft a test that calls gate.decide(&request_id, ApprovalDecision::ApproveOnce) immediately before handle.abort() and asserts the persisted decision stays ApproveOnce?

🤖 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/openhuman/approval/gate.rs` around lines 1400 - 1479, Add regression
coverage for the abort/decision race in the existing external-abort waiter
tests: commit an approval via gate.decide using the captured request_id
immediately before aborting the spawned waiter, then assert cleanup still occurs
and store::get_decision remains ApprovalDecision::ApproveOnce. Ensure the test
exercises the conditional Deny cleanup path so it cannot overwrite an
already-persisted approval.

206-271: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Blocking SQLite write inside Drop::drop can stall the runtime thread performing the cancellation.

Drop::drop (lines 237-270) calls store::decide(&self.gate.config, &self.request_id, ApprovalDecision::Deny) synchronously (line 254). Drop cannot yield, so whichever thread runs this destructor — plausibly a Tokio worker thread reclaiming the task during abort() or an outer-deadline drop — is blocked for the duration of the SQLite write/lock acquisition. Tokio's own docs call this out explicitly: "if you call a non-async method from async code, that non-async method is still inside the asynchronous context, so you should also avoid blocking operations there. This includes destructors of objects destroyed in async code."

This mirrors the existing synchronous store::decide calls at lines 842/862, but doing it in Drop is strictly worse: there's no way to bound it with a timeout or defer it, and it fires precisely during the failure scenario this PR targets (turn-deadline cancellation), which is exactly when many tasks might be torn down in a burst.

Consider offloading via tokio::task::spawn_blocking (fire-and-forget, logging errors from the spawned task) so the drop path never blocks its calling thread — or explicitly document that local SQLite writes are treated as acceptable "fast enough" blocking work here.

♻️ Possible direction (fire-and-forget via spawn_blocking)
         match store::decide(&self.gate.config, &self.request_id, ApprovalDecision::Deny) {
-            Ok(Some(_)) => tracing::debug!(...),
-            Ok(None) => tracing::debug!(...),
-            Err(err) => tracing::error!(...),
-        }
+        }
+        let config = self.gate.config.clone();
+        let request_id = self.request_id.clone();
+        tokio::task::spawn_blocking(move || {
+            match store::decide(&config, &request_id, ApprovalDecision::Deny) {
+                Ok(Some(_)) => tracing::debug!(request_id = %request_id, "..."),
+                Ok(None) => tracing::debug!(request_id = %request_id, "..."),
+                Err(err) => tracing::error!(request_id = %request_id, error = %err, "..."),
+            }
+        });

Note Config must be cheaply cloneable (or wrapped in Arc) for this to work without extra cost — worth confirming before adopting.

🤖 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/openhuman/approval/gate.rs` around lines 206 - 271, Remove the
synchronous store::decide call from ParkedApprovalCleanupGuard::drop so
destructor execution never performs SQLite I/O. Preserve the existing in-memory
cleanup and denial behavior by cloning or otherwise safely capturing the
required config and request_id, then dispatching the denial persistence through
tokio::task::spawn_blocking with equivalent success and error logging inside the
spawned task; handle any task-spawn failure without blocking Drop.
🤖 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.

Nitpick comments:
In `@src/openhuman/approval/gate.rs`:
- Around line 1400-1479: Add regression coverage for the abort/decision race in
the existing external-abort waiter tests: commit an approval via gate.decide
using the captured request_id immediately before aborting the spawned waiter,
then assert cleanup still occurs and store::get_decision remains
ApprovalDecision::ApproveOnce. Ensure the test exercises the conditional Deny
cleanup path so it cannot overwrite an already-persisted approval.
- Around line 206-271: Remove the synchronous store::decide call from
ParkedApprovalCleanupGuard::drop so destructor execution never performs SQLite
I/O. Preserve the existing in-memory cleanup and denial behavior by cloning or
otherwise safely capturing the required config and request_id, then dispatching
the denial persistence through tokio::task::spawn_blocking with equivalent
success and error logging inside the spawned task; handle any task-spawn failure
without blocking Drop.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 16d5574d-ba95-469e-bc01-db894687d36f

📥 Commits

Reviewing files that changed from the base of the PR and between d575f9d and dbcc54b.

📒 Files selected for processing (1)
  • src/openhuman/approval/gate.rs

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.

Approval waiter/pending row leaks when a parked tool call is torn down by the turn wall-clock backstop

1 participant