1use std::path::Path;
13
14use anyhow::{Context, Result, bail, ensure};
15
16use super::worker_binary::{
17 apply_staged_execution_setting, bridge_launch, container_upload_ownership_args, stage_profile,
18};
19use super::{Controller, execute_checked};
20use crate::targets::{self, CommandExecutor, CommandSpec, ProcessExecutor};
21use mj_core::worker_launch::{
22 REVIEWER_DIR, ReviewMcpDelivery, ReviewMcpServer, ReviewerLaunchConfig,
23 reviewer_staging_profile_home,
24};
25
26pub fn reviewer_stager() -> mj_client::session::ReviewerStager {
30 mj_client::session::ReviewerStager::new(ControllerReviewerStager)
31}
32
33struct ControllerReviewerStager;
34
35impl mj_client::session::ReviewerStagerBackend for ControllerReviewerStager {
36 fn stage(
37 &self,
38 _config: mj_core::config::Config,
39 session: mj_core::state::SessionRecord,
40 profile_id: String,
41 generation: u64,
42 cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
43 ) -> Result<ReviewerLaunchConfig> {
44 let session_id = session.id.clone();
45 let controller = Controller::load()?;
46 let executor = crate::targets::CancellableProcessExecutor::new(cancelled)
47 .with_deadline(std::time::Duration::from_secs(90));
48 controller.stage_reviewer_profile_controlled(
49 &session_id,
50 &profile_id,
51 generation,
52 &[],
53 &executor,
54 )
55 }
56}
57
58impl Controller {
59 pub fn stage_reviewer_profile(
65 &self,
66 session_id: &str,
67 profile_id: &str,
68 generation: u64,
69 ) -> Result<ReviewerLaunchConfig> {
70 self.stage_reviewer_profile_controlled(
71 session_id,
72 profile_id,
73 generation,
74 &[],
75 &ProcessExecutor,
76 )
77 }
78
79 pub fn stage_reviewer_profile_with_mcp(
87 &self,
88 session_id: &str,
89 profile_id: &str,
90 generation: u64,
91 mcp_servers: &[ReviewMcpServer],
92 dispatch_tool: bool,
93 ) -> Result<ReviewerLaunchConfig> {
94 let mut servers = mcp_servers.to_vec();
95 if dispatch_tool {
96 let (_, worker_root) = self.worker_placement(session_id)?;
97 servers.push(review_dispatch_server(&worker_root));
98 }
99 self.stage_reviewer_profile_controlled(
100 session_id,
101 profile_id,
102 generation,
103 &servers,
104 &ProcessExecutor,
105 )
106 }
107
108 pub fn stage_reviewer_profile_controlled(
109 &self,
110 session_id: &str,
111 profile_id: &str,
112 generation: u64,
113 mcp_servers: &[ReviewMcpServer],
114 executor: &impl CommandExecutor,
115 ) -> Result<ReviewerLaunchConfig> {
116 let profile = self
117 .config
118 .profiles
119 .get(profile_id)
120 .with_context(|| format!("unknown profile {profile_id:?}"))?;
121 ensure!(
122 profile.enabled,
123 "reviewer profile {profile_id:?} is disabled"
124 );
125 let session = self
126 .state
127 .sessions
128 .get(session_id)
129 .with_context(|| format!("unknown session {session_id}"))?;
130 let target = session.target_runtime_settings(&self.config)?;
131 let execution_policy = profile
132 .kind
133 .effective_execution_policy(target.execution_policy);
134 if execution_policy.is_unconstrained() && !target.execution_policy.is_unconstrained() {
138 let session_unconstrained = self
139 .config
140 .profiles
141 .get(&session.last_profile)
142 .is_some_and(|session_profile| {
143 session_profile
144 .kind
145 .effective_execution_policy(target.execution_policy)
146 .is_unconstrained()
147 });
148 ensure!(
149 session_unconstrained,
150 "{} cannot review this session: it has no guardian approval mode, so it would run unconstrained while the session runs with approvals on target {:?}. Choose another reviewer, or run the session on a container target",
151 profile.kind.display_name(),
152 session.target_template_id
153 );
154 }
155 let (backend, worker_root) = self.worker_placement(session_id)?;
156
157 let staging = tempfile::tempdir().context("create reviewer staging directory")?;
158 let local = staging.path().join("profile");
159 stage_profile(profile, &local).with_context(|| format!("stage profile {profile_id:?}"))?;
160 apply_staged_execution_setting(profile.kind, execution_policy, &local)
161 .with_context(|| format!("stage profile {profile_id:?}"))?;
162 if !mcp_servers.is_empty()
166 && ReviewMcpDelivery::for_harness(profile.kind) == ReviewMcpDelivery::HarnessProfile
167 {
168 configure_staged_review_mcp(profile.kind, &local, mcp_servers)
169 .with_context(|| format!("configure reviewer MCP servers for {profile_id:?}"))?;
170 }
171 upload_reviewer_profile(executor, &backend, &worker_root, generation, &local)?;
172
173 let (bridge_command, bridge_args) = bridge_launch(profile.kind, execution_policy);
174 let mut environment = profile.environment.resolved().clone();
175 environment.remove(profile.home_env());
179 let excluded_environment = profile.exclude_harness_environment(&mut environment);
182 Ok(ReviewerLaunchConfig {
183 profile_id: profile_id.to_owned(),
184 harness: profile.kind,
185 bridge_command: bridge_command.into(),
186 bridge_args,
187 environment,
188 excluded_environment,
189 execution_policy,
190 model: None,
191 effort: None,
192 fast_mode: None,
193 generation,
194 mcp_servers: mcp_servers.to_vec(),
197 })
198 }
199}
200
201fn configure_staged_review_mcp(
209 harness: mj_core::config::HarnessKind,
210 profile_stage: &Path,
211 servers: &[ReviewMcpServer],
212) -> Result<()> {
213 let Some(file) = harness.mcp_config_file() else {
214 bail!("{harness:?} does not read MCP servers from its profile");
215 };
216 let kimi = harness == mj_core::config::HarnessKind::Kimi;
217 let path = profile_stage.join(file);
218 let mut document = match std::fs::read(&path) {
219 Ok(body) => serde_json::from_slice::<serde_json::Value>(&body)
220 .with_context(|| format!("parse staged reviewer configuration {}", path.display()))?,
221 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
222 serde_json::Value::Object(serde_json::Map::new())
223 }
224 Err(error) => {
225 return Err(error)
226 .with_context(|| format!("read staged reviewer configuration {}", path.display()));
227 }
228 };
229 let root = document.as_object_mut().with_context(|| {
230 format!(
231 "staged reviewer configuration {} must contain a JSON object",
232 path.display()
233 )
234 })?;
235 let configured = root
236 .entry("mcpServers")
237 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
238 .as_object_mut()
239 .with_context(|| {
240 format!(
241 "mcpServers in staged reviewer configuration {} must be a JSON object",
242 path.display()
243 )
244 })?;
245 for server in servers {
246 let mut entry = serde_json::json!({
247 "command": server.command,
248 "args": server.args,
249 });
250 if kimi {
251 let object = entry
252 .as_object_mut()
253 .expect("the server entry is a JSON object");
254 object.insert("transport".into(), "stdio".into());
255 object.insert("runtime_id".into(), "local".into());
256 } else {
257 let object = entry
258 .as_object_mut()
259 .expect("the server entry is a JSON object");
260 object.insert("type".into(), "stdio".into());
261 }
262 configured.insert(server.name.clone(), entry);
263 }
264 let mut body = serde_json::to_vec_pretty(&document)?;
265 body.push(b'\n');
266 mj_core::config::atomic_write(&path, &body)
267 .with_context(|| format!("write staged reviewer configuration {}", path.display()))
268}
269
270fn review_dispatch_server(worker_root: &str) -> ReviewMcpServer {
274 let socket = format!(
275 "{worker_root}/{}/{}",
276 REVIEWER_DIR,
277 mj_core::review::mcp::REVIEW_DISPATCH_SOCKET
278 );
279 ReviewMcpServer {
280 name: mj_core::review::mcp::REVIEW_MCP_SERVER_NAME.to_owned(),
281 command: Path::new(worker_root).join("hel"),
282 args: vec![
283 "worker".to_owned(),
284 "review-mcp".to_owned(),
285 "--socket".to_owned(),
286 socket,
287 ],
288 }
289}
290
291fn reviewer_profile_home(worker_root: &str, generation: u64) -> String {
297 reviewer_staging_profile_home(Path::new(worker_root), generation)
298 .to_string_lossy()
299 .into_owned()
300}
301
302fn upload_reviewer_profile(
308 executor: &impl CommandExecutor,
309 locator: &targets::TargetLocator,
310 worker_root: &str,
311 generation: u64,
312 local: &Path,
313) -> Result<()> {
314 let home = reviewer_profile_home(worker_root, generation);
315 match locator {
316 targets::TargetLocator::LocalBare { .. } => {
317 for command in [
318 CommandSpec::new("rm", ["-rf", "--", &home])
319 .purpose("clear the local reviewer profile"),
320 CommandSpec::new("mkdir", ["-p", &home])
321 .purpose("create the local reviewer profile directory"),
322 CommandSpec::new(
323 "cp",
324 [
325 "-R".to_owned(),
326 format!("{}/.", local.display()),
327 home.clone(),
328 ],
329 )
330 .purpose("install the local reviewer profile"),
331 CommandSpec::new("chmod", ["-R", "go-rwx", &home])
332 .purpose("restrict local reviewer profile permissions"),
333 ] {
334 execute_checked(executor, command)?;
335 }
336 }
337 targets::TargetLocator::LocalPodman { container_id, .. }
338 | targets::TargetLocator::LocalDocker { container_id, .. }
339 | targets::TargetLocator::AppleContainer { container_id, .. } => {
340 let engine = match locator {
341 targets::TargetLocator::LocalPodman { .. } => "podman",
342 targets::TargetLocator::LocalDocker { .. } => "docker",
343 targets::TargetLocator::AppleContainer { .. } => "container",
344 _ => unreachable!("matched local container target"),
345 };
346 for arguments in [
347 vec![
348 "exec".to_owned(),
349 container_id.clone(),
350 "rm".to_owned(),
351 "-rf".to_owned(),
352 "--".to_owned(),
353 home.clone(),
354 ],
355 vec![
356 "exec".to_owned(),
357 container_id.clone(),
358 "mkdir".to_owned(),
359 "-p".to_owned(),
360 home.clone(),
361 ],
362 vec![
363 "cp".to_owned(),
364 format!("{}/.", local.display()),
365 format!("{container_id}:{home}"),
366 ],
367 container_upload_ownership_args(container_id, worker_root, &[&home]),
368 vec![
369 "exec".to_owned(),
370 container_id.clone(),
371 "chmod".to_owned(),
372 "-R".to_owned(),
373 "go-rwx".to_owned(),
374 home.clone(),
375 ],
376 ] {
377 execute_checked(
378 executor,
379 CommandSpec::new(engine, arguments).purpose("stage the reviewer profile"),
380 )?;
381 }
382 }
383 targets::TargetLocator::AwsEc2 { ssh, .. }
384 | targets::TargetLocator::SshBare { ssh, .. } => {
385 let incoming = format!("{home}.incoming");
386 execute_checked(
387 executor,
388 crate::targets::ssh_command(ssh, ["mkdir", "-p", worker_root])
389 .purpose("create the reviewer directory"),
390 )?;
391 execute_checked(
392 executor,
393 crate::targets::ssh_command(ssh, ["rm", "-rf", "--", &incoming, &home])
394 .purpose("clear the reviewer profile"),
395 )?;
396 execute_checked(
397 executor,
398 crate::targets::scp_upload(ssh, local, &incoming, true)
399 .purpose("upload the reviewer profile"),
400 )?;
401 execute_checked(
402 executor,
403 crate::targets::ssh_command(ssh, ["mv", &incoming, &home])
404 .purpose("install the reviewer profile"),
405 )?;
406 execute_checked(
407 executor,
408 crate::targets::ssh_command(ssh, ["chmod", "-R", "go-rwx", &home])
409 .purpose("restrict reviewer profile permissions"),
410 )?;
411 }
412 targets::TargetLocator::SshPodman {
413 ssh, container_id, ..
414 }
415 | targets::TargetLocator::SshDocker {
416 ssh, container_id, ..
417 } => {
418 let engine = match locator {
419 targets::TargetLocator::SshPodman { .. } => "podman",
420 targets::TargetLocator::SshDocker { .. } => "docker",
421 _ => unreachable!("matched remote container target"),
422 };
423 let upload = format!("{worker_root}/.reviewer-upload-{generation}");
424 execute_checked(
425 executor,
426 crate::targets::ssh_command(ssh, ["rm", "-rf", "--", &upload])
427 .purpose("clear remote reviewer staging"),
428 )?;
429 execute_checked(
430 executor,
431 crate::targets::scp_upload(ssh, local, &upload, true)
432 .purpose("upload the remote reviewer profile"),
433 )?;
434 for arguments in [
435 vec![
436 engine.to_owned(),
437 "exec".to_owned(),
438 container_id.clone(),
439 "rm".to_owned(),
440 "-rf".to_owned(),
441 "--".to_owned(),
442 home.clone(),
443 ],
444 vec![
445 engine.to_owned(),
446 "exec".to_owned(),
447 container_id.clone(),
448 "mkdir".to_owned(),
449 "-p".to_owned(),
450 home.clone(),
451 ],
452 vec![
453 engine.to_owned(),
454 "cp".to_owned(),
455 format!("{upload}/."),
456 format!("{container_id}:{home}"),
457 ],
458 std::iter::once(engine.to_owned())
459 .chain(container_upload_ownership_args(
460 container_id,
461 worker_root,
462 &[&home],
463 ))
464 .collect(),
465 vec![
466 engine.to_owned(),
467 "exec".to_owned(),
468 container_id.clone(),
469 "chmod".to_owned(),
470 "-R".to_owned(),
471 "go-rwx".to_owned(),
472 home.clone(),
473 ],
474 ] {
475 execute_checked(
476 executor,
477 crate::targets::ssh_command(ssh, arguments)
478 .purpose("stage the remote reviewer profile"),
479 )?;
480 }
481 execute_checked(
482 executor,
483 crate::targets::ssh_command(ssh, ["rm", "-rf", "--", &upload])
484 .purpose("remove remote reviewer staging"),
485 )?;
486 }
487 }
488 if home.trim().is_empty() {
489 bail!("the reviewer profile home resolved to an empty path");
490 }
491 Ok(())
492}
493
494#[cfg(test)]
495mod tests {
496 use std::cell::RefCell;
497 use std::collections::BTreeMap;
498
499 use super::*;
500 use crate::controller::test_support::checkpoint_test_session;
501 use mj_core::config::{Config, HarnessKind, HarnessProfile, TargetTemplate};
502 use mj_core::state::{SessionState, State};
503
504 use crate::targets::CommandOutput;
505
506 struct RecordingExecutor {
507 commands: RefCell<Vec<CommandSpec>>,
508 }
509
510 impl RecordingExecutor {
511 fn new() -> Self {
512 Self {
513 commands: RefCell::new(Vec::new()),
514 }
515 }
516
517 fn script(&self) -> Vec<String> {
519 self.commands
520 .borrow()
521 .iter()
522 .map(|command| format!("{} {}", command.program, command.args.join(" ")))
523 .collect()
524 }
525 }
526
527 impl CommandExecutor for RecordingExecutor {
528 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
529 self.commands.borrow_mut().push(command.clone());
530 Ok(CommandOutput {
531 status: 0,
532 stdout: Vec::new(),
533 stderr: Vec::new(),
534 })
535 }
536 }
537
538 const SESSION_ID: &str = "0123456789abcdef0123456789abcdef";
541
542 fn fixture(directory: &Path, locator: mj_core::state::TargetLocator) -> (Controller, String) {
543 let session_id = SESSION_ID;
544 let mut session = checkpoint_test_session(session_id);
545 session.target_template_id = "local".into();
546 session.state = SessionState::Running;
547 let template = match &locator {
548 mj_core::state::TargetLocator::LocalPodman { .. } => {
549 serde_json::from_str(r#"{"kind":"local-podman","image":"test"}"#).unwrap()
550 }
551 mj_core::state::TargetLocator::LocalDocker { .. } => {
552 serde_json::from_str(r#"{"kind":"local-docker","image":"test"}"#).unwrap()
553 }
554 _ => TargetTemplate::LocalBare,
555 };
556 session.target = Some(locator);
557 let mut config = Config::default();
558 config.targets.insert("local".into(), template);
559 for (id, kind) in [
560 ("codex", HarnessKind::Codex),
561 ("claude", HarnessKind::Claude),
562 ] {
563 let home = directory.join(id);
564 std::fs::create_dir_all(&home).unwrap();
565 config.profiles.insert(
566 id.to_owned(),
567 HarnessProfile {
568 enabled: true,
569 kind,
570 home,
571 environment: BTreeMap::from([("EXTRA".into(), "1".into())]).into(),
572 context_window_bytes: None,
573 subagents: Default::default(),
574 guardian_review_model: None,
575 },
576 );
577 }
578 (
579 Controller {
580 config,
581 state: State {
582 sessions: [(session_id.into(), session)].into_iter().collect(),
583 ..State::default()
584 },
585 },
586 session_id.to_owned(),
587 )
588 }
589
590 #[test]
591 fn staging_copies_the_chosen_profile_into_the_worker_root() {
592 let directory = tempfile::tempdir().unwrap();
593 let worker_root = directory.path().join(SESSION_ID);
594 std::fs::create_dir_all(directory.path().join("claude")).unwrap();
595 std::fs::write(directory.path().join("claude/CLAUDE.md"), b"reviewer").unwrap();
597 let (controller, session_id) = fixture(
598 directory.path(),
599 mj_core::state::TargetLocator::LocalBare {
600 worker_root: worker_root.clone(),
601 },
602 );
603 let executor = RecordingExecutor::new();
604
605 let config = controller
606 .stage_reviewer_profile_controlled(&session_id, "claude", 0, &[], &executor)
607 .unwrap();
608
609 assert_eq!(config.profile_id, "claude");
610 assert_eq!(config.harness, HarnessKind::Claude);
611 assert_eq!(config.generation, 0);
612 assert_eq!(config.model, None);
613 assert_eq!(config.effort, None);
614 assert!(
616 !config
617 .environment
618 .contains_key(HarnessKind::Claude.home_env())
619 );
620 assert_eq!(
621 config.environment.get("EXTRA").map(String::as_str),
622 Some("1")
623 );
624
625 let home = format!("{}/reviewer/profile", worker_root.display());
626 let script = executor.script();
627 let cleared = script
628 .iter()
629 .position(|line| line.starts_with("rm ") && line.contains(&home))
630 .expect("the previous reviewer profile is cleared");
631 let copied = script
632 .iter()
633 .position(|line| line.starts_with("cp ") && line.ends_with(&home))
634 .expect("the staged profile is installed");
635 assert!(
636 cleared < copied,
637 "a stale profile must go before the new one lands: {script:?}"
638 );
639 assert!(
640 script
641 .iter()
642 .any(|line| line.contains("go-rwx") && line.contains(&home)),
643 "the reviewer profile must not be world readable: {script:?}"
644 );
645 }
646
647 #[test]
648 fn local_container_targets_stage_the_reviewer_through_their_engine() {
649 let directory = tempfile::tempdir().unwrap();
650 let container_id = crate::targets::resource_name(SESSION_ID).unwrap();
651 for (locator, engine) in [
652 (
653 mj_core::state::TargetLocator::LocalPodman {
654 borrowed_from: None,
655 container_id: container_id.clone(),
656 workspace_storage: Default::default(),
657 },
658 "podman",
659 ),
660 (
661 mj_core::state::TargetLocator::LocalDocker {
662 borrowed_from: None,
663 container_id: container_id.clone(),
664 },
665 "docker",
666 ),
667 ] {
668 let (controller, session_id) = fixture(directory.path(), locator);
669 let executor = RecordingExecutor::new();
670
671 controller
672 .stage_reviewer_profile_controlled(&session_id, "codex", 3, &[], &executor)
673 .unwrap();
674
675 let script = executor.script();
676 assert!(
677 script
678 .iter()
679 .all(|line| line.starts_with(&format!("{engine} "))),
680 "a container target is reached only through its engine: {script:?}"
681 );
682 let home = script
683 .iter()
684 .find_map(|line| {
685 line.split(' ')
686 .find(|word| word.contains("/reviewer/profile"))
687 })
688 .expect("the reviewer profile is placed")
689 .to_owned();
690 assert!(
691 home.contains(&format!("/{session_id}")),
692 "the reviewer lives under this session's worker root: {home}"
693 );
694 assert!(
696 !script.iter().any(|line| {
697 line.contains("run") || line.contains("git") || line.contains("create")
698 }),
699 "staging a reviewer provisions nothing: {script:?}"
700 );
701 }
702 }
703
704 #[test]
705 fn reviewer_staging_preserves_owned_approval_for_both_mcp_delivery_paths() {
706 let directory = tempfile::tempdir().unwrap();
707 let (controller, session_id) = fixture(
708 directory.path(),
709 mj_core::state::TargetLocator::LocalBare {
710 worker_root: directory.path().join(SESSION_ID),
711 },
712 );
713 let mut servers =
714 mj_review::bifrost::review_mcp_servers(&[directory.path().to_owned()], "review");
715 servers.push(review_dispatch_server("/worker"));
716 for profile in ["codex", "claude"] {
717 let executor = RecordingExecutor::new();
718 let config = controller
719 .stage_reviewer_profile_controlled(&session_id, profile, 1, &servers, &executor)
720 .unwrap();
721 assert_eq!(config.mcp_servers, servers);
722 assert!(!config.mcp_servers[0].is_review_dispatch(Path::new("/worker/hel")));
723 assert!(config.mcp_servers[1].is_review_dispatch(Path::new("/worker/hel")));
724 }
725 }
726
727 #[test]
728 fn an_unconstrained_reviewer_is_refused_for_a_session_that_runs_with_approvals() {
729 let directory = tempfile::tempdir().unwrap();
730 let (mut controller, session_id) = fixture(
731 directory.path(),
732 mj_core::state::TargetLocator::LocalBare {
733 worker_root: directory.path().join(SESSION_ID),
734 },
735 );
736 controller.config.profiles.insert(
737 "muse".into(),
738 HarnessProfile {
739 enabled: true,
740 kind: HarnessKind::Muse,
741 home: directory.path().join("muse"),
742 environment: BTreeMap::new().into(),
743 context_window_bytes: None,
744 subagents: Default::default(),
745 guardian_review_model: None,
746 },
747 );
748 let executor = RecordingExecutor::new();
749
750 let error = controller
753 .stage_reviewer_profile_controlled(&session_id, "muse", 0, &[], &executor)
754 .unwrap_err();
755
756 assert!(
757 format!("{error:#}").contains("cannot review this session"),
758 "{error:#}"
759 );
760 assert!(executor.commands.borrow().is_empty());
761 }
762
763 #[test]
764 fn a_muse_reviewer_is_staged_with_the_permission_profile_its_policy_enforces() {
765 let directory = tempfile::tempdir().unwrap();
766 let worker_root = directory.path().join(SESSION_ID);
767 let (mut controller, session_id) = fixture(
768 directory.path(),
769 mj_core::state::TargetLocator::LocalBare {
770 worker_root: worker_root.clone(),
771 },
772 );
773 let home = directory.path().join("muse");
776 std::fs::create_dir_all(&home).unwrap();
777 std::fs::write(
778 home.join("settings.json"),
779 br#"{"schema_version":1,"permissions":{"schema_version":1,"default_profile":":auto-review"}}"#,
780 )
781 .unwrap();
782 controller.config.profiles.insert(
783 "muse".into(),
784 HarnessProfile {
785 enabled: true,
786 kind: HarnessKind::Muse,
787 home,
788 environment: BTreeMap::new().into(),
789 context_window_bytes: None,
790 subagents: Default::default(),
791 guardian_review_model: None,
792 },
793 );
794 controller
797 .state
798 .sessions
799 .get_mut(&session_id)
800 .unwrap()
801 .last_profile = "muse".into();
802
803 controller
804 .stage_reviewer_profile_controlled(&session_id, "muse", 0, &[], &ProcessExecutor)
805 .unwrap();
806
807 let staged: serde_json::Value = serde_json::from_slice(
808 &std::fs::read(worker_root.join("reviewer/profile/settings.json")).unwrap(),
809 )
810 .unwrap();
811 assert_eq!(staged["permissions"]["default_profile"], ":unrestricted");
812 }
813
814 #[test]
815 fn an_unknown_profile_is_refused_before_anything_is_copied() {
816 let directory = tempfile::tempdir().unwrap();
817 let (controller, session_id) = fixture(
818 directory.path(),
819 mj_core::state::TargetLocator::LocalBare {
820 worker_root: directory.path().join(SESSION_ID),
821 },
822 );
823 let executor = RecordingExecutor::new();
824
825 let error = controller
826 .stage_reviewer_profile_controlled(&session_id, "missing", 0, &[], &executor)
827 .unwrap_err();
828
829 assert!(format!("{error:#}").contains("unknown profile"));
830 assert!(executor.commands.borrow().is_empty());
831 }
832
833 #[test]
834 fn a_new_generation_travels_to_the_worker_so_it_starts_a_fresh_reviewer() {
835 let directory = tempfile::tempdir().unwrap();
836 let (controller, session_id) = fixture(
837 directory.path(),
838 mj_core::state::TargetLocator::LocalBare {
839 worker_root: directory.path().join(SESSION_ID),
840 },
841 );
842 let executor = RecordingExecutor::new();
843
844 let first = controller
845 .stage_reviewer_profile_controlled(&session_id, "codex", 0, &[], &executor)
846 .unwrap();
847 let second = controller
848 .stage_reviewer_profile_controlled(&session_id, "codex", 1, &[], &executor)
849 .unwrap();
850
851 assert!(first.reusable_for(&first));
852 assert!(
853 !first.reusable_for(&second),
854 "a new generation must not reload the old conversation"
855 );
856 }
857}