Skip to content

Fix expired pending approvals lingering after restart#2357

Open
Alexxigang wants to merge 1 commit into
tinyhumansai:mainfrom
Alexxigang:fix-approval-expiry-lifecycle
Open

Fix expired pending approvals lingering after restart#2357
Alexxigang wants to merge 1 commit into
tinyhumansai:mainfrom
Alexxigang:fix-approval-expiry-lifecycle

Conversation

@Alexxigang
Copy link
Copy Markdown

@Alexxigang Alexxigang commented May 20, 2026

Summary

  • lazily expire stale pending approvals in the SQLite store before listing actionable rows
  • reject late decisions for requests that have already expired on disk
  • preserve durable audit state for expired rows instead of leaving them pending forever
  • add regression tests for restart/orphaned-request expiry behavior

Why

expires_at was already being persisted for pending approvals, but the store never enforced it on read or decide paths. After a restart, an orphaned request could remain actionable in �pproval.list_pending indefinitely even though its TTL had already elapsed.

This change keeps the fix scoped to the expiry-lifecycle gap in #2135 and does not overlap with #2335's recent-decision audit read path.

Validation

  • compare-only diff confirmed a single-file targeted change in src/openhuman/approval/store.rs
  • replayed the new SQLite expiry transition/query logic in a standalone sqlite3 harness and verified expired rows are hidden from pending results and cannot be approved late
  • local Rust compile validation is currently blocked on this machine by a broken MSVC linker (link.exe rejects Rust object arguments during build-script linking), so I could not run cargo test end-to-end here

Summary by CodeRabbit

  • New Features

    • Pending approval requests now automatically expire and transition to a terminal state after a configured duration.
  • Improvements

    • Expired approval requests no longer appear as actionable pending items after system restart.
    • Enhanced error handling for approval processing.
  • Documentation

    • Updated durability and replay safety guarantees.

Review Change Stack

@Alexxigang Alexxigang requested a review from a team May 20, 2026 15:14
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

📝 Walkthrough

Walkthrough

The approval store adds durability-oriented expiration handling for pending SQLite rows. A new public expire_stale function transitions expired undecided rows to terminal state. list_pending and decide now lazily expire stale rows before reads, ensuring expired approvals never appear actionable after restart. Row decoding handles args_redacted parse failures gracefully.

Changes

Approval Store Expiration and Durability

Layer / File(s) Summary
Module contract and documentation
src/openhuman/approval/store.rs
Module documentation updated to describe durability safety via expires_at enforcement and replay safety for orphaned decide calls. Function doc comments are tightened to reflect new expiration behavior.
Expiration mechanism and read-path integration
src/openhuman/approval/store.rs
Public expire_stale function transitions expired undecided rows to terminal state by setting decided_at and persisting decision. list_pending and decide integrate lazy expiration before reads. Row decoding adjusted so args_redacted parse failures fall back to JSON null.
Test infrastructure and expiration validation
src/openhuman/approval/store.rs
Test helpers refactored with sample_with_expiry and fetch_decision_state. Comprehensive tests verify stale expiration behavior, idempotency of expire_stale, terminal audit persistence on late decisions, and non-expiring rows remaining pending.

Sequence Diagram

sequenceDiagram
  participant Client
  participant expire_stale
  participant expire_stale_with_now
  participant list_pending
  participant decide
  participant SQLite
  
  Client->>list_pending: list_pending()
  list_pending->>expire_stale: expire_stale()
  expire_stale->>expire_stale_with_now: expire_stale_with_now(Utc::now)
  expire_stale_with_now->>SQLite: UPDATE undecided rows where expires_at < now
  SQLite-->>expire_stale_with_now: count of expired rows
  expire_stale_with_now->>expire_stale: return count
  expire_stale-->>list_pending: Ok(count)
  list_pending->>SQLite: SELECT undecided rows
  SQLite-->>list_pending: pending approvals
  list_pending-->>Client: Vec<Pending>
  
  Client->>decide: decide(request_id, decision)
  decide->>expire_stale: expire_stale()
  expire_stale-->>decide: Ok(count)
  decide->>SQLite: UPDATE row to decided state
  SQLite-->>decide: rows affected
  decide-->>Client: Ok(Option<Audit>)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

A rabbit hops through the store with care,
Expiring old approvals in the SQLite air,
Durability safe, decisions replayed,
Stale rows transition as the function is made! 🐰✨

🚥 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 'Fix expired pending approvals lingering after restart' directly summarizes the main change: ensuring expired pending approvals are properly handled and no longer linger after a restart.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

217-219: 💤 Low value

Log a warning on args_redacted parse failure.

Silently falling back to Null could mask data corruption or encoding issues in the args_redacted column. Consider logging at debug level to aid troubleshooting.

