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