Skip to main content

kaynine_runtime/
approval.rs

1//! Interactive approval (SPEC §8.3, §5.1): persist first, then broadcast.
2//!
3//! The handler is constructed inside the run task so it shares the loop's
4//! realtime `mpsc` sender — the run task's forwarder is the only writer on
5//! session broadcast feed, so approval realtime events flow through the same
6//! channel. The handler sends its envelopes with `run_seq: None`; the
7//! forwarder assigns a sequence number at forward time (see `run.rs`).
8
9use kaynine_core::error::KaynineError;
10use kaynine_core::event::{EventEnvelope, RealtimeEvent};
11use kaynine_core::ids::{BranchId, RunId, SessionId, ToolCallId};
12use kaynine_core::policy::{ApprovalDecision, ApprovalHandler, ApprovalRequest};
13use kaynine_core::store::{AppendOutcome, AuthoritativeEvent, LeaseOwner, SessionStore};
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex};
18use tokio_util::sync::CancellationToken;
19
20/// Outcome of a late `resolve_approval` submission (SPEC §8.3).
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum ResolutionOutcome {
23    Delivered,
24    Expired,
25    NotFound,
26}
27
28impl From<ResolutionOutcome> for crate::service::ApprovalOutcome {
29    fn from(outcome: ResolutionOutcome) -> Self {
30        match outcome {
31            ResolutionOutcome::Delivered => crate::service::ApprovalOutcome::Delivered,
32            ResolutionOutcome::Expired => crate::service::ApprovalOutcome::Expired,
33            ResolutionOutcome::NotFound => crate::service::ApprovalOutcome::NotFound,
34        }
35    }
36}
37
38fn host_unix_now() -> i64 {
39    std::time::SystemTime::now()
40        .duration_since(std::time::UNIX_EPOCH)
41        .map(|d| d.as_secs() as i64)
42        .unwrap_or(0)
43}
44
45/// Shared state between the run task's handler and the actor's
46/// `ResolveApproval` command.
47#[derive(Default)]
48pub(crate) struct ApprovalShared {
49    /// Live waiters: call_id → oneshot resolution channel.
50    pub(crate) pending: Mutex<HashMap<ToolCallId, oneshot::Sender<bool>>>,
51    /// Calls whose wait ended by timeout; call_id → deadline_unix. A resolve
52    /// submitted afterwards maps to `Expired` instead of `NotFound`.
53    pub(crate) expired: Mutex<HashMap<ToolCallId, i64>>,
54}
55
56pub struct InteractiveApprovalHandler {
57    store: Arc<dyn SessionStore>,
58    session_id: SessionId,
59    branch_id: BranchId,
60    run_id: RunId,
61    revision: Arc<AsyncMutex<u64>>,
62    owner: LeaseOwner,
63    events: mpsc::Sender<EventEnvelope<RealtimeEvent>>,
64    shared: Arc<ApprovalShared>,
65    timeout: Duration,
66}
67
68impl InteractiveApprovalHandler {
69    #[allow(clippy::too_many_arguments)]
70    pub(crate) fn new(
71        store: Arc<dyn SessionStore>,
72        session_id: SessionId,
73        branch_id: BranchId,
74        run_id: RunId,
75        revision: Arc<AsyncMutex<u64>>,
76        owner: LeaseOwner,
77        events: mpsc::Sender<EventEnvelope<RealtimeEvent>>,
78        shared: Arc<ApprovalShared>,
79        timeout: Duration,
80    ) -> Self {
81        Self {
82            store,
83            session_id,
84            branch_id,
85            run_id,
86            revision,
87            owner,
88            events,
89            shared,
90            timeout,
91        }
92    }
93
94    /// Resolves a pending approval from outside the run (actor command).
95    /// `Delivered` only means the decision reached a live waiter.
96    pub(crate) fn resolve(
97        shared: &ApprovalShared,
98        call_id: &ToolCallId,
99        approved: bool,
100    ) -> ResolutionOutcome {
101        let sender = shared
102            .pending
103            .lock()
104            .expect("pending mutex poisoned")
105            .remove(call_id);
106        match sender {
107            Some(tx) => match tx.send(approved) {
108                Ok(()) => ResolutionOutcome::Delivered,
109                // The waiter went away (timeout/cancel raced us).
110                Err(_) => {
111                    if shared
112                        .expired
113                        .lock()
114                        .expect("expired mutex poisoned")
115                        .contains_key(call_id)
116                    {
117                        ResolutionOutcome::Expired
118                    } else {
119                        ResolutionOutcome::NotFound
120                    }
121                }
122            },
123            None => {
124                if shared
125                    .expired
126                    .lock()
127                    .expect("expired mutex poisoned")
128                    .contains_key(call_id)
129                {
130                    ResolutionOutcome::Expired
131                } else {
132                    ResolutionOutcome::NotFound
133                }
134            }
135        }
136    }
137
138    /// Append events to the session store, retrying up to 8 attempts on
139    /// `RevisionConflict`. Mirrors the steer-append retry loop in `run.rs`.
140    ///
141    /// On conflict the shared revision is refreshed from the store response
142    /// before the next attempt. `NotLeaseHolder` and `LeaseExpired` are
143    /// returned immediately as hard failures; all other errors also fail
144    /// immediately.
145    async fn append(&self, events: Vec<AuthoritativeEvent>) -> Result<u64, KaynineError> {
146        for _ in 0..8 {
147            let expected = *self.revision.lock().await;
148            match self
149                .store
150                .append_events(
151                    &self.session_id,
152                    expected,
153                    &self.owner,
154                    events.clone(),
155                    Vec::new(),
156                )
157                .await
158            {
159                Ok(AppendOutcome::Appended { new_revision }) => {
160                    *self.revision.lock().await = new_revision;
161                    return Ok(new_revision);
162                }
163                Ok(AppendOutcome::RevisionConflict { current_revision }) => {
164                    *self.revision.lock().await = current_revision;
165                    tokio::time::sleep(Duration::from_millis(10)).await;
166                    continue;
167                }
168                Ok(AppendOutcome::NotLeaseHolder | AppendOutcome::LeaseExpired) => {
169                    return Err(KaynineError::Store(
170                        kaynine_core::error::StoreError::Internal(
171                            "lease lost during approval append".into(),
172                        ),
173                    ));
174                }
175                Err(error) => return Err(error),
176            }
177        }
178        // Retries exhausted — fail closed.
179        Err(KaynineError::Store(
180            kaynine_core::error::StoreError::Internal(
181                "approval append failed after 8 revision-conflict retries".into(),
182            ),
183        ))
184    }
185
186    async fn broadcast(&self, payload: RealtimeEvent) {
187        // run_seq: None → the run-task forwarder assigns one at forward time.
188        let _ = self
189            .events
190            .send(EventEnvelope {
191                session_id: self.session_id.clone(),
192                branch_id: self.branch_id.clone(),
193                run_id: Some(self.run_id.clone()),
194                revision: 0,
195                run_seq: None,
196                payload,
197            })
198            .await;
199    }
200
201    fn cleanup(&self, call_id: &ToolCallId, expired_at: Option<i64>) {
202        if let Some(deadline) = expired_at {
203            self.shared
204                .expired
205                .lock()
206                .expect("expired mutex poisoned")
207                .insert(call_id.clone(), deadline);
208        }
209        self.shared
210            .pending
211            .lock()
212            .expect("pending mutex poisoned")
213            .remove(call_id);
214    }
215}
216
217#[async_trait::async_trait]
218impl ApprovalHandler for InteractiveApprovalHandler {
219    async fn wait(&self, request: ApprovalRequest, cancel: CancellationToken) -> ApprovalDecision {
220        let deadline_unix = host_unix_now() + self.timeout.as_secs() as i64;
221
222        // 1. Persist ApprovalRequested first (fail closed on append failure).
223        if self
224            .append(vec![AuthoritativeEvent::ApprovalRequested {
225                call_id: request.call_id.clone(),
226                deadline_unix,
227            }])
228            .await
229            .is_err()
230        {
231            return ApprovalDecision::Denied {
232                reason: "审批持久化失败".into(),
233            };
234        }
235
236        // 2. Broadcast ApprovalPending after the durable write.
237        self.broadcast(RealtimeEvent::ApprovalPending {
238            call_id: request.call_id.clone(),
239            deadline_unix,
240        })
241        .await;
242
243        let (tx, rx) = oneshot::channel();
244        self.shared
245            .pending
246            .lock()
247            .expect("pending mutex poisoned")
248            .insert(request.call_id.clone(), tx);
249        let mut rx = rx;
250
251        // 3. Await decision / timeout / cancellation.
252        let outcome = tokio::select! {
253            biased;
254            _ = cancel.cancelled() => ApprovalDecision::Denied {
255                reason: "工具在执行前被取消".into(),
256            },
257            _ = tokio::time::sleep(self.timeout) => ApprovalDecision::Denied {
258                reason: "审批超时".into(),
259            },
260            res = &mut rx => match res {
261                Ok(true) => ApprovalDecision::Approved,
262                Ok(false) => ApprovalDecision::Denied {
263                    reason: "审批被拒绝".into(),
264                },
265                Err(_) => ApprovalDecision::Denied {
266                    reason: "审批通道关闭".into(),
267                },
268            },
269        };
270
271        let approved = outcome == ApprovalDecision::Approved;
272        let timed_out =
273            matches!(&outcome, ApprovalDecision::Denied { reason } if reason == "审批超时");
274
275        // 4. Persist + broadcast ApprovalResolved, then clean up.
276        if self
277            .append(vec![AuthoritativeEvent::ApprovalResolved {
278                call_id: request.call_id.clone(),
279                approved,
280            }])
281            .await
282            .is_err()
283        {
284            tracing::error!(call_id = %request.call_id, "approval resolved append failed");
285        }
286        self.broadcast(RealtimeEvent::ApprovalResolved {
287            call_id: request.call_id.clone(),
288            approved,
289        })
290        .await;
291        self.cleanup(&request.call_id, timed_out.then_some(deadline_unix));
292
293        outcome
294    }
295}