♻️ Proposed fix to add debug logging
 fn row_to_pending(row: &rusqlite::Row<'_>) -> rusqlite::Result<PendingApproval> {
     let args_str: String = row.get(3)?;
-    let args_redacted = serde_json::from_str(&args_str).unwrap_or(serde_json::Value::Null);
+    let args_redacted = serde_json::from_str(&args_str).unwrap_or_else(|e| {
+        tracing::debug!(
+            error = %e,
+            "[approval::store] args_redacted parse failed, falling back to null"
+        );
+        serde_json::Value::Null
+    });
     let created_str: String = row.get(5)?;

As per coding guidelines: "Use log / tracing at debug or trace level on ... error paths".

🤖 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/store.rs` around lines 217 - 219, In row_to_pending,
don't silently unwrap args_redacted parse failures: change the
serde_json::from_str(&args_str).unwrap_or(...) call to match on the Result from
serde_json::from_str and on Err emit a debug-level log (using log::debug or
tracing::debug) that includes the parse error and the original args_str, then
set args_redacted = serde_json::Value::Null as the fallback; reference the
function name row_to_pending and the variables args_str/args_redacted when
locating where to implement this.

201-215: ⚡ Quick win

Add trace logging for state transition.

This function performs a significant state transition (pending → terminal) but lacks any logging. Per coding guidelines, state transitions should be logged at debug or trace level to aid debugging.

♻️ Proposed fix to add trace logging
 fn expire_stale_with_now(conn: &Connection, now: DateTime<Utc>) -> Result<usize> {
     let now_rfc3339 = now.to_rfc3339();
     let deny = ApprovalDecision::Deny.as_str();
     let updated = conn
         .execute(
             "UPDATE pending_approvals
              SET decided_at = ?1, decision = ?2
              WHERE decided_at IS NULL
                AND expires_at IS NOT NULL
                AND strftime('%s', expires_at) <= strftime('%s', ?3)",
             params![now_rfc3339, deny, now_rfc3339],
         )
         .context("[approval::store] expire stale rows")?;
+    if updated > 0 {
+        tracing::debug!(
+            rows = updated,
+            "[approval::store] transitioned stale pending rows to terminal state"
+        );
+    }
     Ok(updated)
 }

As per coding guidelines: "Use log / tracing at debug or trace level on ... state transitions".

🤖 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/store.rs` around lines 201 - 215, The
expire_stale_with_now function performs a pending→terminal transition but has no
telemetry; add a trace (or debug) log in expire_stale_with_now after executing
the UPDATE to record the transition details: log the function name
(expire_stale_with_now), the timestamp `now` (or now_rfc3339), the decision
being applied (`ApprovalDecision::Deny` / `deny`) and the number of rows updated
(`updated`) so operators can correlate when and how many rows moved to a
terminal state; use the project’s logging crate (`tracing` or `log`) consistent
with other modules and emit the log before returning Ok(updated).
🤖 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/openhuman/approval/store.rs`:
- Around line 257-295: The test helper formatting is off — run rustfmt (cargo
fmt) or manually reformat the chained method calls in the helper functions
(sample, sample_with_expiry, fetch_decision_state) so chained calls are aligned
correctly; specifically reflow the multi-line chained calls in
sample_with_expiry and the statement/query chaining inside fetch_decision_state
(prepare(...).context(...)? and query(...).context(...)? /
rows.next().context(...)?), or simply run cargo fmt to auto-fix the formatting.
- Around line 371-468: Two multi-line assert! macros in the tests
list_pending_expires_stale_rows_before_returning and
decide_on_expired_row_returns_none_and_keeps_terminal_audit_state are
misformatted and fail cargo fmt; reformat those assert! calls to satisfy rustfmt
(e.g. put the boolean expression and the message on the same line or use a
single-line assert!(expr, "message") or construct the message with format!
inline), then run cargo fmt to apply consistent formatting and commit the
result.

---

Nitpick comments:
In `@src/openhuman/approval/store.rs`:
- Around line 217-219: In row_to_pending, don't silently unwrap args_redacted
parse failures: change the serde_json::from_str(&args_str).unwrap_or(...) call
to match on the Result from serde_json::from_str and on Err emit a debug-level
log (using log::debug or tracing::debug) that includes the parse error and the
original args_str, then set args_redacted = serde_json::Value::Null as the
fallback; reference the function name row_to_pending and the variables
args_str/args_redacted when locating where to implement this.
- Around line 201-215: The expire_stale_with_now function performs a
pending→terminal transition but has no telemetry; add a trace (or debug) log in
expire_stale_with_now after executing the UPDATE to record the transition
details: log the function name (expire_stale_with_now), the timestamp `now` (or
now_rfc3339), the decision being applied (`ApprovalDecision::Deny` / `deny`) and
the number of rows updated (`updated`) so operators can correlate when and how
many rows moved to a terminal state; use the project’s logging crate (`tracing`
or `log`) consistent with other modules and emit the log before returning
Ok(updated).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c9e52c2a-44ae-483e-9584-4937bac81426

📥 Commits

Reviewing files that changed from the base of the PR and between cc498d1 and 8c2c440.

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

Comment on lines 257 to +295
fn sample(request_id: &str, session_id: &str) -> PendingApproval {
sample_with_expiry(request_id, session_id, Some(Utc::now() + Duration::minutes(10)))
}

fn sample_with_expiry(
request_id: &str,
session_id: &str,
expires_at: Option<DateTime<Utc>>,
) -> PendingApproval {
PendingApproval {
request_id: request_id.to_string(),
tool_name: "composio".to_string(),
action_summary: "send slack message (12 chars)".to_string(),
args_redacted: json!({ "action": "execute", "tool_slug": "SLACK_SEND" }),
session_id: session_id.to_string(),
created_at: Utc::now(),
expires_at: Some(Utc::now() + Duration::minutes(10)),
expires_at,
}
}

fn fetch_decision_state(
config: &Config,
request_id: &str,
) -> Option<(Option<String>, Option<String>)> {
with_connection(config, |conn| {
let mut stmt = conn
.prepare("SELECT decided_at, decision FROM pending_approvals WHERE request_id = ?1")
.context("prepare raw decision lookup")?;
let mut rows = stmt.query(params![request_id]).context("query raw decision lookup")?;
if let Some(row) = rows.next().context("decision row next")? {
let decided_at: Option<String> = row.get(0)?;
let decision: Option<String> = row.get(1)?;
Ok(Some((decided_at, decision)))
} else {
Ok(None)
}
})
.unwrap()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix formatting to pass CI.

The pipeline reports cargo fmt failures in the test helpers. Run cargo fmt to fix the formatting at lines 258 and 285 (chained method calls).

🧰 Tools
🪛 GitHub Actions: Type Check / 0_Rust Quality (fmt + clippy).txt

[error] 282-282: cargo fmt --all -- --check failed due to formatting differences in query statement chaining. Rustfmt expects stmt.query(...) to be broken across multiple lines before .context(...).

🪛 GitHub Actions: Type Check / 1_Type Check TypeScript.txt

[error] 282-282: Prettier --check failed for openhuman-app format:check. Diff indicates formatting mismatch near query() call chain in store.rs.

🪛 GitHub Actions: Type Check / Rust Quality (fmt + clippy)

[error] 282-282: cargo fmt --all -- --check failed due to formatting differences in raw SQL query: stmt.query(...) must be reformatted across multiple lines.

🪛 GitHub Actions: Type Check / Type Check TypeScript

[error] 282-282: Prettier --check failed: formatting differences detected in store.rs (stmt.query(...).context(...) chained formatting). Run 'prettier --write .' to fix.

🤖 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/store.rs` around lines 257 - 295, The test helper
formatting is off — run rustfmt (cargo fmt) or manually reformat the chained
method calls in the helper functions (sample, sample_with_expiry,
fetch_decision_state) so chained calls are aligned correctly; specifically
reflow the multi-line chained calls in sample_with_expiry and the
statement/query chaining inside fetch_decision_state (prepare(...).context(...)?
and query(...).context(...)? / rows.next().context(...)?), or simply run cargo
fmt to auto-fix the formatting.

Comment on lines +371 to +468
#[test]
fn list_pending_expires_stale_rows_before_returning() {
let (config, _dir) = test_config();
insert_pending(
&config,
&sample_with_expiry("expired", "sess-A", Some(Utc::now() - Duration::minutes(5))),
)
.unwrap();
insert_pending(
&config,
&sample_with_expiry("active", "sess-A", Some(Utc::now() + Duration::minutes(5))),
)
.unwrap();

let rows = list_pending(&config).unwrap();
let ids: Vec<_> = rows.into_iter().map(|row| row.request_id).collect();
assert_eq!(ids, vec!["active"]);

let state = fetch_decision_state(&config, "expired").expect("expired row should persist");
assert!(state.0.is_some(), "expired row should have decided_at recorded");
assert_eq!(state.1.as_deref(), Some("deny"));
}

#[test]
fn decide_on_expired_row_returns_none_and_keeps_terminal_audit_state() {
let (config, _dir) = test_config();
insert_pending(
&config,
&sample_with_expiry("late", "sess-A", Some(Utc::now() - Duration::minutes(1))),
)
.unwrap();

let decided = decide(&config, "late", ApprovalDecision::ApproveOnce).unwrap();
assert!(decided.is_none(), "late approvals should no longer be actionable");

let state = fetch_decision_state(&config, "late").expect("row should remain for audit");
assert!(state.0.is_some());
assert_eq!(state.1.as_deref(), Some("deny"));
}

#[test]
fn expire_stale_returns_number_of_rows_transitioned() {
let (config, _dir) = test_config();
insert_pending(
&config,
&sample_with_expiry("old-1", "sess-A", Some(Utc::now() - Duration::minutes(2))),
)
.unwrap();
insert_pending(
&config,
&sample_with_expiry("old-2", "sess-B", Some(Utc::now() - Duration::minutes(1))),
)
.unwrap();
insert_pending(
&config,
&sample_with_expiry("fresh", "sess-B", Some(Utc::now() + Duration::minutes(30))),
)
.unwrap();

let expired = expire_stale(&config).unwrap();
assert_eq!(expired, 2);

let rows = list_pending(&config).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].request_id, "fresh");
}

