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