1use crate::approval_flow::{handle_ask_user, request_approval};
31use crate::config::KodaConfig;
32use crate::db::{Database, Role};
33use crate::engine::{ApprovalDecision, EngineCommand, EngineEvent};
34use crate::file_tracker::FileTracker;
35use crate::persistence::Persistence;
36use crate::preview;
37use crate::providers::ToolCall;
38use crate::sub_agent_cache::SubAgentCache;
39use crate::sub_agent_dispatch;
40use crate::tools;
41use crate::trust::{self, ToolApproval, TrustMode};
42
43use anyhow::Result;
44use std::path::{Path, PathBuf};
45use tokio::sync::mpsc;
46use tokio_util::sync::CancellationToken;
47
48#[allow(clippy::too_many_arguments)]
52pub(crate) async fn record_tool_result(
53 tc: &ToolCall,
54 result: &str,
55 success: bool,
56 full_output: Option<&str>,
57 db: &Database,
58 session_id: &str,
59 max_result_chars: usize,
60 project_root: &Path,
61 file_tracker: &mut FileTracker,
62 sink: &dyn crate::engine::EngineSink,
63) -> Result<()> {
64 sink.emit(EngineEvent::ToolCallResult {
65 id: tc.id.clone(),
66 name: tc.function_name.clone(),
67 output: result.to_string(),
68 });
69
70 if let Some(full) = full_output {
74 db.insert_tool_message_with_full(session_id, result, &tc.id, full)
75 .await?;
76 } else {
77 let stored = truncate_for_history(result, max_result_chars);
78 db.insert_message(
79 session_id,
80 &Role::Tool,
81 Some(&stored),
82 None,
83 Some(&tc.id),
84 None,
85 )
86 .await?;
87 }
88 let parsed_args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
97 track_file_lifecycle(
98 &tc.function_name,
99 &parsed_args,
100 project_root,
101 file_tracker,
102 success,
103 )
104 .await;
105 Ok(())
106}
107
108fn truncate_for_history(output: &str, max_chars: usize) -> String {
111 if output.len() <= max_chars {
112 return output.to_string();
113 }
114 let mut end = max_chars;
116 while end > 0 && !output.is_char_boundary(end) {
117 end -= 1;
118 }
119 format!(
120 "{}\n\n[...truncated {} chars. Re-read the file if you need the full content.]",
121 &output[..end],
122 output.len() - end
123 )
124}
125
126fn resolve_tool_path(
131 tool_name: &str,
132 args: &serde_json::Value,
133 project_root: &Path,
134) -> Option<PathBuf> {
135 if !matches!(tool_name, "Write" | "Delete") {
136 return None;
137 }
138 crate::file_tracker::resolve_file_path_from_args(args, project_root)
139}
140
141async fn track_file_lifecycle(
149 tool_name: &str,
150 args: &serde_json::Value,
151 project_root: &Path,
152 file_tracker: &mut FileTracker,
153 success: bool,
154) {
155 if !success {
156 return;
157 }
158 if let Some(path) = resolve_tool_path(tool_name, args, project_root) {
159 match tool_name {
160 "Write" => file_tracker.track_created(path).await,
161 "Delete" => file_tracker.untrack(&path).await,
162 _ => {}
163 }
164 }
165}
166
167pub(crate) fn can_parallelize(
191 tool_calls: &[ToolCall],
192 mode: TrustMode,
193 project_root: &Path,
194 file_tracker: Option<&crate::file_tracker::FileTracker>,
195) -> bool {
196 let all_approved = !tool_calls.iter().any(|tc| {
197 let args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
198 matches!(
199 trust::check_tool_with_tracker(
200 &tc.function_name,
201 &args,
202 mode,
203 Some(project_root),
204 file_tracker,
205 ),
206 ToolApproval::NeedsConfirmation | ToolApproval::Blocked
207 )
208 });
209
210 if !all_approved {
211 return false;
212 }
213
214 let mut seen = std::collections::HashSet::new();
215 let has_conflict = tool_calls.iter().any(|tc| {
216 if !crate::tools::is_mutating_tool(&tc.function_name) {
217 return false;
218 }
219 let args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
220 if let Some(path) = crate::undo::extract_file_path(&tc.function_name, &args) {
221 !seen.insert(path)
223 } else {
224 false
225 }
226 });
227
228 !has_conflict
229}
230
231#[tracing::instrument(skip_all, fields(tool = %tc.function_name))]
233#[allow(clippy::too_many_arguments)]
234pub(crate) async fn execute_one_tool(
235 tc: &ToolCall,
236 project_root: &Path,
237 config: &KodaConfig,
238 db: &Database,
239 _session_id: &str,
240 tools: &crate::tools::ToolRegistry,
241 mode: TrustMode,
242 sink: &dyn crate::engine::EngineSink,
243 cancel: CancellationToken,
244 sub_agent_cache: &SubAgentCache,
245 bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
246 caller_spawner: Option<u32>,
247) -> (String, String, bool, Option<String>) {
248 let (result, success, full_output) = if matches!(
249 tc.function_name.as_str(),
250 "ListBackgroundTasks" | "CancelTask" | "WaitTask"
251 ) {
252 let r = crate::tools::bg_task_tools::execute(
259 &tc.function_name,
260 &tc.arguments,
261 bg_agents,
262 &tools.bg_registry,
263 caller_spawner,
264 )
265 .await;
266 (r.output, r.success, r.full_output)
267 } else if tc.function_name == "InvokeAgent" {
268 let (_, mut dummy_rx) = mpsc::channel(1);
295 let policy = tools.sandbox_policy().clone();
296 let read_cache = tools.file_read_cache();
297 let fut = sub_agent_dispatch::execute_sub_agent(
298 project_root,
299 config,
300 db,
301 &tc.arguments,
302 mode,
303 sink,
304 cancel.clone(),
305 &mut dummy_rx,
307 Some(read_cache),
308 sub_agent_cache,
309 _session_id,
310 bg_agents,
311 &policy,
314 caller_spawner,
320 None,
325 );
326 match Box::pin(fut).await {
327 Ok(output) => (output, true, None),
328 Err(e) => (format!("Error invoking sub-agent: {e}"), false, None),
329 }
330 } else {
331 if crate::tools::is_mutating_tool(&tc.function_name) {
333 sub_agent_cache.invalidate();
334 }
335 let streaming = if tc.function_name == "Bash" {
336 Some((sink, tc.id.as_str()))
337 } else {
338 None
339 };
340 let r = tools
341 .execute(&tc.function_name, &tc.arguments, streaming, caller_spawner)
342 .await;
343 (r.output, r.success, r.full_output)
344 };
345
346 (tc.id.clone(), result, success, full_output)
347}
348
349#[allow(clippy::too_many_arguments)]
358async fn validate_then_execute_one_tool(
359 tc: &ToolCall,
360 project_root: &Path,
361 config: &KodaConfig,
362 db: &Database,
363 session_id: &str,
364 tools: &crate::tools::ToolRegistry,
365 mode: TrustMode,
366 sink: &dyn crate::engine::EngineSink,
367 cancel: CancellationToken,
368 sub_agent_cache: &SubAgentCache,
369 bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
370 caller_spawner: Option<u32>,
371) -> (String, String, bool, Option<String>) {
372 let parsed_args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
373
374 let validation_error = tools::validate::validate_with_registry(
375 tools,
376 &tc.function_name,
377 &parsed_args,
378 project_root,
379 )
380 .await;
381
382 if let Some(error) = validation_error {
383 return (
384 tc.id.clone(),
385 format!("Validation error: {error}"),
386 false,
387 None,
388 );
389 }
390
391 execute_one_tool(
392 tc,
393 project_root,
394 config,
395 db,
396 session_id,
397 tools,
398 mode,
399 sink,
400 cancel,
401 sub_agent_cache,
402 bg_agents,
403 caller_spawner,
404 )
405 .await
406}
407
408#[allow(clippy::too_many_arguments)]
410pub(crate) async fn execute_tools_parallel(
411 tool_calls: &[ToolCall],
412 project_root: &Path,
413 config: &KodaConfig,
414 db: &Database,
415 session_id: &str,
416 tools: &crate::tools::ToolRegistry,
417 mode: TrustMode,
418 sink: &dyn crate::engine::EngineSink,
419 cancel: CancellationToken,
420 sub_agent_cache: &SubAgentCache,
421 file_tracker: &mut FileTracker,
422 bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
423 caller_spawner: Option<u32>,
424) -> Result<()> {
425 let count = tool_calls.len();
426 sink.emit(EngineEvent::Info {
427 message: format!("Running {count} tools in parallel..."),
428 });
429
430 let futures: Vec<_> = tool_calls
432 .iter()
433 .map(|tc| {
434 validate_then_execute_one_tool(
439 tc,
440 project_root,
441 config,
442 db,
443 session_id,
444 tools,
445 mode,
446 sink,
447 cancel.clone(),
448 sub_agent_cache,
449 bg_agents,
450 caller_spawner,
451 )
452 })
453 .collect();
454 let results = futures_util::future::join_all(futures).await;
455
456 for (i, (tc_id, result, success, full_output)) in results.into_iter().enumerate() {
458 sink.emit(EngineEvent::ToolCallStart {
459 id: tc_id.clone(),
460 name: tool_calls[i].function_name.clone(),
461 args: serde_json::from_str(&tool_calls[i].arguments).unwrap_or_default(),
462 is_sub_agent: false,
463 });
464 record_tool_result(
465 &tool_calls[i],
466 &result,
467 success,
468 full_output.as_deref(),
469 db,
470 session_id,
471 tools.caps.tool_result_chars,
472 project_root,
473 file_tracker,
474 sink,
475 )
476 .await?;
477 }
478 Ok(())
479}
480
481#[allow(clippy::too_many_arguments)]
488pub(crate) async fn execute_tools_split_batch(
489 tool_calls: &[ToolCall],
490 project_root: &Path,
491 config: &KodaConfig,
492 db: &Database,
493 session_id: &str,
494 tools: &crate::tools::ToolRegistry,
495 mode: TrustMode,
496 sink: &dyn crate::engine::EngineSink,
497 cancel: CancellationToken,
498 cmd_rx: &mut mpsc::Receiver<EngineCommand>,
499 sub_agent_cache: &SubAgentCache,
500 file_tracker: &mut FileTracker,
501 bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
502 caller_spawner: Option<u32>,
503) -> Result<()> {
504 let (parallel, sequential): (Vec<_>, Vec<_>) = tool_calls.iter().partition(|tc| {
506 let args: serde_json::Value = serde_json::from_str(&tc.arguments).unwrap_or_default();
507 matches!(
508 trust::check_tool(&tc.function_name, &args, mode, Some(project_root),),
509 ToolApproval::AutoApprove
510 )
511 });
512
513 if parallel.len() > 1 {
515 sink.emit(EngineEvent::Info {
516 message: format!("Running {} tools in parallel...", parallel.len()),
517 });
518
519 let futures: Vec<_> = parallel
520 .iter()
521 .map(|tc| {
522 validate_then_execute_one_tool(
526 tc,
527 project_root,
528 config,
529 db,
530 session_id,
531 tools,
532 mode,
533 sink,
534 cancel.clone(),
535 sub_agent_cache,
536 bg_agents,
537 caller_spawner,
538 )
539 })
540 .collect();
541 let results = futures_util::future::join_all(futures).await;
542
543 for (j, (tc_id, result, success, full_output)) in results.into_iter().enumerate() {
544 sink.emit(EngineEvent::ToolCallStart {
545 id: tc_id.clone(),
546 name: parallel[j].function_name.clone(),
547 args: serde_json::from_str(¶llel[j].arguments).unwrap_or_default(),
548 is_sub_agent: false,
549 });
550 record_tool_result(
551 parallel[j],
552 &result,
553 success,
554 full_output.as_deref(),
555 db,
556 session_id,
557 tools.caps.tool_result_chars,
558 project_root,
559 file_tracker,
560 sink,
561 )
562 .await?;
563 }
564 } else {
565 for tc in ¶llel {
567 let calls = std::slice::from_ref(*tc);
568 execute_tools_sequential(
569 calls,
570 project_root,
571 config,
572 db,
573 session_id,
574 tools,
575 mode,
576 sink,
577 cancel.clone(),
578 cmd_rx,
579 sub_agent_cache,
580 file_tracker,
581 bg_agents,
582 caller_spawner,
583 )
584 .await?;
585 }
586 }
587
588 if !sequential.is_empty() {
590 let seq_calls: Vec<ToolCall> = sequential.into_iter().cloned().collect();
591 execute_tools_sequential(
592 &seq_calls,
593 project_root,
594 config,
595 db,
596 session_id,
597 tools,
598 mode,
599 sink,
600 cancel.clone(),
601 cmd_rx,
602 sub_agent_cache,
603 file_tracker,
604 bg_agents,
605 caller_spawner,
606 )
607 .await?;
608 }
609
610 Ok(())
611}
612
613#[allow(clippy::too_many_arguments)]
615pub(crate) async fn execute_tools_sequential(
616 tool_calls: &[ToolCall],
617 project_root: &Path,
618 config: &KodaConfig,
619 db: &Database,
620 session_id: &str,
621 tools: &crate::tools::ToolRegistry,
622 mode: TrustMode,
623 sink: &dyn crate::engine::EngineSink,
624 cancel: CancellationToken,
625 cmd_rx: &mut mpsc::Receiver<EngineCommand>,
626 sub_agent_cache: &SubAgentCache,
627 file_tracker: &mut FileTracker,
628 bg_agents: &std::sync::Arc<crate::bg_agent::BgAgentRegistry>,
629 caller_spawner: Option<u32>,
630) -> Result<()> {
631 for tc in tool_calls {
632 if cancel.is_cancelled() {
634 sink.emit(EngineEvent::Warn {
635 message: "Interrupted".into(),
636 });
637 return Ok(());
638 }
639
640 let parsed_args: serde_json::Value =
641 serde_json::from_str(&tc.arguments).unwrap_or_default();
642
643 sink.emit(EngineEvent::ToolCallStart {
644 id: tc.id.clone(),
645 name: tc.function_name.clone(),
646 args: parsed_args.clone(),
647 is_sub_agent: false,
648 });
649
650 if tc.function_name == "AskUser" {
653 let answer = handle_ask_user(sink, cmd_rx, &cancel, &parsed_args).await;
654 let result = match answer {
655 Some(text) if !text.trim().is_empty() => text,
656 Some(_) => "User did not provide an answer.".into(),
657 None => return Ok(()), };
659 record_tool_result(
660 tc,
661 &result,
662 true,
663 None, db,
665 session_id,
666 tools.caps.tool_result_chars,
667 project_root,
668 file_tracker,
669 sink,
670 )
671 .await?;
672 continue;
673 }
674
675 if let Some(error) = tools::validate::validate_with_registry(
678 tools,
679 &tc.function_name,
680 &parsed_args,
681 project_root,
682 )
683 .await
684 {
685 record_tool_result(
686 tc,
687 &format!("Validation error: {error}"),
688 false,
689 None,
690 db,
691 session_id,
692 tools.caps.tool_result_chars,
693 project_root,
694 file_tracker,
695 sink,
696 )
697 .await?;
698 continue;
699 }
700
701 let approval = trust::check_tool_with_tracker(
703 &tc.function_name,
704 &parsed_args,
705 mode,
706 Some(project_root),
707 Some(file_tracker),
708 );
709
710 match approval {
711 ToolApproval::AutoApprove => {
712 }
714 ToolApproval::Blocked => {
715 let detail = tools::describe_action(&tc.function_name, &parsed_args);
717 let diff_preview =
718 preview::compute(&tc.function_name, &parsed_args, project_root).await;
719 sink.emit(EngineEvent::ActionBlocked {
720 tool_name: tc.function_name.clone(),
721 detail: detail.clone(),
722 preview: diff_preview,
723 });
724 db.insert_message(
725 session_id,
726 &Role::Tool,
727 Some("[safe mode] Action blocked. You are in read-only mode. DO NOT retry this command. Describe what you would do instead. The user must press Shift+Tab to switch to auto or strict mode."),
728 None,
729 Some(&tc.id),
730 None,
731 )
732 .await?;
733 continue;
734 }
735 ToolApproval::NeedsConfirmation => {
736 let detail = tools::describe_action(&tc.function_name, &parsed_args);
737 let diff_preview =
738 preview::compute(&tc.function_name, &parsed_args, project_root).await;
739 let effect = crate::trust::resolve_tool_effect_with_registry(
740 &tc.function_name,
741 &parsed_args,
742 tools,
743 );
744
745 match request_approval(
746 sink,
747 cmd_rx,
748 &cancel,
749 &tc.function_name,
750 &detail,
751 diff_preview,
752 effect,
753 )
754 .await
755 {
756 Some(ApprovalDecision::Approve) => {}
757 Some(ApprovalDecision::Reject) => {
758 db.insert_message(
759 session_id,
760 &Role::Tool,
761 Some("User rejected this action."),
762 None,
763 Some(&tc.id),
764 None,
765 )
766 .await?;
767 continue;
768 }
769 Some(ApprovalDecision::RejectWithFeedback { feedback }) => {
770 let result = format!("User rejected this action with feedback: {feedback}");
771 db.insert_message(
772 session_id,
773 &Role::Tool,
774 Some(&result),
775 None,
776 Some(&tc.id),
777 None,
778 )
779 .await?;
780 continue;
781 }
782 Some(ApprovalDecision::RejectAuto { reason }) => {
783 let result = format!("[auto-rejected: {reason}]");
788 db.insert_message(
789 session_id,
790 &Role::Tool,
791 Some(&result),
792 None,
793 Some(&tc.id),
794 None,
795 )
796 .await?;
797 continue;
798 }
799 None => {
800 return Ok(());
802 }
803 }
804 }
805 }
806
807 let (_, result, success, full_output) = execute_one_tool(
808 tc,
809 project_root,
810 config,
811 db,
812 session_id,
813 tools,
814 mode,
815 sink,
816 cancel.clone(),
817 sub_agent_cache,
818 bg_agents,
819 caller_spawner,
820 )
821 .await;
822 record_tool_result(
823 tc,
824 &result,
825 success,
826 full_output.as_deref(),
827 db,
828 session_id,
829 tools.caps.tool_result_chars,
830 project_root,
831 file_tracker,
832 sink,
833 )
834 .await?;
835 }
836 Ok(())
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use crate::providers::ToolCall;
843
844 fn make_tool_call(name: &str) -> ToolCall {
845 ToolCall {
846 id: "t1".to_string(),
847 function_name: name.to_string(),
848 arguments: "{}".to_string(),
849 thought_signature: None,
850 }
851 }
852
853 #[test]
854 fn test_can_parallelize_read_only() {
855 let calls = vec![make_tool_call("Read"), make_tool_call("Grep")];
856 assert!(can_parallelize(
857 &calls,
858 TrustMode::Safe,
859 Path::new("/test/project"),
860 None,
861 ));
862 }
863
864 #[test]
865 fn test_cannot_parallelize_writes() {
866 let calls = vec![make_tool_call("Read"), make_tool_call("Write")];
867 assert!(!can_parallelize(
868 &calls,
869 TrustMode::Safe,
870 Path::new("/test/project"),
871 None,
872 ));
873 }
874
875 #[test]
876 fn test_cannot_parallelize_bash() {
877 let calls = vec![
879 make_tool_call("Read"),
880 ToolCall {
881 id: "t2".to_string(),
882 function_name: "Bash".to_string(),
883 arguments: r#"{"command": "rm -rf /tmp/test"}"#.to_string(),
884 thought_signature: None,
885 },
886 ];
887 assert!(!can_parallelize(
888 &calls,
889 TrustMode::Safe,
890 Path::new("/test/project"),
891 None,
892 ));
893 }
894
895 #[test]
896 fn test_can_parallelize_agents() {
897 let calls = vec![make_tool_call("InvokeAgent"), make_tool_call("InvokeAgent")];
898 assert!(can_parallelize(
899 &calls,
900 TrustMode::Safe,
901 Path::new("/test/project"),
902 None,
903 ));
904 }
905
906 #[test]
907 fn test_cannot_parallelize_same_file_edits() {
908 let calls = vec![
909 ToolCall {
910 id: "t1".to_string(),
911 function_name: "Edit".to_string(),
912 arguments: r#"{"file_path": "src/main.rs"}"#.to_string(),
913 thought_signature: None,
914 },
915 ToolCall {
916 id: "t2".to_string(),
917 function_name: "Edit".to_string(),
918 arguments: r#"{"file_path": "src/main.rs"}"#.to_string(),
919 thought_signature: None,
920 },
921 ];
922 assert!(!can_parallelize(
923 &calls,
924 TrustMode::Auto, Path::new("/test/project"),
926 None,
927 ));
928 }
929
930 #[test]
931 fn test_can_parallelize_different_file_edits() {
932 let calls = vec![
933 ToolCall {
934 id: "t1".to_string(),
935 function_name: "Edit".to_string(),
936 arguments: r#"{"file_path": "src/main.rs"}"#.to_string(),
937 thought_signature: None,
938 },
939 ToolCall {
940 id: "t2".to_string(),
941 function_name: "Edit".to_string(),
942 arguments: r#"{"file_path": "src/lib.rs"}"#.to_string(),
943 thought_signature: None,
944 },
945 ];
946 assert!(can_parallelize(
947 &calls,
948 TrustMode::Auto,
949 Path::new("/test/project"),
950 None,
951 ));
952 }
953
954 #[test]
955 fn test_is_mutating_tool() {
956 assert!(crate::tools::is_mutating_tool("Write"));
957 assert!(crate::tools::is_mutating_tool("Edit"));
958 assert!(crate::tools::is_mutating_tool("Delete"));
959 assert!(crate::tools::is_mutating_tool("Bash"));
960 assert!(crate::tools::is_mutating_tool("MemoryWrite"));
961 assert!(!crate::tools::is_mutating_tool("Read"));
962 assert!(!crate::tools::is_mutating_tool("List"));
963 assert!(!crate::tools::is_mutating_tool("InvokeAgent"));
965 }
966
967 #[test]
968 fn test_mixed_batch_not_fully_parallelizable() {
969 let calls = vec![make_tool_call("InvokeAgent"), make_tool_call("Write")];
970 assert!(!can_parallelize(
971 &calls,
972 TrustMode::Safe,
973 Path::new("/test/project"),
974 None,
975 ));
976 }
977
978 #[test]
979 fn test_mixed_batch_fully_parallelizable_in_auto() {
980 let calls = vec![make_tool_call("InvokeAgent"), make_tool_call("Write")];
981 assert!(can_parallelize(
982 &calls,
983 TrustMode::Auto,
984 Path::new("/test/project"),
985 None,
986 ));
987 }
988
989 #[tokio::test]
1000 async fn test_can_parallelize_delete_owned_file_uses_tracker() {
1001 let dir = tempfile::TempDir::new().unwrap();
1002 let db = crate::db::Database::open(&dir.path().join("test.db"))
1003 .await
1004 .unwrap();
1005 let mut tracker = crate::file_tracker::FileTracker::new("test-sess", db).await;
1006 let root = dir.path().join("project");
1013 std::fs::create_dir_all(&root).unwrap();
1014 let root = root.canonicalize().unwrap();
1015 let owned_abs = root.join("temp_output.md");
1016 std::fs::write(&owned_abs, "").unwrap();
1017 tracker
1018 .track_created(owned_abs.canonicalize().unwrap())
1019 .await;
1020
1021 let calls = vec![
1025 ToolCall {
1026 id: "t1".to_string(),
1027 function_name: "Read".to_string(),
1028 arguments: r#"{"path": "other.txt"}"#.to_string(),
1029 thought_signature: None,
1030 },
1031 ToolCall {
1032 id: "t2".to_string(),
1033 function_name: "Delete".to_string(),
1034 arguments: r#"{"path": "temp_output.md"}"#.to_string(),
1035 thought_signature: None,
1036 },
1037 ];
1038
1039 assert!(
1042 !can_parallelize(&calls, TrustMode::Safe, &root, None),
1043 "sanity: without tracker, Delete must look like NeedsConfirmation"
1044 );
1045
1046 assert!(
1049 can_parallelize(&calls, TrustMode::Safe, &root, Some(&tracker)),
1050 "with tracker, Delete of Koda-owned file must be \
1051 parallel-eligible (matches sequential path classification)"
1052 );
1053 }
1054}