#[test]
fn expire_stale_is_idempotent() {
let (config, _dir) = test_config();
insert_pending(
&config,
&sample_with_expiry("once", "sess-A", Some(Utc::now() - Duration::minutes(3))),
)
.unwrap();

assert_eq!(expire_stale(&config).unwrap(), 1);
assert_eq!(expire_stale(&config).unwrap(), 0);

let state = fetch_decision_state(&config, "once").expect("row should remain recorded");
assert!(state.0.is_some());
assert_eq!(state.1.as_deref(), Some("deny"));
}

#[test]
fn expire_stale_leaves_non_expiring_rows_pending() {
let (config, _dir) = test_config();
insert_pending(&config, &sample_with_expiry("no-ttl", "sess-A", None)).unwrap();

assert_eq!(expire_stale(&config).unwrap(), 0);
let rows = list_pending(&config).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].request_id, "no-ttl");

let state = fetch_decision_state(&config, "no-ttl").expect("row should still exist");
assert!(state.0.is_none());
assert!(state.1.is_none());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix formatting in assert! macros to pass CI.

The pipeline reports cargo fmt failures for the multi-line assert! calls at lines 387-391 and 401-404. Run cargo fmt to fix.

🧰 Tools
🪛 GitHub Actions: Type Check / 0_Rust Quality (fmt + clippy).txt

