Skip to main content

navi_core/goal/
runtime.rs

1use crate::goal::types::SessionGoal;
2use std::sync::RwLock;
3
4use super::accounting::GoalAccountingState;
5use super::steering;
6
7/// Per-session goal lifecycle manager.
8///
9/// Drives auto-continuation, status transitions, and steering prompt injection.
10/// All mutations go through `with_goal_state` which serializes access via a
11/// dedicated state lock, preventing external `set`/`clear` calls from
12/// interfering with `continue_if_idle`.
13pub struct GoalRuntimeHandle {
14    /// The currently bound session id.
15    session_id: RwLock<Option<String>>,
16    /// The goal state protected by a mutex.
17    goal: RwLock<Option<SessionGoal>>,
18    /// Accounting state for the current turn.
19    accounting: RwLock<Option<GoalAccountingState>>,
20    /// Whether auto-continuation is enabled.
21    auto_continue: RwLock<bool>,
22    /// Serializes goal state mutations to prevent race conditions between
23    /// external set/clear and internal continue_if_idle.
24    state_lock: std::sync::Mutex<()>,
25}
26
27impl GoalRuntimeHandle {
28    /// Creates a new runtime handle with an optional initial goal.
29    pub fn new(initial_goal: Option<SessionGoal>) -> Self {
30        // Initialize accounting if there's an active goal.
31        let accounting = initial_goal
32            .as_ref()
33            .map(|g| GoalAccountingState::new(g.clone()));
34        let session_id = initial_goal
35            .as_ref()
36            .and_then(|goal| (!goal.session_id.is_empty()).then(|| goal.session_id.clone()));
37        Self {
38            session_id: RwLock::new(session_id),
39            goal: RwLock::new(initial_goal),
40            accounting: RwLock::new(accounting),
41            auto_continue: RwLock::new(true),
42            state_lock: std::sync::Mutex::new(()),
43        }
44    }
45
46    // ── Goal accessors ──────────────────────────────────────────
47
48    /// Returns the current goal, if any.
49    pub fn get_goal(&self) -> Option<SessionGoal> {
50        self.goal.read().unwrap_or_else(|e| e.into_inner()).clone()
51    }
52
53    /// Binds this runtime to a session id and rewrites any loaded goal to match.
54    pub fn set_session_id(&self, session_id: impl Into<String>) {
55        let session_id = session_id.into();
56        *self.session_id.write().unwrap_or_else(|e| e.into_inner()) = Some(session_id.clone());
57        if let Some(ref mut goal) = *self.goal.write().unwrap_or_else(|e| e.into_inner()) {
58            goal.session_id = session_id;
59            goal.updated_at = crate::session::current_unix_timestamp();
60        }
61    }
62
63    /// Sets or replaces the goal.
64    pub fn set_objective(&self, objective: String, token_budget: Option<i64>) -> SessionGoal {
65        self.set_objective_with_short_description(objective, None, token_budget)
66    }
67
68    /// Sets or replaces the goal with an optional compact UI label.
69    pub fn set_objective_with_short_description(
70        &self,
71        objective: String,
72        short_description: Option<String>,
73        token_budget: Option<i64>,
74    ) -> SessionGoal {
75        let mut goal_guard = self.goal.write().unwrap_or_else(|e| e.into_inner());
76        if let Some(ref mut goal) = *goal_guard {
77            goal.objective = objective;
78            goal.short_description = short_description;
79            goal.token_budget = token_budget;
80            goal.status = crate::goal::types::GoalStatus::Active;
81            goal.consecutive_blocked_turns = 0;
82            goal.block_reason = None;
83            goal.updated_at = crate::session::current_unix_timestamp();
84            let new_goal = goal.clone();
85            drop(goal_guard);
86            *self.accounting.write().unwrap_or_else(|e| e.into_inner()) =
87                Some(GoalAccountingState::new(new_goal.clone()));
88            new_goal
89        } else {
90            let session_id = self
91                .session_id
92                .read()
93                .unwrap_or_else(|e| e.into_inner())
94                .clone()
95                .unwrap_or_default();
96            let mut new_goal = SessionGoal::new(session_id, objective, token_budget);
97            new_goal.short_description = short_description;
98            let cloned = new_goal.clone();
99            *goal_guard = Some(new_goal);
100            drop(goal_guard);
101            *self.accounting.write().unwrap_or_else(|e| e.into_inner()) =
102                Some(GoalAccountingState::new(cloned.clone()));
103            cloned
104        }
105    }
106
107    /// Updates the stored goal (used after status transitions).
108    pub fn update_goal(&self, mut goal: SessionGoal) {
109        if let Some(session_id) = self
110            .session_id
111            .read()
112            .unwrap_or_else(|e| e.into_inner())
113            .clone()
114        {
115            goal.session_id = session_id;
116        }
117        *self.goal.write().unwrap_or_else(|e| e.into_inner()) = Some(goal.clone());
118        if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
119            acct.replace_goal(goal);
120        }
121    }
122
123    /// Clears the current goal.
124    pub fn clear_goal(&self) {
125        *self.goal.write().unwrap_or_else(|e| e.into_inner()) = None;
126        *self.accounting.write().unwrap_or_else(|e| e.into_inner()) = None;
127        *self.session_id.write().unwrap_or_else(|e| e.into_inner()) = None;
128    }
129
130    /// Updates the checklist on the current goal. Returns the updated goal if successful.
131    pub fn update_checklist(&self, tasks: Vec<super::types::GoalTask>) -> Option<SessionGoal> {
132        let mut goal = self.goal.write().unwrap_or_else(|e| e.into_inner());
133        if let Some(ref mut g) = *goal {
134            g.set_checklist(tasks);
135            let cloned = g.clone();
136            drop(goal);
137            if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
138                acct.replace_goal(cloned.clone());
139            }
140            Some(cloned)
141        } else {
142            None
143        }
144    }
145
146    /// Updates a single task's status in the goal checklist.
147    pub fn update_task_status(
148        &self,
149        task_id: usize,
150        status: super::types::TaskStatus,
151    ) -> Option<SessionGoal> {
152        let mut goal = self.goal.write().unwrap_or_else(|e| e.into_inner());
153        if let Some(ref mut g) = *goal {
154            if !g.update_task_status(task_id, status) {
155                return None;
156            }
157            let cloned = g.clone();
158            drop(goal);
159            if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
160                acct.replace_goal(cloned.clone());
161            }
162            Some(cloned)
163        } else {
164            None
165        }
166    }
167
168    // ── Accounting ─────────────────────────────────────────────
169
170    /// Starts turn accounting for the active goal.
171    pub fn start_turn(&self) {
172        let goal = self.get_goal();
173        if let Some(goal) = goal {
174            if goal.status.should_auto_continue() {
175                let acct = GoalAccountingState::new(goal);
176                acct.start_turn();
177                *self.accounting.write().unwrap_or_else(|e| e.into_inner()) = Some(acct);
178            }
179        }
180    }
181
182    /// Records token usage during a turn.
183    pub fn record_tokens(&self, delta: i64) -> bool {
184        if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
185            let exceeded = acct.record_token_usage(delta);
186            if exceeded {
187                // Sync back immediately so budget_limit_prompt works.
188                if let Some(goal) = acct.snapshot() {
189                    self.update_goal(goal);
190                }
191            }
192            exceeded
193        } else {
194            false
195        }
196    }
197
198    /// Finishes turn accounting and updates the stored goal.
199    pub fn finish_turn(&self) {
200        if let Some(acct) = self
201            .accounting
202            .read()
203            .unwrap_or_else(|e| e.into_inner())
204            .as_ref()
205        {
206            if let Some(goal) = acct.finish_turn() {
207                self.update_goal(goal);
208            }
209        }
210        *self.accounting.write().unwrap_or_else(|e| e.into_inner()) = None;
211    }
212
213    // ── Status transitions ─────────────────────────────────────
214
215    /// Transitions the goal to Blocked after consecutive blocked turns.
216    pub fn record_blocked_turn(&self, reason: &str) -> bool {
217        let mut goal_guard = self.goal.write().unwrap_or_else(|e| e.into_inner());
218        if let Some(ref mut goal) = *goal_guard {
219            let became_blocked = goal.record_blocked_turn(reason);
220            let updated = goal.clone();
221            drop(goal_guard);
222            if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
223                acct.replace_goal(updated);
224            }
225            became_blocked
226        } else {
227            false
228        }
229    }
230
231    /// Transitions the goal to UsageLimited.
232    pub fn mark_usage_limited(&self) {
233        if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
234            if let Some(mut goal) = acct.snapshot() {
235                goal.transition_to(crate::goal::types::GoalStatus::UsageLimited);
236                self.update_goal(goal);
237            }
238        } else if let Some(mut goal) = self.get_goal() {
239            goal.transition_to(crate::goal::types::GoalStatus::UsageLimited);
240            self.update_goal(goal);
241        }
242    }
243
244    // ── Auto-continuation ──────────────────────────────────────
245
246    /// If the goal is active and should auto-continue, returns a steering
247    /// continuation prompt to inject into the conversation.
248    /// Acquires the state lock to prevent race conditions with concurrent
249    /// set/clear operations.
250    pub fn continue_if_idle(&self) -> Option<String> {
251        let _lock = self.state_lock.lock().unwrap_or_else(|e| e.into_inner());
252        let goal = self.get_goal()?;
253        if !goal.status.should_auto_continue() {
254            return None;
255        }
256        let auto = *self.auto_continue.read().unwrap_or_else(|e| e.into_inner());
257        if !auto {
258            return None;
259        }
260        Some(steering::build_continuation_prompt(&goal))
261    }
262
263    /// Returns a budget-limit steering prompt if the budget is exceeded.
264    pub fn budget_limit_prompt(&self) -> Option<String> {
265        let goal = self.get_goal()?;
266        if goal.is_budget_exceeded() {
267            Some(steering::build_budget_limit_prompt(&goal))
268        } else {
269            None
270        }
271    }
272
273    /// Returns an objective-updated steering prompt.
274    pub fn objective_updated_prompt(&self) -> Option<String> {
275        let goal = self.get_goal()?;
276        if goal.status == crate::goal::types::GoalStatus::Active {
277            Some(steering::build_objective_updated_prompt(&goal))
278        } else {
279            None
280        }
281    }
282
283    /// Enables or disables auto-continuation.
284    pub fn set_auto_continue(&self, enabled: bool) {
285        *self
286            .auto_continue
287            .write()
288            .unwrap_or_else(|e| e.into_inner()) = enabled;
289    }
290
291    /// Returns `true` if the goal is in a terminal state.
292    pub fn is_terminal(&self) -> bool {
293        self.get_goal()
294            .map(|g| g.status.is_terminal())
295            .unwrap_or(false)
296    }
297}