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 guardian_review_model: None,
574 },
575 );
576 }
577 (
578 Controller {
579 config,
580 state: State {
581 sessions: [(session_id.into(), session)].into_iter().collect(),
582 ..State::default()
583 },
584 },
585 session_id.to_owned(),
586 )
587 }
588
589 #[test]
590 fn staging_copies_the_chosen_profile_into_the_worker_root() {
591 let directory = tempfile::tempdir().unwrap();
592 let worker_root = directory.path().join(SESSION_ID);
593 std::fs::create_dir_all(directory.path().join("claude")).unwrap();
594 std::fs::write(directory.path().join("claude/CLAUDE.md"), b"reviewer").unwrap();
596 let (controller, session_id) = fixture(
597 directory.path(),
598 mj_core::state::TargetLocator::LocalBare {
599 worker_root: worker_root.clone(),
600 },
601 );
602 let executor = RecordingExecutor::new();
603
604 let config = controller
605 .stage_reviewer_profile_controlled(&session_id, "claude", 0, &[], &executor)
606 .unwrap();
607
608 assert_eq!(config.profile_id, "claude");
609 assert_eq!(config.harness, HarnessKind::Claude);
610 assert_eq!(config.generation, 0);
611 assert_eq!(config.model, None);
612 assert_eq!(config.effort, None);
613 assert!(
615 !config
616 .environment
617 .contains_key(HarnessKind::Claude.home_env())
618 );
619 assert_eq!(
620 config.environment.get("EXTRA").map(String::as_str),
621 Some("1")
622 );
623
624 let home = format!("{}/reviewer/profile", worker_root.display());
625 let script = executor.script();
626 let cleared = script
627 .iter()
628 .position(|line| line.starts_with("rm ") && line.contains(&home))
629 .expect("the previous reviewer profile is cleared");
630 let copied = script
631 .iter()
632 .position(|line| line.starts_with("cp ") && line.ends_with(&home))
633 .expect("the staged profile is installed");
634 assert!(
635 cleared < copied,
636 "a stale profile must go before the new one lands: {script:?}"
637 );
638 assert!(
639 script
640 .iter()
641 .any(|line| line.contains("go-rwx") && line.contains(&home)),
642 "the reviewer profile must not be world readable: {script:?}"
643 );
644 }
645
646 #[test]
647 fn local_container_targets_stage_the_reviewer_through_their_engine() {
648 let directory = tempfile::tempdir().unwrap();
649 let container_id = crate::targets::resource_name(SESSION_ID).unwrap();
650 for (locator, engine) in [
651 (
652 mj_core::state::TargetLocator::LocalPodman {
653 borrowed_from: None,
654 container_id: container_id.clone(),
655 workspace_storage: Default::default(),
656 },
657 "podman",
658 ),
659 (
660 mj_core::state::TargetLocator::LocalDocker {
661 borrowed_from: None,
662 container_id: container_id.clone(),
663 },
664 "docker",
665 ),
666 ] {
667 let (controller, session_id) = fixture(directory.path(), locator);
668 let executor = RecordingExecutor::new();
669
670 controller
671 .stage_reviewer_profile_controlled(&session_id, "codex", 3, &[], &executor)
672 .unwrap();
673
674 let script = executor.script();
675 assert!(
676 script
677 .iter()
678 .all(|line| line.starts_with(&format!("{engine} "))),
679 "a container target is reached only through its engine: {script:?}"
680 );
681 let home = script
682 .iter()
683 .find_map(|line| {
684 line.split(' ')
685 .find(|word| word.contains("/reviewer/profile"))
686 })
687 .expect("the reviewer profile is placed")
688 .to_owned();
689 assert!(
690 home.contains(&format!("/{session_id}")),
691 "the reviewer lives under this session's worker root: {home}"
692 );
693 assert!(
695 !script.iter().any(|line| {
696 line.contains("run") || line.contains("git") || line.contains("create")
697 }),
698 "staging a reviewer provisions nothing: {script:?}"
699 );
700 }
701 }
702
703 #[test]
704 fn reviewer_staging_preserves_owned_approval_for_both_mcp_delivery_paths() {
705 let directory = tempfile::tempdir().unwrap();
706 let (controller, session_id) = fixture(
707 directory.path(),
708 mj_core::state::TargetLocator::LocalBare {
709 worker_root: directory.path().join(SESSION_ID),
710 },
711 );
712 let mut servers =
713 mj_review::bifrost::review_mcp_servers(&[directory.path().to_owned()], "review");
714 servers.push(review_dispatch_server("/worker"));
715 for profile in ["codex", "claude"] {
716 let executor = RecordingExecutor::new();
717 let config = controller
718 .stage_reviewer_profile_controlled(&session_id, profile, 1, &servers, &executor)
719 .unwrap();
720 assert_eq!(config.mcp_servers, servers);
721 assert!(!config.mcp_servers[0].is_review_dispatch(Path::new("/worker/hel")));
722 assert!(config.mcp_servers[1].is_review_dispatch(Path::new("/worker/hel")));
723 }
724 }
725
726 #[test]
727 fn an_unconstrained_reviewer_is_refused_for_a_session_that_runs_with_approvals() {
728 let directory = tempfile::tempdir().unwrap();
729 let (mut controller, session_id) = fixture(
730 directory.path(),
731 mj_core::state::TargetLocator::LocalBare {
732 worker_root: directory.path().join(SESSION_ID),
733 },
734 );
735 controller.config.profiles.insert(
736 "muse".into(),
737 HarnessProfile {
738 enabled: true,
739 kind: HarnessKind::Muse,
740 home: directory.path().join("muse"),
741 environment: BTreeMap::new().into(),
742 context_window_bytes: None,
743 guardian_review_model: None,
744 },
745 );
746 let executor = RecordingExecutor::new();
747
748 let error = controller
751 .stage_reviewer_profile_controlled(&session_id, "muse", 0, &[], &executor)
752 .unwrap_err();
753
754 assert!(
755 format!("{error:#}").contains("cannot review this session"),
756 "{error:#}"
757 );
758 assert!(executor.commands.borrow().is_empty());
759 }
760
761 #[test]
762 fn a_muse_reviewer_is_staged_with_the_permission_profile_its_policy_enforces() {
763 let directory = tempfile::tempdir().unwrap();
764 let worker_root = directory.path().join(SESSION_ID);
765 let (mut controller, session_id) = fixture(
766 directory.path(),
767 mj_core::state::TargetLocator::LocalBare {
768 worker_root: worker_root.clone(),
769 },
770 );
771 let home = directory.path().join("muse");
774 std::fs::create_dir_all(&home).unwrap();
775 std::fs::write(
776 home.join("settings.json"),
777 br#"{"schema_version":1,"permissions":{"schema_version":1,"default_profile":":auto-review"}}"#,
778 )
779 .unwrap();
780 controller.config.profiles.insert(
781 "muse".into(),
782 HarnessProfile {
783 enabled: true,
784 kind: HarnessKind::Muse,
785 home,
786 environment: BTreeMap::new().into(),
787 context_window_bytes: None,
788 guardian_review_model: None,
789 },
790 );
791 controller
794 .state
795 .sessions
796 .get_mut(&session_id)
797 .unwrap()
798 .last_profile = "muse".into();
799
800 controller
801 .stage_reviewer_profile_controlled(&session_id, "muse", 0, &[], &ProcessExecutor)
802 .unwrap();
803
804 let staged: serde_json::Value = serde_json::from_slice(
805 &std::fs::read(worker_root.join("reviewer/profile/settings.json")).unwrap(),
806 )
807 .unwrap();
808 assert_eq!(staged["permissions"]["default_profile"], ":unrestricted");
809 }
810
811 #[test]
812 fn an_unknown_profile_is_refused_before_anything_is_copied() {
813 let directory = tempfile::tempdir().unwrap();
814 let (controller, session_id) = fixture(
815 directory.path(),
816 mj_core::state::TargetLocator::LocalBare {
817 worker_root: directory.path().join(SESSION_ID),
818 },
819 );
820 let executor = RecordingExecutor::new();
821
822 let error = controller
823 .stage_reviewer_profile_controlled(&session_id, "missing", 0, &[], &executor)
824 .unwrap_err();
825
826 assert!(format!("{error:#}").contains("unknown profile"));
827 assert!(executor.commands.borrow().is_empty());
828 }
829
830 #[test]
831 fn a_new_generation_travels_to_the_worker_so_it_starts_a_fresh_reviewer() {
832 let directory = tempfile::tempdir().unwrap();
833 let (controller, session_id) = fixture(
834 directory.path(),
835 mj_core::state::TargetLocator::LocalBare {
836 worker_root: directory.path().join(SESSION_ID),
837 },
838 );
839 let executor = RecordingExecutor::new();
840
841 let first = controller
842 .stage_reviewer_profile_controlled(&session_id, "codex", 0, &[], &executor)
843 .unwrap();
844 let second = controller
845 .stage_reviewer_profile_controlled(&session_id, "codex", 1, &[], &executor)
846 .unwrap();
847
848 assert!(first.reusable_for(&first));
849 assert!(
850 !first.reusable_for(&second),
851 "a new generation must not reload the old conversation"
852 );
853 }
854}