[error] 387-387: cargo fmt --all -- --check failed due to formatting differences in assert! with a multi-line condition/message. Rustfmt expects an assert!( ... ) block layout.


[error] 401-401: cargo fmt --all -- --check failed due to formatting differences in assert! for decided.is_none(). Rustfmt expects the assert to be formatted across multiple lines.

🪛 GitHub Actions: Type Check / 1_Type Check TypeScript.txt

[error] 387-389: Prettier --check failed for openhuman-app format:check. Diff indicates formatting mismatch for multi-line assert!(...) in store.rs.


[error] 401-404: Prettier --check failed for openhuman-app format:check. Diff indicates formatting mismatch for multi-line assert!(...) in store.rs.

🪛 GitHub Actions: Type Check / Rust Quality (fmt + clippy)

[error] 387-387: cargo fmt --all -- --check failed due to formatting differences in assert!(): assert!(..., "...") should be reformatted to multi-line macro invocation.


[error] 401-401: cargo fmt --all -- --check failed due to formatting differences in assert!(): assert! for decided.is_none() should be reformatted to multi-line macro invocation.

🪛 GitHub Actions: Type Check / Type Check TypeScript

[error] 387-387: Prettier --check failed: formatting differences detected in store.rs (multi-line assert! call formatting). Run 'prettier --write .' to fix.


[error] 401-401: Prettier --check failed: formatting differences detected in store.rs (multi-line assert! for decided.is_none formatting). Run 'prettier --write .' to fix.

🤖 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/store.rs` around lines 371 - 468, Two multi-line
assert! macros in the tests list_pending_expires_stale_rows_before_returning and
decide_on_expired_row_returns_none_and_keeps_terminal_audit_state are
misformatted and fail cargo fmt; reformat those assert! calls to satisfy rustfmt
(e.g. put the boolean expression and the message on the same line or use a
single-line assert!(expr, "message") or construct the message with format!
inline), then run cargo fmt to apply consistent formatting and commit the
result.

@M3gA-Mind
Copy link
Copy Markdown
Contributor

@Alexxigang CI is failing on changes in this PR — please fix before review.

1 similar comment
@M3gA-Mind
Copy link
Copy Markdown
Contributor

@Alexxigang CI is failing on changes in this PR — please fix before review.

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