Skip to main content

kcode_kennedy_orchestration/
worker.rs

1use std::{
2    collections::{HashMap, HashSet},
3    sync::{
4        Arc,
5        atomic::{AtomicBool, Ordering},
6    },
7    time::Duration,
8};
9
10use anyhow::Context as _;
11use chrono::{DateTime, Duration as ChronoDuration, Utc};
12use kcode_session_history::{SessionCommand, SessionRecord, SessionStopRequest};
13use serde_json::{Value, json};
14use tokio::sync::{Mutex, OnceCell, RwLock};
15use uuid::Uuid;
16
17use super::{AgentMode, Api, ApiError, Config, Manuals, RuntimeModel, Session, SessionService};
18use kcode_kennedy_sessions::SessionOptions;
19
20const POLL_INTERVAL: Duration = Duration::from_secs(1);
21const STARTUP_RETRY: Duration = Duration::from_secs(2);
22
23#[derive(Clone)]
24pub struct SessionRuntime {
25    manuals: Manuals,
26    pub model: RuntimeModel,
27    pub user_root_node_id: String,
28    pub kennedy_root_node_id: String,
29}
30
31pub enum TurnCompletion {
32    Finished,
33    Stopped,
34}
35
36pub struct Orchestrator {
37    config: Config,
38    api: Api,
39    sessions: SessionService,
40    runtime: OnceCell<SessionRuntime>,
41    initialization: Mutex<()>,
42    writer: Arc<Mutex<()>>,
43    writer_job_active: AtomicBool,
44    commands_in_flight: Mutex<HashSet<String>>,
45    active_operations: Mutex<HashMap<String, Uuid>>,
46    conversation_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
47    last_poll_error: RwLock<Option<String>>,
48}
49
50impl Orchestrator {
51    pub fn new(config: Config, api: Api, sessions: SessionService) -> Self {
52        Self {
53            config,
54            api,
55            sessions,
56            runtime: OnceCell::new(),
57            initialization: Mutex::new(()),
58            writer: Arc::new(Mutex::new(())),
59            writer_job_active: AtomicBool::new(false),
60            commands_in_flight: Mutex::new(HashSet::new()),
61            active_operations: Mutex::new(HashMap::new()),
62            conversation_locks: Mutex::new(HashMap::new()),
63            last_poll_error: RwLock::new(None),
64        }
65    }
66
67    pub fn api(&self) -> &Api {
68        &self.api
69    }
70
71    pub fn writer(&self) -> &Arc<Mutex<()>> {
72        &self.writer
73    }
74
75    pub async fn run(self: Arc<Self>) -> anyhow::Result<()> {
76        self.initialize_until_ready().await;
77        loop {
78            match self.poll_once().await {
79                Ok(()) => *self.last_poll_error.write().await = None,
80                Err(error) => {
81                    let message = error.to_string();
82                    let mut previous = self.last_poll_error.write().await;
83                    if previous.as_deref() != Some(message.as_str()) {
84                        tracing::warn!(error=%error, "Backend orchestration poll will retry");
85                        *previous = Some(message);
86                    }
87                }
88            }
89            tokio::time::sleep(POLL_INTERVAL).await;
90        }
91    }
92
93    pub async fn initialize_until_ready(&self) {
94        if self.runtime.get().is_some() {
95            return;
96        }
97        let _initialization = self.initialization.lock().await;
98        if self.runtime.get().is_some() {
99            return;
100        }
101        let mut previous = None;
102        loop {
103            match self.initialize().await {
104                Ok(runtime) => {
105                    let model = runtime.model.model.clone();
106                    let _ = self.runtime.set(runtime);
107                    tracing::info!(%model, "Native Rust orchestration worker ready");
108                    return;
109                }
110                Err(error) => {
111                    let message = error.to_string();
112                    if previous.as_deref() != Some(message.as_str()) {
113                        tracing::warn!(error=%error, "Waiting for Kennedy services before starting orchestration");
114                        previous = Some(message);
115                    }
116                    tokio::time::sleep(STARTUP_RETRY).await;
117                }
118            }
119        }
120    }
121
122    async fn initialize(&self) -> anyhow::Result<SessionRuntime> {
123        self.api.kmap_node(self.api.user_root_node_id())?;
124        self.api.kmap_node(self.api.kennedy_root_node_id())?;
125        self.api.history_health()?;
126        let manuals = Manuals::load(&self.config.system_prompts_directory)?;
127        let runtime = SessionRuntime {
128            manuals,
129            model: self.config.runtime_model.clone(),
130            user_root_node_id: self.api.user_root_node_id().to_owned(),
131            kennedy_root_node_id: self.api.kennedy_root_node_id().to_owned(),
132        };
133        self.api.history_release_interrupted_ingress().await?;
134        Ok(runtime)
135    }
136
137    pub fn runtime(&self) -> anyhow::Result<&SessionRuntime> {
138        self.runtime
139            .get()
140            .context("orchestration runtime is not initialized")
141    }
142
143    pub async fn open_session(
144        &self,
145        runtime: SessionRuntime,
146        options: SessionOptions,
147        restored: Option<&Value>,
148    ) -> anyhow::Result<Session> {
149        let system_prompt = if matches!(options.mode, AgentMode::Ingress { .. }) {
150            runtime.manuals.compose_ingress(
151                &runtime.model,
152                options
153                    .source_session_type
154                    .as_deref()
155                    .unwrap_or("conversation"),
156            )?
157        } else {
158            let session_context = if options.session_type == "free-time" {
159                self_time_schedule(&options.free_time)
160            } else {
161                String::new()
162            };
163            runtime.manuals.compose_conversation(
164                &runtime.model,
165                &options.session_type,
166                &session_context,
167            )?
168        };
169        Session::new(
170            self.sessions.clone(),
171            system_prompt,
172            runtime.model,
173            options,
174            restored,
175        )
176        .await
177    }
178
179    async fn poll_once(self: &Arc<Self>) -> anyhow::Result<()> {
180        let histories = self.list_history().await?;
181        self.signal_pending_stops().await?;
182        self.sync_conversation_commands().await?;
183        self.schedule_writer_job(&histories).await?;
184        self.api.synchronize_audio_ingress().await?;
185        Ok(())
186    }
187
188    async fn signal_pending_stops(&self) -> anyhow::Result<()> {
189        let command_conversations = self
190            .api
191            .history_command_heads()
192            .await?
193            .into_iter()
194            .map(|command| command.conversation_id)
195            .collect::<HashSet<_>>();
196        for request in self.pending_stops().await? {
197            if !self.operation_is_active(&request.session_id).await
198                && request.scope == "turn"
199                && !command_conversations.contains(&request.session_id)
200            {
201                self.finish_idle_turn_stop(&request.session_id).await?;
202            }
203        }
204        Ok(())
205    }
206
207    async fn pending_stops(&self) -> anyhow::Result<Vec<SessionStopRequest>> {
208        Ok(self.api.history_stop_heads().await?)
209    }
210
211    pub async fn pending_stop(
212        &self,
213        session_id: &str,
214    ) -> anyhow::Result<Option<SessionStopRequest>> {
215        Ok(self
216            .pending_stops()
217            .await?
218            .into_iter()
219            .find(|request| request.session_id == session_id))
220    }
221
222    pub async fn complete_pending_stop(
223        &self,
224        session_id: &str,
225        outcome: Value,
226    ) -> anyhow::Result<()> {
227        if let Some(request) = self.pending_stop(session_id).await? {
228            self.api.history_complete_stop(&request.id, outcome).await?;
229        }
230        Ok(())
231    }
232
233    pub async fn operation_is_active(&self, session_id: &str) -> bool {
234        self.active_operations.lock().await.contains_key(session_id)
235    }
236
237    async fn finish_idle_turn_stop(&self, session_id: &str) -> anyhow::Result<()> {
238        let lock = self.conversation_lock(session_id).await;
239        let _guard = lock.lock().await;
240        if self.operation_is_active(session_id).await {
241            return Ok(());
242        }
243        if self.pending_stop(session_id).await?.is_none() {
244            return Ok(());
245        }
246        let record = self.get_conversation(session_id).await?;
247        if record.phase != "active" {
248            return Ok(());
249        }
250        let record = Arc::new(Mutex::new(record));
251        let mut session = {
252            let locked = record.lock().await;
253            self.session_for_record(&locked).await?
254        };
255        let telegram_event = matches!(session.session_type.as_str(), "telegram" | "telegram-group")
256            .then(|| session.pending_external_event_id.clone())
257            .flatten();
258        session.interrupt_current_turn()?;
259        persist_record(&self.api, &record, session.snapshot()?, false).await?;
260        if let Some(event_id) = telegram_event {
261            self.api
262                .telegram_interrupt_event(&event_id, session_id)
263                .await?;
264        }
265        self.complete_pending_stop(session_id, json!({"status":"stopped","scope":"turn"}))
266            .await
267    }
268
269    pub async fn register_operation(&self, session_id: &str, operation_id: Uuid) {
270        self.active_operations
271            .lock()
272            .await
273            .insert(session_id.to_owned(), operation_id);
274    }
275
276    pub async fn remove_operation(&self, session_id: &str, operation_id: Uuid) {
277        let mut active = self.active_operations.lock().await;
278        if active
279            .get(session_id)
280            .is_some_and(|operation| *operation == operation_id)
281        {
282            active.remove(session_id);
283        }
284    }
285
286    pub async fn run_session_turn<C, F>(
287        &self,
288        session_id: &str,
289        session: &mut Session,
290        operation_id: Uuid,
291        checkpoint: C,
292    ) -> anyhow::Result<TurnCompletion>
293    where
294        C: FnMut(Value) -> F + Send,
295        F: std::future::Future<Output = anyhow::Result<()>> + Send,
296    {
297        self.register_operation(session_id, operation_id).await;
298        let stop = match self.api.history_listen_for_stop(session_id) {
299            Ok(stop) => stop,
300            Err(error) => {
301                self.remove_operation(session_id, operation_id).await;
302                return Err(error.into());
303            }
304        };
305        let result: anyhow::Result<TurnCompletion> = {
306            let turn = session.run_pending_turn(operation_id, checkpoint);
307            tokio::pin!(turn);
308            tokio::select! {
309                biased;
310                _ = stop.requested() => Ok({
311                    let _ = self.api.cancel_intelligence(operation_id);
312                    TurnCompletion::Stopped
313                }),
314                result = &mut turn => result.map(|_| TurnCompletion::Finished),
315            }
316        };
317        self.remove_operation(session_id, operation_id).await;
318        result
319    }
320
321    pub async fn list_history(&self) -> anyhow::Result<Vec<SessionRecord>> {
322        Ok(self.api.history_list().await?)
323    }
324
325    pub async fn conversation_lock(&self, id: &str) -> Arc<Mutex<()>> {
326        self.conversation_locks
327            .lock()
328            .await
329            .entry(id.to_owned())
330            .or_insert_with(|| Arc::new(Mutex::new(())))
331            .clone()
332    }
333
334    async fn sync_conversation_commands(self: &Arc<Self>) -> anyhow::Result<()> {
335        let commands = self.api.history_command_heads().await?;
336        for command in commands {
337            let id = command.id.clone();
338            if command.cancel_requested && self.commands_in_flight.lock().await.contains(&id) {
339                continue;
340            }
341            let mut in_flight = self.commands_in_flight.lock().await;
342            if !in_flight.insert(id.clone()) {
343                continue;
344            }
345            drop(in_flight);
346            let worker = self.clone();
347            tokio::spawn(async move {
348                if let Err(error) = worker.process_conversation_command(command).await {
349                    tracing::warn!(command_id=%id, error=%error, "Browser conversation command will retry");
350                }
351                worker.commands_in_flight.lock().await.remove(&id);
352            });
353        }
354        Ok(())
355    }
356
357    async fn process_conversation_command(&self, command: SessionCommand) -> anyhow::Result<()> {
358        let command_id = command.id.clone();
359        let conversation_id = command.conversation_id.clone();
360        let lock = self.conversation_lock(&conversation_id).await;
361        let _conversation_guard = lock.lock().await;
362        let command = if command.status == "pending" {
363            self.api.history_claim_command(&command_id).await?
364        } else {
365            command
366        };
367        let record = self.get_conversation(&conversation_id).await?;
368        if record.phase != "active" || !is_browser_conversation(&record) {
369            self.complete_command(&command_id, json!({"status":"conversation_closed"}))
370                .await?;
371            return Ok(());
372        }
373        let kind = command.kind.clone();
374        let payload = command.payload.clone();
375        let record = Arc::new(Mutex::new(record));
376        if kind == "end" {
377            let mut state = record.lock().await.state.clone();
378            let abandoned_pending_turn = state
379                .get("pendingTurn")
380                .and_then(Value::as_bool)
381                .unwrap_or(false);
382            state["orchestration"] = json!({
383                "owner":"backend",
384                "status":"ending",
385                "abandonedPendingTurn":abandoned_pending_turn,
386            });
387            if let Some(session_id) = state.get("rustLibSessionId").and_then(Value::as_str) {
388                self.api.release_managed_sources(session_id).await;
389            }
390            self.request_conversation_ingress(&record, Some(state))
391                .await?;
392            self.complete_command(&command_id, json!({"status":"closed"}))
393                .await?;
394            return Ok(());
395        }
396        let mut session = {
397            let locked = record.lock().await;
398            self.session_for_record(&locked).await?
399        };
400        if command.cancel_requested {
401            session.interrupt_current_turn()?;
402            persist_record(&self.api, &record, session.snapshot()?, false).await?;
403            self.complete_command(
404                &command_id,
405                json!({"status":"stopped","reason":"user_stopped"}),
406            )
407            .await?;
408            self.complete_pending_stop(
409                &conversation_id,
410                json!({"status":"stopped","scope":"turn"}),
411            )
412            .await?;
413            return Ok(());
414        }
415        if session.orchestration.get("owner").and_then(Value::as_str) != Some("backend") {
416            session.orchestration = json!({"owner":"backend","status":"idle"});
417            persist_record(&self.api, &record, session.snapshot()?, false).await?;
418        }
419        let external_event_id = format!("web:{command_id}");
420        let outcome = match kind.as_str() {
421            "message" => {
422                if session
423                    .answer_for_external_event(&external_event_id)
424                    .is_none()
425                {
426                    if !session.pending_turn {
427                        let mut metadata = payload
428                            .get("metadata")
429                            .cloned()
430                            .unwrap_or_else(|| json!({}));
431                        metadata["externalEventId"] = json!(external_event_id);
432                        anyhow::ensure!(
433                            session.begin_user_turn(
434                                payload
435                                    .get("text")
436                                    .and_then(Value::as_str)
437                                    .unwrap_or_default(),
438                                &metadata,
439                            ),
440                            "The queued message contained no usable input"
441                        );
442                    }
443                    session.orchestration = json!({"owner":"backend","status":"working"});
444                    persist_record(&self.api, &record, session.snapshot()?, true).await?;
445                    let operation_id = Uuid::new_v4();
446                    let api = self.api.clone();
447                    let saved_record = record.clone();
448                    let result = self
449                        .run_session_turn(
450                            &conversation_id,
451                            &mut session,
452                            operation_id,
453                            move |state| {
454                                let api = api.clone();
455                                let record = saved_record.clone();
456                                async move {
457                                    persist_record(&api, &record, state, false).await?;
458                                    Ok(())
459                                }
460                            },
461                        )
462                        .await;
463                    if matches!(&result, Ok(TurnCompletion::Stopped)) {
464                        session.interrupt_current_turn()?;
465                        persist_record(&self.api, &record, session.snapshot()?, false).await?;
466                        self.complete_command(
467                            &command_id,
468                            json!({"status":"stopped","reason":"user_stopped"}),
469                        )
470                        .await?;
471                        self.complete_pending_stop(
472                            &conversation_id,
473                            json!({"status":"stopped","scope":"turn"}),
474                        )
475                        .await?;
476                        return Ok(());
477                    }
478                    if let Err(error) = result {
479                        let round_limit = kcode_agent_runtime::is_session_round_limit(&error);
480                        session.orchestration = if is_cancelled(&error) {
481                            json!({"owner":"backend","status":"stopped"})
482                        } else if round_limit {
483                            json!({"owner":"backend","status":"stopped","lastError":bounded_error(&error)})
484                        } else {
485                            json!({"owner":"backend","status":"retrying","lastError":bounded_error(&error)})
486                        };
487                        let persisted =
488                            persist_record(&self.api, &record, session.snapshot()?, false).await;
489                        if round_limit {
490                            persisted?;
491                            tracing::warn!(command_id=%command_id, "Browser conversation stopped at the tool-loop round limit");
492                            self.complete_command(
493                                &command_id,
494                                json!({"status":"stopped","reason":"tool_loop_round_limit"}),
495                            )
496                            .await?;
497                            return Ok(());
498                        }
499                        persisted.ok();
500                        if is_cancelled(&error) {
501                            self.complete_command(&command_id, json!({"status":"stopped"}))
502                                .await?;
503                            return Ok(());
504                        }
505                        return Err(error);
506                    }
507                    if session.requires_history_ingress() {
508                        session.orchestration =
509                            json!({"owner":"backend","status":"ending","reason":"context-limit"});
510                        persist_record(&self.api, &record, session.snapshot()?, false).await?;
511                        self.request_conversation_ingress(&record, None).await?;
512                        self.complete_command(
513                            &command_id,
514                            json!({"status":"closed","reason":"context_limit"}),
515                        )
516                        .await?;
517                        return Ok(());
518                    }
519                }
520                anyhow::ensure!(
521                    session
522                        .answer_for_external_event(&external_event_id)
523                        .is_some(),
524                    "Kennedy completed the web turn without a recoverable response"
525                );
526                session.orchestration = json!({"owner":"backend","status":"idle"});
527                persist_record(&self.api, &record, session.snapshot()?, false).await?;
528                json!({"status":"answered"})
529            }
530            "retry" => {
531                if session.pending_turn {
532                    session.reset_exhausted_turn_rounds_for_retry();
533                    session.orchestration = json!({"owner":"backend","status":"working"});
534                    persist_record(&self.api, &record, session.snapshot()?, false).await?;
535                    let operation_id = Uuid::new_v4();
536                    let api = self.api.clone();
537                    let saved_record = record.clone();
538                    let result = self
539                        .run_session_turn(
540                            &conversation_id,
541                            &mut session,
542                            operation_id,
543                            move |state| {
544                                let api = api.clone();
545                                let record = saved_record.clone();
546                                async move {
547                                    persist_record(&api, &record, state, false).await?;
548                                    Ok(())
549                                }
550                            },
551                        )
552                        .await;
553                    if matches!(&result, Ok(TurnCompletion::Stopped)) {
554                        session.interrupt_current_turn()?;
555                        persist_record(&self.api, &record, session.snapshot()?, false).await?;
556                        self.complete_command(
557                            &command_id,
558                            json!({"status":"stopped","reason":"user_stopped"}),
559                        )
560                        .await?;
561                        self.complete_pending_stop(
562                            &conversation_id,
563                            json!({"status":"stopped","scope":"turn"}),
564                        )
565                        .await?;
566                        return Ok(());
567                    }
568                    if let Err(error) = result {
569                        let round_limit = kcode_agent_runtime::is_session_round_limit(&error);
570                        session.orchestration = if is_cancelled(&error) {
571                            json!({"owner":"backend","status":"stopped"})
572                        } else if round_limit {
573                            json!({"owner":"backend","status":"stopped","lastError":bounded_error(&error)})
574                        } else {
575                            json!({"owner":"backend","status":"retrying","lastError":bounded_error(&error)})
576                        };
577                        let persisted =
578                            persist_record(&self.api, &record, session.snapshot()?, false).await;
579                        if round_limit {
580                            persisted?;
581                            tracing::warn!(command_id=%command_id, "Browser conversation stopped at the tool-loop round limit");
582                            self.complete_command(
583                                &command_id,
584                                json!({"status":"stopped","reason":"tool_loop_round_limit"}),
585                            )
586                            .await?;
587                            return Ok(());
588                        }
589                        persisted.ok();
590                        if is_cancelled(&error) {
591                            self.complete_command(&command_id, json!({"status":"stopped"}))
592                                .await?;
593                            return Ok(());
594                        }
595                        return Err(error);
596                    }
597                    if session.requires_history_ingress() {
598                        session.orchestration =
599                            json!({"owner":"backend","status":"ending","reason":"context-limit"});
600                        persist_record(&self.api, &record, session.snapshot()?, false).await?;
601                        self.request_conversation_ingress(&record, None).await?;
602                        self.complete_command(
603                            &command_id,
604                            json!({"status":"closed","reason":"context_limit"}),
605                        )
606                        .await?;
607                        return Ok(());
608                    }
609                }
610                session.orchestration = json!({"owner":"backend","status":"idle"});
611                persist_record(&self.api, &record, session.snapshot()?, false).await?;
612                json!({"status":"retried"})
613            }
614            "send-and-end" => {
615                anyhow::ensure!(
616                    !session.pending_turn,
617                    "The saved query must finish before this conversation can end"
618                );
619                if !session.transcript.iter().any(|item| {
620                    item.get("externalEventId").and_then(Value::as_str) == Some(&external_event_id)
621                }) {
622                    let mut metadata = payload
623                        .get("metadata")
624                        .cloned()
625                        .unwrap_or_else(|| json!({}));
626                    metadata["externalEventId"] = json!(external_event_id);
627                    anyhow::ensure!(
628                        session.append_final_user_message(
629                            payload
630                                .get("text")
631                                .and_then(Value::as_str)
632                                .unwrap_or_default(),
633                            &metadata
634                        ),
635                        "The final conversation command contained no usable input"
636                    );
637                }
638                persist_record(&self.api, &record, session.snapshot()?, true).await?;
639                self.close_conversation(&record, &session).await?;
640                json!({"status":"closed"})
641            }
642            _ => anyhow::bail!("Unsupported browser conversation command {kind}"),
643        };
644        self.complete_command(&command_id, outcome).await?;
645        self.complete_pending_stop(
646            &conversation_id,
647            json!({"status":"already-completed","scope":"turn"}),
648        )
649        .await?;
650        Ok(())
651    }
652
653    pub async fn session_for_record(&self, record: &SessionRecord) -> anyhow::Result<Session> {
654        let runtime = self.runtime()?.clone();
655        let mut state = record.state.clone();
656        let session_type = session_type(record);
657        if matches!(session_type.as_str(), "telegram" | "telegram-group") {
658            if !state.get("channel").is_some_and(Value::is_object) {
659                state["channel"] = json!({});
660            }
661            state["channel"]["maxObjectBytes"] = json!(self.config.telegram_max_media_bytes);
662        }
663        let roots = string_array(state.get("rootNodeIds"));
664        let roots = if roots.is_empty() {
665            vec![
666                runtime.user_root_node_id.clone(),
667                runtime.kennedy_root_node_id.clone(),
668            ]
669        } else {
670            roots
671        };
672        let mut options = SessionOptions::conversation(session_type.clone(), roots);
673        options.reference_root_node_ids = string_array(state.get("referenceRootNodeIds"));
674        options.channel = state.get("channel").cloned().unwrap_or(Value::Null);
675        options.free_time = state.get("freeTime").cloned().unwrap_or(Value::Null);
676        options.orchestration = state
677            .get("orchestration")
678            .cloned()
679            .unwrap_or_else(|| json!({"owner":"backend","status":"idle"}));
680        options.provenance_id = state
681            .get("provenanceId")
682            .and_then(Value::as_str)
683            .map(str::to_owned);
684        options.mode = match session_type.as_str() {
685            "free-time" => AgentMode::FreeTime,
686            "wakeup" => AgentMode::Wakeup,
687            _ => AgentMode::Conversation,
688        };
689        self.open_session(runtime, options, Some(&state)).await
690    }
691
692    pub async fn close_conversation(
693        &self,
694        record: &Arc<Mutex<SessionRecord>>,
695        session: &Session,
696    ) -> anyhow::Result<()> {
697        session.release_managed_sources().await;
698        self.request_conversation_ingress(record, None).await
699    }
700
701    pub async fn request_conversation_ingress(
702        &self,
703        record: &Arc<Mutex<SessionRecord>>,
704        state: Option<Value>,
705    ) -> anyhow::Result<()> {
706        let mut locked = record.lock().await;
707        let id = locked.id.clone();
708        let state = state.unwrap_or_else(|| locked.state.clone());
709        let response = self
710            .api
711            .history_request_ingress(
712                &id,
713                kcode_session_history::Checkpoint {
714                    expected_version: locked.version,
715                    state,
716                    user_activity: false,
717                },
718            )
719            .await?;
720        *locked = response;
721        Ok(())
722    }
723
724    async fn complete_command(&self, id: &str, outcome: Value) -> anyhow::Result<()> {
725        self.api.history_complete_command(id, outcome).await?;
726        Ok(())
727    }
728
729    pub async fn get_conversation(&self, id: &str) -> anyhow::Result<SessionRecord> {
730        Ok(self.api.history_get_session(id).await?)
731    }
732
733    pub async fn get_listed_conversation(&self, id: &str) -> anyhow::Result<Option<SessionRecord>> {
734        match self.api.history_get_session(id).await {
735            Ok(record) => Ok(Some(record)),
736            Err(error) if listed_session_disappeared(&error) => Ok(None),
737            Err(error) => Err(error.into()),
738        }
739    }
740
741    async fn schedule_writer_job(
742        self: &Arc<Self>,
743        histories: &[SessionRecord],
744    ) -> anyhow::Result<()> {
745        if self.writer_job_active.load(Ordering::Acquire) {
746            return Ok(());
747        }
748        if let Some(record) = histories
749            .iter()
750            .find(|record| record.phase == "active" && session_type(record) == "free-time")
751            .cloned()
752        {
753            self.launch_writer_job("self time", move |worker| async move {
754                let id = record.id;
755                let Some(record) = worker.get_listed_conversation(&id).await? else {
756                    return Ok(());
757                };
758                worker.process_self_time(record).await
759            })
760            .await;
761            return Ok(());
762        }
763        if let Some(record) = next_ingress(histories, Utc::now()).cloned() {
764            self.launch_writer_job("memory ingress", move |worker| async move {
765                let id = record.id;
766                let Some(record) = worker.get_listed_conversation(&id).await? else {
767                    return Ok(());
768                };
769                worker.process_ingress(record).await
770            })
771            .await;
772        }
773        Ok(())
774    }
775
776    async fn launch_writer_job<F, Fut>(self: &Arc<Self>, label: &'static str, task: F)
777    where
778        F: FnOnce(Arc<Self>) -> Fut + Send + 'static,
779        Fut: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
780    {
781        if self
782            .writer_job_active
783            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
784            .is_err()
785        {
786            return;
787        }
788        let worker = self.clone();
789        tokio::spawn(async move {
790            let _writer_guard = worker.writer.lock().await;
791            if let Err(error) = task(worker.clone()).await {
792                tracing::warn!(
793                    %label,
794                    error=%bounded_error(&error),
795                    "Kmap writer job will retry"
796                );
797            }
798            worker.writer_job_active.store(false, Ordering::Release);
799        });
800    }
801
802    async fn process_ingress(&self, mut record: SessionRecord) -> anyhow::Result<()> {
803        let id = record.id.clone();
804        let rust_session_id = format!("kennedy:history-ingress:{id}");
805        let mut stage = "prepare";
806        let result = async {
807            if record.phase == "ingress_pending" {
808                record
809                    .state
810                    .get("sessionId")
811                    .and_then(Value::as_str)
812                    .context("The queued session has no Session History ID")?;
813                stage = "claim";
814                record = self
815                    .api
816                    .history_start_ingress(
817                        &id,
818                        kcode_session_history::StartIngress {
819                            expected_version: record.version,
820                            provenance_id: format!("session:{id}"),
821                        },
822                    )
823                    .await?;
824            }
825            if record.phase != "ingress_in_progress" {
826                return Ok(());
827            }
828            stage = "model_loop";
829            let runtime = self.runtime()?.clone();
830            let state = record.state.clone();
831            let source_session_type = state
832                .get("sessionType")
833                .and_then(Value::as_str)
834                .unwrap_or("conversation")
835                .to_owned();
836            let roots = {
837                let roots = string_array(state.get("rootNodeIds"));
838                if roots.is_empty() {
839                    vec![
840                        runtime.user_root_node_id.clone(),
841                        runtime.kennedy_root_node_id.clone(),
842                    ]
843                } else {
844                    roots
845                }
846            };
847            let options = SessionOptions {
848                session_type: "history-ingress".into(),
849                root_node_ids: roots,
850                reference_root_node_ids: string_array(state.get("referenceRootNodeIds")),
851                channel: state.get("channel").cloned().unwrap_or(Value::Null),
852                free_time: Value::Null,
853                orchestration: Value::Null,
854                provenance_id: None,
855                mode: AgentMode::Ingress {
856                    record_id: Some(id.clone()),
857                },
858                source_session_type: Some(source_session_type),
859                group_context: state
860                    .get("channel")
861                    .and_then(|channel| channel.get("groupContext"))
862                    .cloned()
863                    .unwrap_or(Value::Null),
864                rust_lib_session_id: Some(rust_session_id.clone()),
865            };
866            let restored = ingress_restore_state(&state);
867            let mut session = self.open_session(runtime, options, Some(restored)).await?;
868            let record = Arc::new(Mutex::new(record));
869            persist_ingress_record(&self.api, &record, session.snapshot()?).await?;
870            if !session.completed {
871                session.pending_turn = true;
872                let api = self.api.clone();
873                let saved_record = record.clone();
874                let completion = self
875                    .run_session_turn(&id, &mut session, Uuid::new_v4(), move |session_state| {
876                        let api = api.clone();
877                        let record = saved_record.clone();
878                        async move {
879                            persist_ingress_record(&api, &record, session_state).await?;
880                            Ok(())
881                        }
882                    })
883                    .await?;
884                if matches!(completion, TurnCompletion::Stopped) {
885                    session.interrupt_current_turn()?;
886                    session.commit_current_write_session()?;
887                    stage = "stop-completion";
888                }
889            }
890            persist_ingress_record(&self.api, &record, session.snapshot()?).await?;
891            stage = "completion";
892            let mut locked = record.lock().await;
893            let completed = self
894                .api
895                .history_complete_ingress(&id, locked.version)
896                .await?;
897            *locked = completed.clone();
898            Ok(())
899        }
900        .await;
901        if let Err(error) = result {
902            self.record_ingress_failure(&id, stage, &error).await.ok();
903            return Err(error);
904        }
905        self.api.release_managed_sources(&rust_session_id).await;
906        Ok(())
907    }
908
909    async fn record_ingress_failure(
910        &self,
911        id: &str,
912        stage: &str,
913        error: &anyhow::Error,
914    ) -> anyhow::Result<()> {
915        let latest = self.get_conversation(id).await?;
916        if !matches!(
917            latest.phase.as_str(),
918            "ingress_pending" | "ingress_in_progress"
919        ) {
920            return Ok(());
921        }
922        self.api
923            .history_fail_ingress(
924                id,
925                kcode_session_history::IngressFailure {
926                    expected_version: latest.version,
927                    stage: stage.to_owned(),
928                    code: Some("ingress_error".into()),
929                    message: bounded_error(error),
930                    rounds_used: None,
931                    context_tokens: None,
932                    context_window_tokens: None,
933                },
934            )
935            .await?;
936        Ok(())
937    }
938
939    async fn process_self_time(&self, record: SessionRecord) -> anyhow::Result<()> {
940        let runtime = self.runtime()?.clone();
941        let id = record.id.clone();
942        let mut state = record.state.clone();
943        if state.get("freeTime").is_none() {
944            let intent = state
945                .get("selfTimeIntent")
946                .context("backend self-time record is missing its durable start intent")?;
947            let duration = intent
948                .get("durationMinutes")
949                .and_then(Value::as_f64)
950                .context("self-time duration is missing")?;
951            let requested = intent
952                .get("requestedAt")
953                .and_then(Value::as_str)
954                .or(Some(record.started_at.as_str()))
955                .context("self-time request time is missing")?;
956            let requested_at = DateTime::parse_from_rfc3339(requested)?.with_timezone(&Utc);
957            let deadline =
958                requested_at + ChronoDuration::milliseconds((duration * 60_000.0).round() as i64);
959            state["freeTime"] = json!({"runId":id,"runStartedAt":requested_at.to_rfc3339(),"deadlineAt":deadline.to_rfc3339(),"durationMinutes":duration,"customPrompt":intent.get("customPrompt").and_then(Value::as_str).unwrap_or(""),"sliceIndex":1});
960            state["orchestration"] = json!({"owner":"backend","status":"running"});
961        }
962        let mut options = SessionOptions::conversation(
963            "free-time",
964            vec![
965                runtime.user_root_node_id.clone(),
966                runtime.kennedy_root_node_id.clone(),
967            ],
968        );
969        options.mode = AgentMode::FreeTime;
970        options.free_time = state.get("freeTime").cloned().unwrap_or(Value::Null);
971        options.provenance_id = state
972            .get("provenanceId")
973            .and_then(Value::as_str)
974            .map(str::to_owned);
975        options.orchestration = json!({"owner":"backend","status":"running"});
976        let mut session = self
977            .open_session(runtime.clone(), options, Some(&state))
978            .await?;
979        session.stage_free_time_opening();
980        let record_arc = Arc::new(Mutex::new(record));
981        persist_record(&self.api, &record_arc, session.snapshot()?, true).await?;
982        let deadline = session
983            .free_time
984            .get("deadlineAt")
985            .and_then(Value::as_str)
986            .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
987            .map(|value| value.with_timezone(&Utc))
988            .context("self-time deadline is invalid")?;
989        let timeout = (deadline - Utc::now() + ChronoDuration::minutes(6))
990            .to_std()
991            .unwrap_or(Duration::ZERO);
992        let operation_id = Uuid::new_v4();
993        let api = self.api.clone();
994        let saved = record_arc.clone();
995        let result = tokio::time::timeout(
996            timeout,
997            self.run_session_turn(&id, &mut session, operation_id, move |state| {
998                let api = api.clone();
999                let record = saved.clone();
1000                async move {
1001                    persist_record(&api, &record, state, false).await?;
1002                    Ok(())
1003                }
1004            }),
1005        )
1006        .await;
1007        let mut reason = match result {
1008            Ok(Ok(TurnCompletion::Stopped)) => "user-stop".into(),
1009            Ok(Ok(TurnCompletion::Finished)) => session
1010                .free_time
1011                .get("sliceEndedReason")
1012                .and_then(Value::as_str)
1013                .unwrap_or_else(|| {
1014                    if Utc::now() >= deadline {
1015                        "deadline"
1016                    } else {
1017                        "tool"
1018                    }
1019                })
1020                .to_owned(),
1021            Ok(Err(error)) => return Err(error),
1022            Err(_) => {
1023                let _ = self.api.cancel_intelligence(operation_id);
1024                self.remove_operation(&id, operation_id).await;
1025                "hard-stop".into()
1026            }
1027        };
1028        if reason != "user-stop" && self.pending_stop(&id).await?.is_some() {
1029            reason = "user-stop".into();
1030        }
1031        if reason == "user-stop" {
1032            session.interrupt_current_turn()?;
1033        }
1034        session.finalize_free_time(&reason)?;
1035        session.commit_current_write_session()?;
1036        persist_record(&self.api, &record_arc, session.snapshot()?, false).await?;
1037        session.release_managed_sources().await;
1038        let mut locked = record_arc.lock().await;
1039        let completed = self
1040            .api
1041            .history_complete(
1042                &id,
1043                kcode_session_history::Checkpoint {
1044                    expected_version: locked.version,
1045                    state: locked.state.clone(),
1046                    user_activity: false,
1047                },
1048            )
1049            .await?;
1050        *locked = completed;
1051        if reason != "user-stop" && deadline - Utc::now() >= ChronoDuration::minutes(5) {
1052            self.create_next_self_time_slice(
1053                &runtime,
1054                session.free_time.clone(),
1055                session.provenance_id.clone(),
1056                deadline,
1057            )
1058            .await?;
1059        }
1060        Ok(())
1061    }
1062
1063    async fn create_next_self_time_slice(
1064        &self,
1065        runtime: &SessionRuntime,
1066        mut free: Value,
1067        provenance_id: Option<String>,
1068        deadline: DateTime<Utc>,
1069    ) -> anyhow::Result<()> {
1070        free["sliceIndex"] = json!(
1071            free.get("sliceIndex")
1072                .and_then(Value::as_u64)
1073                .unwrap_or_default()
1074                + 1
1075        );
1076        if let Some(object) = free.as_object_mut() {
1077            object.remove("sliceEndedReason");
1078            object.remove("sliceEndedAt");
1079            object.remove("warningNoticeAt");
1080            object.remove("expiredNoticeAt");
1081        }
1082        if let Some(message) = free.get("nextSessionMessage").cloned() {
1083            free["handoffMessage"] = message;
1084        }
1085        if let Some(object) = free.as_object_mut() {
1086            object.remove("nextSessionMessage");
1087        }
1088        free["deadlineAt"] = json!(deadline.to_rfc3339());
1089        let mut options = SessionOptions::conversation(
1090            "free-time",
1091            vec![
1092                runtime.user_root_node_id.clone(),
1093                runtime.kennedy_root_node_id.clone(),
1094            ],
1095        );
1096        options.mode = AgentMode::FreeTime;
1097        options.free_time = free;
1098        options.provenance_id = provenance_id;
1099        options.orchestration = json!({"owner":"backend","status":"running"});
1100        let mut session = self.open_session(runtime.clone(), options, None).await?;
1101        session.stage_free_time_opening();
1102        let state = session.snapshot()?;
1103        self.api
1104            .history_register(kcode_session_history::RegisterSession {
1105                id: required_string(&state, "sessionId")?,
1106                started_at: session.started_at.clone(),
1107                state,
1108            })
1109            .await?;
1110        Ok(())
1111    }
1112}
1113
1114pub async fn persist_record(
1115    api: &Api,
1116    record: &Arc<Mutex<SessionRecord>>,
1117    state: Value,
1118    user_activity: bool,
1119) -> anyhow::Result<()> {
1120    let mut record = record.lock().await;
1121    let id = record.id.clone();
1122    let result = match api
1123        .history_checkpoint(
1124            &id,
1125            kcode_session_history::Checkpoint {
1126                expected_version: record.version,
1127                state: state.clone(),
1128                user_activity,
1129            },
1130        )
1131        .await
1132    {
1133        Ok(result) => result,
1134        Err(error) if error.code == "state_conflict" => {
1135            let latest = api.history_get_session(&id).await?;
1136            if latest.state == state {
1137                latest
1138            } else {
1139                return Err(error.into());
1140            }
1141        }
1142        Err(error) => return Err(error.into()),
1143    };
1144    *record = result;
1145    Ok(())
1146}
1147async fn persist_ingress_record(
1148    api: &Api,
1149    record: &Arc<Mutex<SessionRecord>>,
1150    archive: Value,
1151) -> anyhow::Result<()> {
1152    let mut record = record.lock().await;
1153    let id = record.id.clone();
1154    let mut state = record.state.clone();
1155    state["historyIngress"] = archive;
1156    let result = match api
1157        .history_checkpoint(
1158            &id,
1159            kcode_session_history::Checkpoint {
1160                expected_version: record.version,
1161                state: state.clone(),
1162                user_activity: false,
1163            },
1164        )
1165        .await
1166    {
1167        Ok(result) => result,
1168        Err(error) if error.code == "state_conflict" => {
1169            let latest = api.history_get_session(&id).await?;
1170            if latest.state == state {
1171                latest
1172            } else {
1173                return Err(error.into());
1174            }
1175        }
1176        Err(error) => return Err(error.into()),
1177    };
1178    *record = result;
1179    Ok(())
1180}
1181fn session_type(record: &SessionRecord) -> String {
1182    record
1183        .state
1184        .get("sessionType")
1185        .and_then(Value::as_str)
1186        .unwrap_or("conversation")
1187        .into()
1188}
1189
1190fn next_ingress(histories: &[SessionRecord], now: DateTime<Utc>) -> Option<&SessionRecord> {
1191    histories
1192        .iter()
1193        .filter(|record| match record.phase.as_str() {
1194            "ingress_in_progress" => true,
1195            "ingress_pending" => record
1196                .ingress_next_attempt_at
1197                .as_deref()
1198                .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
1199                .is_none_or(|next| next.with_timezone(&Utc) <= now),
1200            _ => false,
1201        })
1202        .min_by(|left, right| ingress_record_order(left, right))
1203}
1204
1205fn ingress_record_order(left: &SessionRecord, right: &SessionRecord) -> std::cmp::Ordering {
1206    let rank = |record: &SessionRecord| {
1207        if record.phase == "ingress_in_progress" {
1208            0
1209        } else {
1210            1
1211        }
1212    };
1213    rank(left)
1214        .cmp(&rank(right))
1215        .then_with(|| ingress_record_time(left).cmp(&ingress_record_time(right)))
1216        .then_with(|| left.id.cmp(&right.id))
1217}
1218
1219fn ingress_record_time(record: &SessionRecord) -> DateTime<Utc> {
1220    [&record.updated_at, &record.started_at]
1221        .into_iter()
1222        .find_map(|value| {
1223            DateTime::parse_from_rfc3339(value)
1224                .ok()
1225                .map(|value| value.with_timezone(&Utc))
1226        })
1227        .unwrap_or(DateTime::<Utc>::MAX_UTC)
1228}
1229
1230fn is_browser_conversation(record: &SessionRecord) -> bool {
1231    session_type(record) == "conversation"
1232}
1233fn required_string(value: &Value, key: &str) -> anyhow::Result<String> {
1234    value
1235        .get(key)
1236        .and_then(Value::as_str)
1237        .filter(|value| !value.is_empty())
1238        .map(str::to_owned)
1239        .with_context(|| format!("backend response omitted {key}"))
1240}
1241fn string_array(value: Option<&Value>) -> Vec<String> {
1242    value
1243        .and_then(Value::as_array)
1244        .into_iter()
1245        .flatten()
1246        .filter_map(Value::as_str)
1247        .map(str::to_owned)
1248        .collect()
1249}
1250fn ingress_restore_state(state: &Value) -> &Value {
1251    state.get("historyIngress").unwrap_or(state)
1252}
1253fn self_time_schedule(value: &Value) -> String {
1254    value
1255        .get("deadlineAt")
1256        .and_then(Value::as_str)
1257        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
1258        .map(|deadline| {
1259            format!(
1260                "The self-time deadline is {}.",
1261                super::prompts::human_utc_datetime(deadline.with_timezone(&Utc))
1262            )
1263        })
1264        .unwrap_or_else(|| "The self-time deadline was not supplied.".into())
1265}
1266fn bounded_error(error: &anyhow::Error) -> String {
1267    format!("{error:#}").chars().take(1_000).collect()
1268}
1269fn listed_session_disappeared(error: &ApiError) -> bool {
1270    error.code == "not_found"
1271}
1272fn is_cancelled(error: &anyhow::Error) -> bool {
1273    error
1274        .downcast_ref::<super::ApiError>()
1275        .is_some_and(|error| error.code == "operation_cancelled")
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281
1282    fn session_record(id: &str, phase: &str, updated_at: &str) -> SessionRecord {
1283        serde_json::from_value(json!({
1284            "id":id,
1285            "phase":phase,
1286            "started_at":updated_at,
1287            "updated_at":updated_at,
1288            "state":{},
1289            "provenance_id":null,
1290            "version":1,
1291            "last_user_message_at":null,
1292            "ended_at":null,
1293            "ingress_failure_count":0,
1294            "ingress_failures":[],
1295            "ingress_next_attempt_at":null
1296        }))
1297        .unwrap()
1298    }
1299
1300    #[test]
1301    fn ingress_resumes_claimed_work_before_pending_work() {
1302        let now = DateTime::parse_from_rfc3339("2026-07-25T03:00:00Z")
1303            .unwrap()
1304            .with_timezone(&Utc);
1305        let pending = session_record("pending", "ingress_pending", "2026-07-25T01:00:00Z");
1306        let claimed = session_record("claimed", "ingress_in_progress", "2026-07-25T02:00:00Z");
1307        assert_eq!(
1308            next_ingress(&[pending, claimed], now).map(|record| record.id.as_str()),
1309            Some("claimed")
1310        );
1311    }
1312
1313    #[test]
1314    fn ingress_restart_prefers_its_own_checkpoint() {
1315        let source = json!({
1316            "sessionType":"conversation",
1317            "historyIngress":{"sessionType":"history-ingress","completed":true}
1318        });
1319        assert_eq!(
1320            ingress_restore_state(&source)["sessionType"],
1321            "history-ingress"
1322        );
1323        assert_eq!(
1324            ingress_restore_state(&json!({"sessionType":"conversation"}))["sessionType"],
1325            "conversation"
1326        );
1327    }
1328
1329    #[test]
1330    fn bounded_errors_include_the_cause_chain() {
1331        let error = anyhow::anyhow!("inner cause").context("outer context");
1332        assert_eq!(bounded_error(&error), "outer context: inner cause");
1333    }
1334}