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}