1use std::sync::{Arc, Weak};
8
9use forge_agent::client::SpawnFailureKind;
10use forge_primitives::WorkerStatus;
11
12use crate::SessionSlot;
13use crate::mcp::peers::types::WrappedPrompt;
14use crate::protocol::{Command, SessionChoice, WorkerSpawnReply};
15use crate::workspace::{ResumeTarget, Workspace};
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum WorkerTargetStatus {
23 Delivered,
24}
25
26#[derive(Debug, PartialEq, Eq)]
30pub enum WorkerDeliverError {
31 UnknownLabel { project_key: String, label: String },
33 NotLive { project_key: String, label: String },
37 DispatchFailed { message: String },
43}
44
45#[derive(Debug, PartialEq, Eq)]
49pub enum WorkerLeadDeliverError {
50 UnknownCaller,
53 LeadCallerHasNoLead,
56 LeadGone,
60 DispatchFailed { message: String },
63}
64
65pub use forge_primitives::LEAD_LABEL;
70
71#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum WorkerSpawnError {
75 NotLeadCaller,
77 EmptyLabel,
79 ReservedLabel,
83 LabelWithPathSeparator,
87 EmptyCharter,
89 UnknownCallerProject,
93 DispatchFailed { message: String },
96 WorktreeCreationFailed { reason: String },
102 ResumeLookupFailed { label: String, message: String },
108}
109
110#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum WorkerUpdateError {
115 UnknownCallerProject,
117 NoSuchWorker { label: String, project_key: String },
120 StoreFailed { message: String },
122}
123
124#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum DespawnOutcome {
130 Despawned { worktree_cleanup_warning: Option<String>, branch_cleanup_warning: Option<String> },
137 Blocked { reason: String },
141}
142
143#[derive(Debug, PartialEq, Eq)]
148pub enum WorkerDespawnError {
149 NotLeadCaller,
151 EmptyLabel,
153 UnknownCallerProject,
155 UnknownLabel { label: String, project_key: String },
157 DispatchFailed { message: String },
162}
163
164pub fn classify_worker_spawn_failure(
206 message: &str,
207 is_git_repo_at_spawn: bool,
208 kind: SpawnFailureKind,
209) -> WorkerSpawnError {
210 if !matches!(kind, SpawnFailureKind::Unclassified) {
213 return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
214 }
215 if message.contains("worker limit reached") {
219 return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
220 }
221 if message.contains("is still cleaning up; retry once it finishes") {
226 return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
227 }
228 if message.contains("which forge.toml declares") {
232 return WorkerSpawnError::DispatchFailed { message: message.to_owned() };
233 }
234 let lower = message.to_lowercase();
235 let mentions_worktree = lower.contains("worktree");
236 let resembles_branch_resolve = lower.contains("failed to resolve base branch");
237 let resembles_already_exists = lower.contains("already used by worktree");
238 if (mentions_worktree || resembles_branch_resolve || resembles_already_exists)
239 && is_git_repo_at_spawn
240 {
241 return WorkerSpawnError::WorktreeCreationFailed { reason: message.to_owned() };
242 }
243 WorkerSpawnError::DispatchFailed { message: message.to_owned() }
244}
245
246#[derive(Debug, Clone)]
249pub struct CallerProject {
250 pub project_key: crate::ProjectKey,
251 pub is_lead: bool,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum WorkerCapSource {
258 ProjectMaxWorkers,
259 Default,
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct WorkerCapacity {
268 pub project: String,
269 pub cap: usize,
270 pub live: usize,
271 pub cap_source: WorkerCapSource,
272}
273
274#[async_trait::async_trait]
281pub trait WorkerFacade: Send + Sync {
282 fn caller_project(&self, caller: &SessionSlot) -> Option<CallerProject>;
285
286 async fn spawn_worker(
305 &self,
306 caller: &SessionSlot,
307 label: String,
308 charter: String,
309 kick: Option<String>,
310 resume_kick: Option<String>,
311 interactive: bool,
312 resume_session: bool,
313 mcp_families: Option<Vec<String>>,
314 cwd: Option<String>,
315 ) -> Result<WorkerSpawnReply, WorkerSpawnError>;
316
317 fn update_worker(
327 &self,
328 caller: &SessionSlot,
329 label: &str,
330 charter: Option<String>,
331 kick: Option<String>,
332 resume_kick: Option<String>,
333 mcp_families: Option<Vec<String>>,
334 ) -> Result<(), WorkerUpdateError>;
335
336 async fn despawn_worker(
341 &self,
342 caller: &SessionSlot,
343 label: &str,
344 force: bool,
345 ) -> Result<DespawnOutcome, WorkerDespawnError>;
346
347 fn list_workers(&self, caller: &SessionSlot) -> Vec<WorkerStatus>;
350
351 fn list_all_workers(&self) -> Vec<WorkerStatus>;
354
355 fn capacity(&self, caller: &SessionSlot) -> Option<WorkerCapacity>;
363
364 async fn deliver_worker_prompt(
370 &self,
371 caller: &SessionSlot,
372 target_label: &str,
373 wrapped: WrappedPrompt,
374 ) -> Result<WorkerTargetStatus, WorkerDeliverError>;
375
376 async fn deliver_worker_prompt_to_project(
383 &self,
384 caller: &SessionSlot,
385 org: &str,
386 project: &str,
387 target_label: &str,
388 wrapped: WrappedPrompt,
389 ) -> Result<WorkerTargetStatus, WorkerDeliverError>;
390
391 async fn deliver_prompt_to_lead(
398 &self,
399 caller: &SessionSlot,
400 wrapped: WrappedPrompt,
401 ) -> Result<WorkerTargetStatus, WorkerLeadDeliverError>;
402}
403
404fn refused_delivery(
410 workers: &[crate::mcp::workers::types::WorkerEntry],
411 project_key: &str,
412 label: &str,
413) -> Option<WorkerDeliverError> {
414 if crate::mcp::workers::types::live_worker_with_label(workers, label).is_some() {
415 return None;
416 }
417 if workers.iter().any(|w| w.label == label) {
418 return Some(WorkerDeliverError::NotLive {
419 project_key: project_key.to_owned(),
420 label: label.to_owned(),
421 });
422 }
423 Some(WorkerDeliverError::UnknownLabel {
424 project_key: project_key.to_owned(),
425 label: label.to_owned(),
426 })
427}
428
429async fn await_delivery(
434 rx: tokio::sync::oneshot::Receiver<Result<(), String>>,
435) -> Result<WorkerTargetStatus, WorkerDeliverError> {
436 match rx.await {
437 Ok(Ok(())) => Ok(WorkerTargetStatus::Delivered),
438 Ok(Err(message)) => Err(WorkerDeliverError::DispatchFailed { message }),
439 Err(_) => Err(WorkerDeliverError::DispatchFailed {
440 message: "the delivery handler dropped its reply".to_owned(),
441 }),
442 }
443}
444
445impl WorkerSpawnError {
446 pub(crate) fn message(&self) -> String {
450 match self {
451 Self::NotLeadCaller => {
452 "agents__spawn is lead-only; this session is a worker. Workers cannot \
453 spawn other workers in v1."
454 .to_owned()
455 }
456 Self::EmptyLabel => "label must be non-empty after trim".to_owned(),
457 Self::ReservedLabel => format!(
458 "label '{LEAD_LABEL}' is reserved - agents__send_message uses it as \
459 the addressing keyword for the caller's project's own agent. Pick a different label."
460 ),
461 Self::LabelWithPathSeparator => {
462 "label must not contain a slash ('/'): claude rewrites it in the worktree name, \
463 so forge's path and branch would point somewhere it never creates. Pick a label \
464 without one."
465 .to_owned()
466 }
467 Self::EmptyCharter => "charter must be non-empty after trim".to_owned(),
468 Self::UnknownCallerProject => {
469 "could not resolve caller to a known project (forge bug)".to_owned()
470 }
471 Self::DispatchFailed { message } => format!("worker spawn failed: {message}"),
472 Self::WorktreeCreationFailed { reason } => {
473 format!("worktree creation failed: {reason}")
474 }
475 Self::ResumeLookupFailed { label, message } => format!(
476 "could not look up a prior session for '{label}': {message}. Nothing was spawned, \
477 because a failed lookup is not the same answer as no prior session; retry, or \
478 spawn without resume_session to start fresh"
479 ),
480 }
481 }
482}
483
484pub(crate) fn validate_worker_label(label: &str) -> Result<(), WorkerSpawnError> {
489 if label.trim().is_empty() {
490 return Err(WorkerSpawnError::EmptyLabel);
491 }
492 if label.trim() == LEAD_LABEL {
493 return Err(WorkerSpawnError::ReservedLabel);
494 }
495 if label.contains('/') {
496 return Err(WorkerSpawnError::LabelWithPathSeparator);
497 }
498 Ok(())
499}
500
501pub(super) fn validate_worker_spawn(
505 is_lead: bool,
506 label: &str,
507 charter: &str,
508) -> Result<(), WorkerSpawnError> {
509 if !is_lead {
510 return Err(WorkerSpawnError::NotLeadCaller);
511 }
512 validate_worker_label(label)?;
513 if charter.trim().is_empty() {
514 return Err(WorkerSpawnError::EmptyCharter);
515 }
516 Ok(())
517}
518
519fn discard_refused_worktree(
527 ws: &Workspace,
528 project_key: &crate::target::ProjectKey,
529 repo: &std::path::Path,
530 label: &str,
531 ensured: Option<(std::path::PathBuf, forge_agent::env::worktree::WorktreeEnsure)>,
532) {
533 let Some((worktree, outcome)) = ensured else { return };
534 if let Some(holder) = ws.worktree_held_elsewhere(&worktree, project_key, label) {
535 tracing::debug!(
536 target: "forge_workspace::mcp::workers",
537 %label,
538 worktree = %worktree.display(),
539 %holder,
540 "a refused spawn's worktree is held by another seat; the rollback leaves it",
541 );
542 return;
543 }
544 if let Some(warning) =
545 forge_agent::env::worktree::discard_worker_worktree(repo, label, &worktree, outcome)
546 {
547 tracing::warn!(
548 target: "forge_workspace::mcp::workers",
549 %label,
550 worktree = %worktree.display(),
551 %warning,
552 "a refused spawn's fresh worktree stayed behind"
553 );
554 }
555}
556
557pub struct ProdWorkerFacade {
563 workspace: Weak<Workspace>,
564}
565
566impl ProdWorkerFacade {
567 pub fn from_arc(workspace: &Arc<Workspace>) -> Arc<dyn WorkerFacade> {
568 Arc::new(Self { workspace: Arc::downgrade(workspace) })
569 }
570}
571
572#[async_trait::async_trait]
573impl WorkerFacade for ProdWorkerFacade {
574 fn caller_project(&self, caller: &SessionSlot) -> Option<CallerProject> {
575 let ws = self.workspace.upgrade()?;
576 let cx = crate::mcp::caller_context::caller_context(&ws, caller)?;
577 Some(CallerProject { project_key: cx.project_key, is_lead: cx.is_lead })
578 }
579
580 async fn spawn_worker(
581 &self,
582 caller: &SessionSlot,
583 label: String,
584 charter: String,
585 kick: Option<String>,
586 resume_kick: Option<String>,
587 interactive: bool,
588 resume_session: bool,
589 mcp_families: Option<Vec<String>>,
590 cwd: Option<String>,
591 ) -> Result<WorkerSpawnReply, WorkerSpawnError> {
592 let cp = self.caller_project(caller).ok_or(WorkerSpawnError::UnknownCallerProject)?;
593 validate_worker_spawn(cp.is_lead, &label, &charter)?;
594 let ws = self.workspace.upgrade().ok_or_else(|| WorkerSpawnError::DispatchFailed {
595 message: "workspace dropped".into(),
596 })?;
597 if ws.despawn_cleanup_pending(&cp.project_key, &label) {
604 return Err(WorkerSpawnError::DispatchFailed {
605 message:
606 "this label's previous despawn is still cleaning up; retry once it finishes"
607 .to_owned(),
608 });
609 }
610 let Some(view) = ws.list_projects().into_iter().find(|v| v.key == cp.project_key) else {
614 return Err(WorkerSpawnError::UnknownCallerProject);
615 };
616 let row = if resume_session {
622 ws.stored_worker_row(&cp.project_key, &label).unwrap_or_else(|error| {
623 tracing::warn!(
624 target: "forge_workspace::mcp::workers",
625 event_name = "worker_row_cwd_unreadable",
626 project = %cp.project_key.as_str(),
627 label = %label,
628 %error,
629 "reading the worker's recorded tree failed; resuming in the project \
630 path, which may be a different directory than the row names",
631 );
632 None
633 })
634 } else {
635 None
636 };
637 let stated = cwd
638 .as_deref()
639 .map(str::to_owned)
640 .or_else(|| row.as_ref().and_then(|row| row.cwd.clone()));
641 let (root, stated_is_git) =
646 crate::mcp::workers::types::worker_root_and_git(&view.path, stated.as_deref())
647 .map_err(|message| WorkerSpawnError::DispatchFailed { message })?;
648 if stated.is_some()
653 && let Some(known) = ws.project_name_for_resolved_path(&root.to_string_lossy())
654 {
655 return Err(WorkerSpawnError::DispatchFailed {
656 message: crate::spawn::known_project_refusal(&known, &view.name),
657 });
658 }
659 if resume_session && let (Some(_), Some(row)) = (cwd.as_deref(), row.as_ref()) {
665 let history =
666 row.cwd.as_deref().map_or_else(|| view.path.clone(), std::path::PathBuf::from);
667 let history = std::fs::canonicalize(&history).unwrap_or(history);
668 if root != history {
669 return Err(WorkerSpawnError::DispatchFailed {
670 message: format!(
671 "this resume states a different tree ('{}') than the one this \
672 worker's history lives in ('{}'); a conversation is found by the \
673 directory it ran in, so a worker moves trees only by being \
674 despawned and spawned fresh",
675 root.display(),
676 history.display(),
677 ),
678 });
679 }
680 }
681 let is_git_repo_at_spawn = if let Some(stated_is_git) = stated_is_git {
690 stated_is_git
691 } else {
692 ws.recorded_worker_is_git_repo(&cp.project_key, &label)
693 .unwrap_or_else(|error| {
694 tracing::warn!(
695 target: "forge_workspace::mcp::workers",
696 event_name = "worker_row_gitness_unreadable",
697 project = %cp.project_key.as_str(),
698 label = %label,
699 %error,
700 "reading the worker's recorded gitness failed; probing the project path, \
701 which may ensure a different worktree than the row names",
702 );
703 None
704 })
705 .unwrap_or_else(|| forge_agent::env::worktree::is_git_repo(&view.path))
706 };
707 let mut ensured = None;
718 let base = forge_agent::env::worktree::worktree_base(&root);
724 let mut _worktree_claim: Option<crate::workspace::WorktreeClaimGuard> = None;
727 let (resume_existing, session_choice) = if resume_session {
728 let run_dir =
734 crate::mcp::workers::types::worker_tag_dir(&root, &label, is_git_repo_at_spawn);
735 if is_git_repo_at_spawn {
736 let claim = ws
742 .claim_worktree(
743 &run_dir,
744 &cp.project_key,
745 &label,
746 crate::workspace::WorktreeOp::Spawn,
747 )
748 .map_err(|reason| WorkerSpawnError::WorktreeCreationFailed {
749 reason: format!("this worker's worktree cannot be shared: {reason}"),
750 })?;
751 if let Some(collision) = ws.worktree_collision(
759 &run_dir,
760 &cp.project_key,
761 &label,
762 crate::workspace::WorktreeOp::Spawn,
763 ) {
764 return Err(WorkerSpawnError::WorktreeCreationFailed {
765 reason: format!("this worker's worktree cannot be shared: {collision}"),
766 });
767 }
768 _worktree_claim = Some(claim);
771 match forge_agent::env::worktree::ensure_worker_worktree(&base, &label, &run_dir) {
772 Ok(outcome) => ensured = Some((run_dir.clone(), outcome)),
773 Err(err) => {
774 return Err(WorkerSpawnError::WorktreeCreationFailed {
775 reason: err.to_string(),
776 });
777 }
778 }
779 }
780 match ws.resolve_worker_resume_session(&view.org, &view.name, &run_dir, &label) {
781 Ok(ResumeTarget::Found { session_id, restamp }) => {
782 if restamp {
783 if let Err(error) = ws.stamp_adopted_worker_transcript(
788 &cp.project_key,
789 &label,
790 &session_id,
791 &run_dir,
792 ) {
793 discard_refused_worktree(
796 &ws,
797 &cp.project_key,
798 &base,
799 &label,
800 ensured.take(),
801 );
802 return Err(WorkerSpawnError::DispatchFailed {
803 message: format!(
804 "stamping the adopted transcript's project failed: {error}"
805 ),
806 });
807 }
808 }
809 (Some(session_id), SessionChoice::Resumed)
810 }
811 Ok(ResumeTarget::None) => (None, SessionChoice::FreshWithoutPrior),
815 Err(error) => {
820 discard_refused_worktree(&ws, &cp.project_key, &base, &label, ensured.take());
821 return Err(WorkerSpawnError::ResumeLookupFailed {
822 label,
823 message: error.to_string(),
824 });
825 }
826 }
827 } else {
828 (None, SessionChoice::Fresh)
829 };
830
831 let spawn_label = label.clone();
835 let (tx, rx) = tokio::sync::oneshot::channel();
836 let cmd = Command::SpawnWorker {
837 project_key: cp.project_key.clone(),
838 label,
839 charter,
840 spawned_by: caller.clone(),
841 resume_existing,
844 kick,
845 resume_kick,
846 interactive,
847 mcp_families,
848 cwd: stated.as_ref().map(|_| root.to_string_lossy().into_owned()),
852 from_boot_respawn: false,
853 return_to: Some(tx),
854 };
855 if let Err(err) = ws.dispatch(cmd) {
856 discard_refused_worktree(&ws, &cp.project_key, &base, &spawn_label, ensured.take());
857 return Err(WorkerSpawnError::DispatchFailed {
858 message: format!("dispatch failed: {err:?}"),
859 });
860 }
861 match rx.await {
862 Ok(Ok(mut reply)) => {
863 reply.session_choice = session_choice;
864 if let Some(warning) = reply.durability_warning.take() {
865 tracing::error!(
866 target: "forge_workspace::mcp::workers",
867 project = %cp.project_key.as_str(),
868 label = %spawn_label,
869 warning = %warning,
870 "the worker's row could not be recorded; it will not survive a forge restart",
871 );
872 reply.durability_warning = Some(warning);
873 }
874 Ok(reply)
875 }
876 Ok(Err(message)) => {
877 discard_refused_worktree(&ws, &cp.project_key, &base, &spawn_label, ensured.take());
878 Err(classify_worker_spawn_failure(
882 &message,
883 is_git_repo_at_spawn,
884 SpawnFailureKind::Unclassified,
885 ))
886 }
887 Err(_) => Err(WorkerSpawnError::DispatchFailed {
890 message: "spawn handler dropped reply channel".into(),
891 }),
892 }
893 }
894
895 fn update_worker(
896 &self,
897 caller: &SessionSlot,
898 label: &str,
899 charter: Option<String>,
900 kick: Option<String>,
901 resume_kick: Option<String>,
902 mcp_families: Option<Vec<String>>,
903 ) -> Result<(), WorkerUpdateError> {
904 let cp = self.caller_project(caller).ok_or(WorkerUpdateError::UnknownCallerProject)?;
905 let ws = self.workspace.upgrade().ok_or_else(|| WorkerUpdateError::StoreFailed {
906 message: "workspace dropped".into(),
907 })?;
908 match ws.update_worker_row(&cp.project_key, label, charter, kick, resume_kick, mcp_families)
909 {
910 Ok(true) => Ok(()),
911 Ok(false) => Err(WorkerUpdateError::NoSuchWorker {
912 label: label.to_owned(),
913 project_key: cp.project_key.as_str().to_owned(),
914 }),
915 Err(err) => Err(WorkerUpdateError::StoreFailed { message: err.to_string() }),
916 }
917 }
918
919 async fn despawn_worker(
920 &self,
921 caller: &SessionSlot,
922 label: &str,
923 force: bool,
924 ) -> Result<DespawnOutcome, WorkerDespawnError> {
925 let cp = self.caller_project(caller).ok_or(WorkerDespawnError::UnknownCallerProject)?;
926 if !cp.is_lead {
927 return Err(WorkerDespawnError::NotLeadCaller);
928 }
929 if label.trim().is_empty() {
930 return Err(WorkerDespawnError::EmptyLabel);
931 }
932 let ws = self.workspace.upgrade().ok_or_else(|| WorkerDespawnError::DispatchFailed {
933 message: "workspace dropped".into(),
934 })?;
935 let (tx, rx) = tokio::sync::oneshot::channel();
936 let cmd = Command::DespawnWorker {
937 project_key: cp.project_key.clone(),
938 label: label.to_owned(),
939 force,
940 respond: Some(tx),
941 };
942 if let Err(err) = ws.dispatch(cmd) {
943 return Err(WorkerDespawnError::DispatchFailed {
944 message: format!("dispatch failed: {err:?}"),
945 });
946 }
947 match rx.await {
948 Ok(crate::protocol::DespawnResult::Despawned {
949 worktree_cleanup_warning,
950 branch_cleanup_warning,
951 }) => {
952 Ok(DespawnOutcome::Despawned { worktree_cleanup_warning, branch_cleanup_warning })
953 }
954 Ok(crate::protocol::DespawnResult::Blocked { reason }) => {
955 Ok(DespawnOutcome::Blocked { reason })
956 }
957 Ok(crate::protocol::DespawnResult::NotFound) => Err(WorkerDespawnError::UnknownLabel {
958 label: label.to_owned(),
959 project_key: cp.project_key.as_str().to_owned(),
960 }),
961 Ok(crate::protocol::DespawnResult::Failed { reason }) => {
962 Err(WorkerDespawnError::DispatchFailed { message: reason })
963 }
964 Err(_) => Err(WorkerDespawnError::DispatchFailed {
965 message: "despawn handler dropped reply channel".into(),
966 }),
967 }
968 }
969
970 fn list_workers(&self, caller: &SessionSlot) -> Vec<WorkerStatus> {
971 let Some(cp) = self.caller_project(caller) else {
972 return Vec::new();
973 };
974 let Some(ws) = self.workspace.upgrade() else {
975 return Vec::new();
976 };
977 ws.list_live_workers(&cp.project_key)
978 .iter()
979 .map(|entry| ws.worker_status_snapshot(entry))
980 .collect()
981 }
982
983 fn list_all_workers(&self) -> Vec<WorkerStatus> {
984 let Some(ws) = self.workspace.upgrade() else {
985 return Vec::new();
986 };
987 let mut all = Vec::new();
988 for project in ws.list_projects() {
989 for entry in ws.list_live_workers(&project.key) {
990 all.push(ws.worker_status_snapshot(&entry));
991 }
992 }
993 all
994 }
995
996 fn capacity(&self, caller: &SessionSlot) -> Option<WorkerCapacity> {
997 let cp = self.caller_project(caller)?;
998 let ws = self.workspace.upgrade()?;
999 let (cap, cap_source) = ws
1000 .project_for_key(&cp.project_key)
1001 .and_then(|project| {
1002 project.max_workers.map(|cap| (cap, WorkerCapSource::ProjectMaxWorkers))
1003 })
1004 .unwrap_or((crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT, WorkerCapSource::Default));
1005 Some(WorkerCapacity {
1006 project: cp.project_key.as_str().to_owned(),
1007 cap,
1008 live: ws.count_live_workers(&cp.project_key),
1009 cap_source,
1010 })
1011 }
1012
1013 async fn deliver_worker_prompt(
1014 &self,
1015 caller: &SessionSlot,
1016 target_label: &str,
1017 wrapped: WrappedPrompt,
1018 ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1019 let cp = self.caller_project(caller).ok_or_else(|| WorkerDeliverError::UnknownLabel {
1020 project_key: "<unknown>".into(),
1021 label: target_label.into(),
1022 })?;
1023 let Some(ws) = self.workspace.upgrade() else {
1024 return Err(WorkerDeliverError::UnknownLabel {
1025 project_key: cp.project_key.as_str().into(),
1026 label: target_label.into(),
1027 });
1028 };
1029 if let Some(refusal) = refused_delivery(
1033 &ws.list_live_workers(&cp.project_key),
1034 cp.project_key.as_str(),
1035 target_label,
1036 ) {
1037 return Err(refusal);
1038 }
1039 let (tx, rx) = tokio::sync::oneshot::channel();
1040 if let Err(err) = ws.dispatch(Command::DeliverWorkerPrompt {
1041 caller: caller.clone(),
1042 project_key: cp.project_key,
1043 target_label: target_label.into(),
1044 wrapped,
1045 respond: Some(tx),
1046 }) {
1047 return Err(WorkerDeliverError::DispatchFailed {
1048 message: format!("dispatch failed: {err:?}"),
1049 });
1050 }
1051 await_delivery(rx).await
1052 }
1053
1054 async fn deliver_worker_prompt_to_project(
1055 &self,
1056 caller: &SessionSlot,
1057 org: &str,
1058 project: &str,
1059 target_label: &str,
1060 wrapped: WrappedPrompt,
1061 ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1062 let unknown = || WorkerDeliverError::UnknownLabel {
1063 project_key: project.into(),
1064 label: target_label.into(),
1065 };
1066 let Some(ws) = self.workspace.upgrade() else {
1067 return Err(unknown());
1068 };
1069 let Some(view) = ws.list_projects().into_iter().find(|v| v.org == org && v.name == project)
1070 else {
1071 return Err(unknown());
1072 };
1073 if let Some(refusal) =
1074 refused_delivery(&ws.list_live_workers(&view.key), project, target_label)
1075 {
1076 return Err(refusal);
1077 }
1078 let (tx, rx) = tokio::sync::oneshot::channel();
1079 if let Err(err) = ws.dispatch(Command::DeliverWorkerPrompt {
1080 caller: caller.clone(),
1081 project_key: view.key,
1082 target_label: target_label.into(),
1083 wrapped,
1084 respond: Some(tx),
1085 }) {
1086 return Err(WorkerDeliverError::DispatchFailed {
1087 message: format!("dispatch failed: {err:?}"),
1088 });
1089 }
1090 await_delivery(rx).await
1091 }
1092
1093 async fn deliver_prompt_to_lead(
1094 &self,
1095 caller: &SessionSlot,
1096 wrapped: WrappedPrompt,
1097 ) -> Result<WorkerTargetStatus, WorkerLeadDeliverError> {
1098 let Some(ws) = self.workspace.upgrade() else {
1099 return Err(WorkerLeadDeliverError::UnknownCaller);
1100 };
1101 let Some(cx) = crate::mcp::caller_context::caller_context(&ws, caller) else {
1107 return Err(WorkerLeadDeliverError::UnknownCaller);
1108 };
1109 if cx.is_lead {
1110 return Err(WorkerLeadDeliverError::LeadCallerHasNoLead);
1111 }
1112 let target_lead_key = cx.lead.clone();
1113 if !cx.lead_running {
1117 return Err(WorkerLeadDeliverError::LeadGone);
1118 }
1119 let (tx, rx) = tokio::sync::oneshot::channel();
1120 if let Err(err) = ws.dispatch(Command::DeliverWorkerPromptToLead {
1121 caller: caller.clone(),
1122 target_lead_key,
1123 wrapped,
1124 respond: Some(tx),
1125 }) {
1126 return Err(WorkerLeadDeliverError::DispatchFailed {
1127 message: format!("dispatch failed: {err:?}"),
1128 });
1129 }
1130 match rx.await {
1131 Ok(Ok(())) => Ok(WorkerTargetStatus::Delivered),
1132 Ok(Err(message)) => Err(WorkerLeadDeliverError::DispatchFailed { message }),
1133 Err(_) => Err(WorkerLeadDeliverError::DispatchFailed {
1134 message: "the delivery handler dropped its reply".to_owned(),
1135 }),
1136 }
1137 }
1138}
1139
1140#[cfg(any(test, feature = "testing"))]
1144type RecordedSpawnCall = (
1145 SessionSlot,
1146 String,
1147 String,
1148 Option<String>,
1149 Option<String>,
1150 bool,
1151 bool,
1152 Option<Vec<String>>,
1153 Option<String>,
1154);
1155
1156#[cfg(any(test, feature = "testing"))]
1159type RecordedUpdateCall =
1160 (SessionSlot, String, Option<String>, Option<String>, Option<String>, Option<Vec<String>>);
1161
1162#[cfg(any(test, feature = "testing"))]
1165#[derive(Debug)]
1166pub struct RecordedProjectDelivery {
1167 pub caller: SessionSlot,
1168 pub org: String,
1169 pub project: String,
1170 pub label: String,
1171 pub wrapped: WrappedPrompt,
1172}
1173
1174#[cfg(any(test, feature = "testing"))]
1179#[derive(Default)]
1180pub struct MockWorkerFacade {
1181 pub callers: parking_lot::Mutex<std::collections::HashMap<SessionSlot, CallerProject>>,
1184 pub workers: parking_lot::Mutex<std::collections::HashMap<String, Vec<WorkerStatus>>>,
1188 pub spawn_calls: parking_lot::Mutex<Vec<RecordedSpawnCall>>,
1190 pub spawn_reply: parking_lot::Mutex<Option<Result<WorkerSpawnReply, WorkerSpawnError>>>,
1193 pub update_calls: parking_lot::Mutex<Vec<RecordedUpdateCall>>,
1195 pub update_result: parking_lot::Mutex<Option<Result<(), WorkerUpdateError>>>,
1199 pub deliver_calls: parking_lot::Mutex<Vec<(SessionSlot, String, WrappedPrompt)>>,
1201 pub deliver_to_project_calls: parking_lot::Mutex<Vec<RecordedProjectDelivery>>,
1203 pub despawn_calls: parking_lot::Mutex<Vec<(SessionSlot, String, bool)>>,
1205 pub despawn_outcome: parking_lot::Mutex<Option<DespawnOutcome>>,
1208 pub capacity_reply: parking_lot::Mutex<Option<WorkerCapacity>>,
1212}
1213
1214#[cfg(any(test, feature = "testing"))]
1215impl MockWorkerFacade {
1216 pub fn new() -> Self {
1217 Self::default()
1218 }
1219
1220 pub fn into_arc(self) -> std::sync::Arc<dyn WorkerFacade> {
1221 std::sync::Arc::new(self)
1222 }
1223}
1224
1225#[cfg(any(test, feature = "testing"))]
1226#[async_trait::async_trait]
1227impl WorkerFacade for MockWorkerFacade {
1228 fn caller_project(&self, caller: &SessionSlot) -> Option<CallerProject> {
1229 self.callers.lock().get(caller).cloned()
1230 }
1231
1232 async fn spawn_worker(
1233 &self,
1234 caller: &SessionSlot,
1235 label: String,
1236 charter: String,
1237 kick: Option<String>,
1238 resume_kick: Option<String>,
1239 interactive: bool,
1240 resume_session: bool,
1241 mcp_families: Option<Vec<String>>,
1242 cwd: Option<String>,
1243 ) -> Result<WorkerSpawnReply, WorkerSpawnError> {
1244 let cp = self.caller_project(caller).ok_or(WorkerSpawnError::UnknownCallerProject)?;
1245 validate_worker_spawn(cp.is_lead, &label, &charter)?;
1246 self.spawn_calls.lock().push((
1247 caller.clone(),
1248 label,
1249 charter,
1250 kick,
1251 resume_kick,
1252 interactive,
1253 resume_session,
1254 mcp_families,
1255 cwd,
1256 ));
1257 self.spawn_reply.lock().clone().unwrap_or(Err(WorkerSpawnError::DispatchFailed {
1258 message: "no preloaded reply".into(),
1259 }))
1260 }
1261
1262 fn update_worker(
1263 &self,
1264 caller: &SessionSlot,
1265 label: &str,
1266 charter: Option<String>,
1267 kick: Option<String>,
1268 resume_kick: Option<String>,
1269 mcp_families: Option<Vec<String>>,
1270 ) -> Result<(), WorkerUpdateError> {
1271 self.caller_project(caller).ok_or(WorkerUpdateError::UnknownCallerProject)?;
1272 self.update_calls.lock().push((
1273 caller.clone(),
1274 label.to_owned(),
1275 charter,
1276 kick,
1277 resume_kick,
1278 mcp_families,
1279 ));
1280 self.update_result.lock().clone().unwrap_or(Ok(()))
1281 }
1282
1283 async fn despawn_worker(
1284 &self,
1285 caller: &SessionSlot,
1286 label: &str,
1287 force: bool,
1288 ) -> Result<DespawnOutcome, WorkerDespawnError> {
1289 let cp = self.caller_project(caller).ok_or(WorkerDespawnError::UnknownCallerProject)?;
1290 if !cp.is_lead {
1291 return Err(WorkerDespawnError::NotLeadCaller);
1292 }
1293 if label.trim().is_empty() {
1294 return Err(WorkerDespawnError::EmptyLabel);
1295 }
1296 let known = self
1297 .workers
1298 .lock()
1299 .get(cp.project_key.as_str())
1300 .is_some_and(|ws| ws.iter().any(|w| w.label == label));
1301 if !known {
1302 return Err(WorkerDespawnError::UnknownLabel {
1303 label: label.to_owned(),
1304 project_key: cp.project_key.as_str().to_owned(),
1305 });
1306 }
1307 self.despawn_calls.lock().push((caller.clone(), label.to_owned(), force));
1308 Ok(self.despawn_outcome.lock().clone().unwrap_or(DespawnOutcome::Despawned {
1309 worktree_cleanup_warning: None,
1310 branch_cleanup_warning: None,
1311 }))
1312 }
1313
1314 fn list_workers(&self, caller: &SessionSlot) -> Vec<WorkerStatus> {
1315 let Some(cp) = self.caller_project(caller) else {
1316 return Vec::new();
1317 };
1318 self.workers.lock().get(cp.project_key.as_str()).cloned().unwrap_or_default()
1319 }
1320
1321 fn list_all_workers(&self) -> Vec<WorkerStatus> {
1322 let workers = self.workers.lock();
1323 let mut projects: Vec<(&String, &Vec<WorkerStatus>)> = workers.iter().collect();
1324 projects.sort_by(|left, right| left.0.cmp(right.0));
1325 projects.into_iter().flat_map(|(_, list)| list).cloned().collect()
1326 }
1327
1328 fn capacity(&self, caller: &SessionSlot) -> Option<WorkerCapacity> {
1329 let cp = self.caller_project(caller)?;
1330 Some(self.capacity_reply.lock().clone().unwrap_or(WorkerCapacity {
1331 project: cp.project_key.as_str().to_owned(),
1332 cap: crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT,
1333 live: self.workers.lock().get(cp.project_key.as_str()).map_or(0, Vec::len),
1334 cap_source: WorkerCapSource::Default,
1335 }))
1336 }
1337
1338 async fn deliver_worker_prompt(
1339 &self,
1340 caller: &SessionSlot,
1341 target_label: &str,
1342 wrapped: WrappedPrompt,
1343 ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1344 let cp = self.caller_project(caller).ok_or_else(|| WorkerDeliverError::UnknownLabel {
1345 project_key: "<unknown>".into(),
1346 label: target_label.into(),
1347 })?;
1348 let known = self
1349 .workers
1350 .lock()
1351 .get(cp.project_key.as_str())
1352 .is_some_and(|ws| ws.iter().any(|w| w.label == target_label));
1353 if !known {
1354 return Err(WorkerDeliverError::UnknownLabel {
1355 project_key: cp.project_key.as_str().into(),
1356 label: target_label.into(),
1357 });
1358 }
1359 self.deliver_calls.lock().push((caller.clone(), target_label.into(), wrapped));
1360 Ok(WorkerTargetStatus::Delivered)
1361 }
1362
1363 async fn deliver_worker_prompt_to_project(
1364 &self,
1365 caller: &SessionSlot,
1366 org: &str,
1367 project: &str,
1368 target_label: &str,
1369 wrapped: WrappedPrompt,
1370 ) -> Result<WorkerTargetStatus, WorkerDeliverError> {
1371 let unknown = || WorkerDeliverError::UnknownLabel {
1372 project_key: project.into(),
1373 label: target_label.into(),
1374 };
1375 let known = self
1376 .workers
1377 .lock()
1378 .get(project)
1379 .is_some_and(|ws| ws.iter().any(|w| w.label == target_label));
1380 if !known {
1381 return Err(unknown());
1382 }
1383 self.deliver_to_project_calls.lock().push(RecordedProjectDelivery {
1384 caller: caller.clone(),
1385 org: org.into(),
1386 project: project.into(),
1387 label: target_label.into(),
1388 wrapped,
1389 });
1390 Ok(WorkerTargetStatus::Delivered)
1391 }
1392
1393 async fn deliver_prompt_to_lead(
1394 &self,
1395 caller: &SessionSlot,
1396 wrapped: WrappedPrompt,
1397 ) -> Result<WorkerTargetStatus, WorkerLeadDeliverError> {
1398 let cp = self.caller_project(caller).ok_or(WorkerLeadDeliverError::UnknownCaller)?;
1399 if cp.is_lead {
1400 return Err(WorkerLeadDeliverError::LeadCallerHasNoLead);
1401 }
1402 let lead_slot = self
1408 .workers
1409 .lock()
1410 .get(cp.project_key.as_str())
1411 .and_then(|ws| ws.iter().find(|w| w.slot == *caller))
1412 .map(|w| w.spawned_by.clone());
1413 let Some(lead_slot) = lead_slot else {
1414 return Err(WorkerLeadDeliverError::UnknownCaller);
1415 };
1416 if lead_slot.label().is_empty() {
1419 return Err(WorkerLeadDeliverError::LeadGone);
1420 }
1421 self.deliver_calls.lock().push((caller.clone(), "<lead>".to_owned(), wrapped));
1425 Ok(WorkerTargetStatus::Delivered)
1426 }
1427}
1428
1429#[cfg(test)]
1430mod mock_tests {
1431 use super::*;
1432 use crate::mcp::peers::types::WrappedKind;
1433
1434 #[test]
1435 fn mock_caller_project_returns_preloaded() {
1436 let mock = MockWorkerFacade::new();
1437 mock.callers.lock().insert(
1438 SessionSlot::from_str_for_test("k1"),
1439 CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1440 );
1441 let cp = mock.caller_project(&SessionSlot::from_str_for_test("k1")).unwrap();
1442 assert!(cp.is_lead);
1443 assert_eq!(cp.project_key.as_str(), "forge");
1444 }
1445
1446 #[tokio::test]
1447 async fn mock_spawn_rejects_non_lead() {
1448 let mock = MockWorkerFacade::new();
1449 mock.callers.lock().insert(
1450 SessionSlot::from_str_for_test("k1"),
1451 CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: false },
1452 );
1453 let res = mock
1454 .spawn_worker(
1455 &SessionSlot::from_str_for_test("k1"),
1456 "reviewer".into(),
1457 "charter".into(),
1458 None,
1459 None,
1460 false,
1461 false,
1462 None,
1463 None,
1464 )
1465 .await;
1466 assert!(matches!(res, Err(WorkerSpawnError::NotLeadCaller)));
1467 }
1468
1469 #[tokio::test]
1470 async fn mock_spawn_records_call_and_returns_preloaded_reply() {
1471 let mock = MockWorkerFacade::new();
1472 mock.callers.lock().insert(
1473 SessionSlot::from_str_for_test("lead-key"),
1474 CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1475 );
1476 *mock.spawn_reply.lock() = Some(Ok(WorkerSpawnReply {
1477 session_id: "new-uuid".into(),
1478 tag: "forge:worker:reviewer".into(),
1479 mcp_families: None,
1480 rate_limited_account: None,
1481 durability_warning: None,
1482 worktree: None,
1483 session_choice: SessionChoice::Fresh,
1484 }));
1485 let res = mock
1486 .spawn_worker(
1487 &SessionSlot::from_str_for_test("lead-key"),
1488 "reviewer".into(),
1489 "charter".into(),
1490 None,
1491 None,
1492 false,
1493 false,
1494 None,
1495 None,
1496 )
1497 .await
1498 .unwrap();
1499 assert_eq!(res.session_id, "new-uuid");
1500 assert_eq!(mock.spawn_calls.lock().len(), 1);
1501 }
1502
1503 #[test]
1504 fn validate_worker_spawn_enforces_lead_and_nonempty_fields() {
1505 assert!(matches!(
1508 validate_worker_spawn(false, "reviewer", "c"),
1509 Err(WorkerSpawnError::NotLeadCaller)
1510 ));
1511 assert!(matches!(
1512 validate_worker_spawn(true, " ", "c"),
1513 Err(WorkerSpawnError::EmptyLabel)
1514 ));
1515 assert!(matches!(
1516 validate_worker_spawn(true, LEAD_LABEL, "c"),
1517 Err(WorkerSpawnError::ReservedLabel)
1518 ));
1519 assert!(matches!(
1523 validate_worker_spawn(true, "a/b", "c"),
1524 Err(WorkerSpawnError::LabelWithPathSeparator)
1525 ));
1526 assert!(matches!(
1527 validate_worker_spawn(true, "reviewer", " "),
1528 Err(WorkerSpawnError::EmptyCharter)
1529 ));
1530 assert!(validate_worker_spawn(true, "reviewer", "c").is_ok());
1531 }
1532
1533 #[tokio::test]
1534 async fn mock_spawn_empty_inline_charter_errors_empty_charter() {
1535 let mock = MockWorkerFacade::new();
1536 let lead = SessionSlot::from_str_for_test("lead-key");
1537 mock.callers.lock().insert(
1538 lead.clone(),
1539 CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1540 );
1541 let res = mock
1542 .spawn_worker(
1543 &lead,
1544 "reviewer".into(),
1545 " ".into(),
1546 None,
1547 None,
1548 false,
1549 false,
1550 None,
1551 None,
1552 )
1553 .await;
1554 assert!(matches!(res, Err(WorkerSpawnError::EmptyCharter)));
1555 assert_eq!(
1556 mock.spawn_calls.lock().len(),
1557 0,
1558 "empty inline charter must not record a spawn"
1559 );
1560 }
1561
1562 #[test]
1563 fn dedup_finds_non_failed_label_ignores_failed() {
1564 use crate::mcp::workers::types::WorkerEntry;
1565 use forge_primitives::WorkerLiveness;
1566 use std::time::SystemTime;
1567 let entry = |label: &str, status: WorkerLiveness| WorkerEntry {
1568 label: label.to_owned(),
1569 charter: "c".into(),
1570 slot: SessionSlot::from_str_for_test("w-uuid"),
1571 session_id: None,
1572 status,
1573 spawned_at: SystemTime::UNIX_EPOCH,
1574 spawned_by: SessionSlot::from_str_for_test("lead"),
1575 needs_tag: false,
1576 is_git_repo_at_spawn: false,
1577 cwd: None,
1578 diagnostic: None,
1579 kick: None,
1580 };
1581 let running = vec![entry("implementer", WorkerLiveness::Running)];
1583 assert!(
1584 crate::mcp::workers::types::live_worker_with_label(&running, "implementer").is_some()
1585 );
1586 let spawning = vec![entry("implementer", WorkerLiveness::Spawning)];
1587 assert!(
1588 crate::mcp::workers::types::live_worker_with_label(&spawning, "implementer").is_some()
1589 );
1590 let failed = vec![entry("implementer", WorkerLiveness::Failed)];
1592 assert!(
1593 crate::mcp::workers::types::live_worker_with_label(&failed, "implementer").is_none()
1594 );
1595 assert!(crate::mcp::workers::types::live_worker_with_label(&running, "tester").is_none());
1597 }
1598
1599 #[tokio::test]
1600 async fn mock_deliver_unknown_label_errors() {
1601 let mock = MockWorkerFacade::new();
1602 let caller = SessionSlot::from_str_for_test("k1");
1603 mock.callers.lock().insert(
1604 caller.clone(),
1605 CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1606 );
1607 let wrapped = WrappedPrompt {
1608 id: crate::mcp::peers::types::MessageId::mint(),
1609 kind: WrappedKind::Message,
1610 sender_name: "forge".into(),
1611 sender_org: "Personal".into(),
1612 body: "hi".into(),
1613 };
1614 let res = mock.deliver_worker_prompt(&caller, "missing", wrapped).await;
1615 assert!(matches!(res, Err(WorkerDeliverError::UnknownLabel { .. })));
1616 }
1617
1618 fn status(label: &str) -> WorkerStatus {
1619 WorkerStatus {
1620 label: label.into(),
1621 charter: format!("{label}'s charter"),
1622 status: forge_primitives::WorkerLiveness::Running,
1623 session_id: format!("{label}-uuid"),
1624 slot: SessionSlot::from_str_for_test(label),
1625 spawned_at: std::time::SystemTime::UNIX_EPOCH,
1626 spawned_by: SessionSlot::from_str_for_test("lead"),
1627 diagnostic: None,
1628 heal_attempt: None,
1629 heal_next_at: None,
1630 activity: None,
1631 }
1632 }
1633
1634 #[test]
1635 fn mock_list_all_workers_flattens_every_project() {
1636 let mock = MockWorkerFacade::new();
1637 mock.workers.lock().insert("zeta".into(), vec![status("z1")]);
1638 mock.workers.lock().insert("alpha".into(), vec![status("a1"), status("a2")]);
1639 let mut labels: Vec<String> =
1640 mock.list_all_workers().into_iter().map(|worker| worker.label).collect();
1641 labels.sort();
1642 assert_eq!(labels, ["a1", "a2", "z1"], "every project's pool is flattened");
1643 }
1644
1645 #[test]
1646 fn mock_capacity_fallback_derives_live_from_workers_map() {
1647 let mock = MockWorkerFacade::new();
1648 mock.callers.lock().insert(
1649 SessionSlot::from_str_for_test("k1"),
1650 CallerProject { project_key: crate::ProjectKey::new("forge"), is_lead: true },
1651 );
1652 mock.workers.lock().insert(
1653 "forge".into(),
1654 vec![
1655 WorkerStatus {
1656 label: "a".into(),
1657 charter: "c".into(),
1658 status: forge_primitives::WorkerLiveness::Running,
1659 session_id: "a-uuid".into(),
1660 slot: SessionSlot::from_str_for_test("a-uuid"),
1661 spawned_at: std::time::SystemTime::UNIX_EPOCH,
1662 spawned_by: SessionSlot::from_str_for_test("lead"),
1663 diagnostic: None,
1664 heal_attempt: None,
1665 heal_next_at: None,
1666 activity: None,
1667 },
1668 WorkerStatus {
1669 label: "b".into(),
1670 charter: "c".into(),
1671 status: forge_primitives::WorkerLiveness::Running,
1672 session_id: "b-uuid".into(),
1673 slot: SessionSlot::from_str_for_test("b-uuid"),
1674 spawned_at: std::time::SystemTime::UNIX_EPOCH,
1675 spawned_by: SessionSlot::from_str_for_test("lead"),
1676 diagnostic: None,
1677 heal_attempt: None,
1678 heal_next_at: None,
1679 activity: None,
1680 },
1681 ],
1682 );
1683 let capacity =
1684 mock.capacity(&SessionSlot::from_str_for_test("k1")).expect("caller resolves");
1685 assert_eq!(capacity.project, "forge");
1686 assert_eq!(capacity.cap, crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT);
1687 assert_eq!(capacity.cap_source, WorkerCapSource::Default);
1688 assert_eq!(capacity.live, 2);
1689 }
1690}
1691
1692#[cfg(test)]
1697mod prod_list_workers_tests {
1698 use super::*;
1699 use forge_primitives::{SessionLifecycleState, WorkerLiveness};
1700
1701 #[test]
1702 fn list_workers_populates_activity() {
1703 let (ws, _rx) = Workspace::testing_stub();
1704 ws.seed_test_project("forge", "/tmp/forge");
1705 let project = ws
1706 .list_projects()
1707 .into_iter()
1708 .find(|v| v.name == "forge")
1709 .expect("seeded project present")
1710 .key;
1711
1712 let caller = SessionSlot::worker("TestOrg", "forge", "implementer");
1715 ws.insert_live_worker(
1716 &project,
1717 crate::mcp::workers::types::WorkerEntry {
1718 label: "implementer".into(),
1719 charter: "test charter".into(),
1720 slot: caller.clone(),
1721 session_id: None,
1722 status: WorkerLiveness::Running,
1723 spawned_at: std::time::SystemTime::UNIX_EPOCH,
1724 spawned_by: SessionSlot::lead("TestOrg", "forge"),
1725 needs_tag: false,
1726 is_git_repo_at_spawn: false,
1727 cwd: None,
1728 diagnostic: None,
1729 kick: None,
1730 },
1731 );
1732 let domain = ws.register_domain_session(caller.clone(), None);
1733
1734 let facade = ProdWorkerFacade::from_arc(&ws);
1735 let listed = facade.list_workers(&caller);
1736 assert_eq!(listed.len(), 1);
1737 assert_eq!(
1738 listed[0].activity,
1739 Some(SessionLifecycleState::Idle),
1740 "agents__list must report the derived activity, not leave it unset",
1741 );
1742
1743 domain.lock().turn_pending = true;
1744 assert_eq!(
1745 facade.list_workers(&caller)[0].activity,
1746 Some(SessionLifecycleState::Running),
1747 "and it must be re-derived per call, not cached at spawn",
1748 );
1749 }
1750
1751 #[test]
1756 fn list_all_workers_walks_every_project() {
1757 let (ws, _rx) = Workspace::testing_stub();
1758 ws.seed_test_project("forge", "/tmp/forge");
1759 ws.seed_test_project("other", "/tmp/other");
1760 let projects = ws.list_projects();
1761 let key_for = |name: &str| {
1762 projects
1763 .iter()
1764 .find(|view| view.name == name)
1765 .expect("seeded project present")
1766 .key
1767 .clone()
1768 };
1769 let entry = |project_name: &str, label: &str, status: WorkerLiveness| {
1770 crate::mcp::workers::types::WorkerEntry {
1771 label: label.into(),
1772 charter: "test charter".into(),
1773 slot: SessionSlot::worker("TestOrg", project_name, label),
1774 session_id: None,
1775 status,
1776 spawned_at: std::time::SystemTime::UNIX_EPOCH,
1777 spawned_by: SessionSlot::lead("TestOrg", project_name),
1778 needs_tag: false,
1779 is_git_repo_at_spawn: false,
1780 cwd: None,
1781 diagnostic: None,
1782 kick: None,
1783 }
1784 };
1785 ws.insert_live_worker(
1786 &key_for("forge"),
1787 entry("forge", "implementer", WorkerLiveness::Running),
1788 );
1789 ws.insert_live_worker(
1790 &key_for("other"),
1791 entry("other", "reviewer", WorkerLiveness::Running),
1792 );
1793 ws.insert_live_worker(&key_for("forge"), entry("forge", "crashed", WorkerLiveness::Failed));
1795
1796 let implementer = SessionSlot::worker("TestOrg", "forge", "implementer");
1797 let domain = ws.register_domain_session(implementer, None);
1798
1799 let facade = ProdWorkerFacade::from_arc(&ws);
1800 let listed = facade.list_all_workers();
1801 let pairs: Vec<(String, String)> = listed
1802 .iter()
1803 .map(|worker| (worker.slot.project().to_owned(), worker.label.clone()))
1804 .collect();
1805 assert_eq!(
1806 pairs,
1807 vec![
1808 ("forge".to_owned(), "implementer".to_owned()),
1809 ("forge".to_owned(), "crashed".to_owned()),
1810 ("other".to_owned(), "reviewer".to_owned()),
1811 ],
1812 "every project's workers, in config order",
1813 );
1814 assert_eq!(
1815 listed[0].activity,
1816 Some(SessionLifecycleState::Idle),
1817 "the listing carries the derived activity, not the bare status",
1818 );
1819 domain.lock().turn_pending = true;
1820 assert_eq!(
1821 facade.list_all_workers()[0].activity,
1822 Some(SessionLifecycleState::Running),
1823 "and it is re-derived per call, not cached at spawn",
1824 );
1825 }
1826}
1827
1828#[cfg(test)]
1829mod deliver_tests {
1830 use super::*;
1831 use crate::mcp::peers::types::{MessageId, WrappedKind};
1832 use crate::mcp::workers::types::WorkerEntry;
1833 use forge_primitives::WorkerLiveness;
1834 use std::time::SystemTime;
1835
1836 fn entry(label: &str, status: WorkerLiveness, session_id: Option<&str>) -> WorkerEntry {
1837 WorkerEntry {
1838 label: label.to_owned(),
1839 charter: "test charter".into(),
1840 slot: SessionSlot::worker("TestOrg", "forge", label),
1841 session_id: session_id.map(forge_primitives::SessionId::new),
1842 status,
1843 spawned_at: SystemTime::UNIX_EPOCH,
1844 spawned_by: SessionSlot::lead("TestOrg", "forge"),
1845 needs_tag: false,
1846 is_git_repo_at_spawn: false,
1847 cwd: None,
1848 diagnostic: None,
1849 kick: None,
1850 }
1851 }
1852
1853 fn message() -> WrappedPrompt {
1854 WrappedPrompt {
1855 id: MessageId::mint(),
1856 kind: WrappedKind::Message,
1857 sender_name: "forge".to_owned(),
1858 sender_org: "Busytools".to_owned(),
1859 body: "status?".to_owned(),
1860 }
1861 }
1862
1863 #[tokio::test]
1869 async fn a_prompt_the_session_cannot_take_is_not_acked_as_delivered() {
1870 let (ws, _rx) = Workspace::testing_stub();
1871 ws.seed_test_project("forge", "/tmp/forge");
1872 let project = ws
1873 .list_projects()
1874 .into_iter()
1875 .find(|v| v.name == "forge")
1876 .expect("seeded project present")
1877 .key;
1878 let slot = SessionSlot::worker("TestOrg", "forge", "implementer");
1879 ws.insert_live_worker(
1880 &project,
1881 entry("implementer", WorkerLiveness::Running, Some("live")),
1882 );
1883 ws.mark_session_connected_for_test(&slot, "live");
1886
1887 let facade = ProdWorkerFacade::from_arc(&ws);
1888 let caller = SessionSlot::lead("TestOrg", "forge");
1889 let res = facade.deliver_worker_prompt(&caller, "implementer", message()).await;
1890 assert!(
1891 matches!(res, Err(WorkerDeliverError::DispatchFailed { .. })),
1892 "a prompt no session took must not be acked as delivered; got {res:?}",
1893 );
1894 }
1895
1896 #[tokio::test]
1902 async fn a_failed_workers_label_is_refused_as_not_live() {
1903 let (ws, _rx) = Workspace::testing_stub();
1904 ws.seed_test_project("forge", "/tmp/forge");
1905 let project = ws
1906 .list_projects()
1907 .into_iter()
1908 .find(|v| v.name == "forge")
1909 .expect("seeded project present")
1910 .key;
1911 ws.insert_live_worker(&project, entry("implementer", WorkerLiveness::Failed, Some("dead")));
1912
1913 let facade = ProdWorkerFacade::from_arc(&ws);
1914 let caller = SessionSlot::lead("TestOrg", "forge");
1915 let res = facade.deliver_worker_prompt(&caller, "implementer", message()).await;
1916 assert!(
1917 matches!(res, Err(WorkerDeliverError::NotLive { .. })),
1918 "a Failed worker is not a live worker; the delivery must refuse it rather \
1919 than ack it as sent, and say the row is dead; got {res:?}",
1920 );
1921
1922 let ghost = facade.deliver_worker_prompt(&caller, "ghost", message()).await;
1925 assert!(
1926 matches!(ghost, Err(WorkerDeliverError::UnknownLabel { .. })),
1927 "a label with no row at all is UnknownLabel, not NotLive; got {ghost:?}",
1928 );
1929 }
1930}
1931
1932#[cfg(test)]
1933mod capacity_tests {
1934 use super::*;
1935 use forge_primitives::WorkerLiveness;
1936
1937 fn worker_slot(label: &str) -> SessionSlot {
1939 SessionSlot::worker("TestOrg", "forge", label)
1940 }
1941
1942 fn entry(label: &str) -> crate::mcp::workers::types::WorkerEntry {
1943 crate::mcp::workers::types::WorkerEntry {
1944 label: label.to_owned(),
1945 charter: "test charter".into(),
1946 slot: worker_slot(label),
1947 session_id: None,
1948 status: WorkerLiveness::Running,
1949 spawned_at: std::time::SystemTime::UNIX_EPOCH,
1950 spawned_by: SessionSlot::lead("TestOrg", "forge"),
1951 needs_tag: false,
1952 is_git_repo_at_spawn: false,
1953 cwd: None,
1954 diagnostic: None,
1955 kick: None,
1956 }
1957 }
1958
1959 fn project_key_of(ws: &Workspace, name: &str) -> crate::ProjectKey {
1960 ws.list_projects().into_iter().find(|v| v.name == name).expect("seeded project present").key
1961 }
1962
1963 #[test]
1964 fn capacity_reports_project_override_when_set() {
1965 let (ws, _rx) = Workspace::testing_stub();
1966 ws.seed_test_project_with_max_workers("forge", "/tmp/forge", 1);
1967 let project = project_key_of(&ws, "forge");
1968 let caller = worker_slot("implementer");
1969 ws.insert_live_worker(&project, entry("implementer"));
1970
1971 let facade = ProdWorkerFacade::from_arc(&ws);
1972 let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
1973 assert_eq!(capacity.project, project.as_str());
1974 assert_eq!(capacity.cap, 1);
1975 assert_eq!(capacity.cap_source, WorkerCapSource::ProjectMaxWorkers);
1976 assert_eq!(capacity.live, 1);
1977 }
1978
1979 #[test]
1980 fn capacity_falls_back_to_default_without_override() {
1981 let (ws, _rx) = Workspace::testing_stub();
1982 ws.seed_test_project("forge", "/tmp/forge");
1983 let project = project_key_of(&ws, "forge");
1984 let caller = worker_slot("implementer");
1985 ws.insert_live_worker(&project, entry("implementer"));
1986
1987 let facade = ProdWorkerFacade::from_arc(&ws);
1988 let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
1989 assert_eq!(capacity.cap, crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT);
1990 assert_eq!(capacity.cap_source, WorkerCapSource::Default);
1991 assert_eq!(capacity.live, 1);
1992 }
1993
1994 #[test]
1995 fn capacity_live_count_matches_project_workers() {
1996 let (ws, _rx) = Workspace::testing_stub();
1997 ws.seed_test_project("forge", "/tmp/forge");
1998 let project = project_key_of(&ws, "forge");
1999 let caller = worker_slot("a");
2000 ws.insert_live_worker(&project, entry("a"));
2001 ws.insert_live_worker(&project, entry("b"));
2002
2003 let facade = ProdWorkerFacade::from_arc(&ws);
2004 let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
2005 assert_eq!(capacity.live, 2);
2006 }
2007
2008 #[test]
2009 fn capacity_none_for_unknown_caller() {
2010 let (ws, _rx) = Workspace::testing_stub();
2011 let facade = ProdWorkerFacade::from_arc(&ws);
2012 assert!(facade.capacity(&SessionSlot::from_str_for_test("ghost")).is_none());
2013 }
2014
2015 #[test]
2016 fn capacity_excludes_failed_workers_from_live_count() {
2017 let (ws, _rx) = Workspace::testing_stub();
2018 ws.seed_test_project_with_max_workers("forge", "/tmp/forge", 2);
2019 let project = project_key_of(&ws, "forge");
2020 let caller = worker_slot("running");
2021 ws.insert_live_worker(&project, entry("running"));
2022 let mut failed = entry("failed");
2023 failed.status = WorkerLiveness::Failed;
2024 ws.insert_live_worker(&project, failed);
2025
2026 let facade = ProdWorkerFacade::from_arc(&ws);
2027 let capacity = facade.capacity(&caller).expect("live worker resolves to its project");
2028 assert_eq!(
2029 capacity.live, 1,
2030 "a Failed entry stays in the map but must not count against the cap"
2031 );
2032 }
2033
2034 #[test]
2035 fn capacity_zero_live_workers_after_failure() {
2036 let (ws, _rx) = Workspace::testing_stub();
2037 ws.seed_test_project("forge", "/tmp/forge");
2038 let project = project_key_of(&ws, "forge");
2039 let caller = worker_slot("only");
2040 let mut failed = entry("only");
2041 failed.status = WorkerLiveness::Failed;
2042 ws.insert_live_worker(&project, failed);
2043
2044 let facade = ProdWorkerFacade::from_arc(&ws);
2045 let capacity = facade.capacity(&caller).expect("a Failed entry still resolves the project");
2046 assert_eq!(capacity.live, 0);
2047 assert_eq!(capacity.cap, crate::config::DEFAULT_MAX_WORKERS_PER_PROJECT);
2048 }
2049}
2050
2051#[cfg(test)]
2052mod worktree_creation_failed_tests {
2053 use super::*;
2054
2055 #[test]
2056 fn worktree_creation_failed_carries_reason() {
2057 let err = WorkerSpawnError::WorktreeCreationFailed {
2058 reason: "fatal: 'worktree-reviewer' is already used by worktree at /a".into(),
2059 };
2060 let WorkerSpawnError::WorktreeCreationFailed { reason } = err else {
2061 panic!("expected WorktreeCreationFailed");
2062 };
2063 assert!(reason.contains("already used by worktree"));
2064 }
2065
2066 #[test]
2076 fn classify_routes_worktree_word_to_worktree_creation_failed_when_git() {
2077 let err = classify_worker_spawn_failure(
2080 "Error creating worktree: something went wrong",
2081 true,
2082 SpawnFailureKind::Unclassified,
2083 );
2084 let WorkerSpawnError::WorktreeCreationFailed { reason } = err else {
2085 panic!("expected WorktreeCreationFailed; got {err:?}");
2086 };
2087 assert!(reason.contains("worktree"));
2088 }
2089
2090 #[test]
2091 fn classify_routes_base_branch_resolve_to_worktree_creation_failed_when_git() {
2092 let err = classify_worker_spawn_failure(
2098 "Failed to resolve base branch \"HEAD\": git rev-parse failed",
2099 true,
2100 SpawnFailureKind::Unclassified,
2101 );
2102 assert!(
2103 matches!(err, WorkerSpawnError::WorktreeCreationFailed { .. }),
2104 "expected WorktreeCreationFailed; got {err:?}",
2105 );
2106 }
2107
2108 #[test]
2109 fn classify_routes_already_used_to_worktree_creation_failed_when_git() {
2110 let err = classify_worker_spawn_failure(
2114 "fatal: 'reviewer' is already used by worktree at /a/b/c",
2115 true,
2116 SpawnFailureKind::Unclassified,
2117 );
2118 assert!(
2119 matches!(err, WorkerSpawnError::WorktreeCreationFailed { .. }),
2120 "expected WorktreeCreationFailed; got {err:?}",
2121 );
2122 }
2123
2124 #[test]
2125 fn classify_falls_through_to_dispatch_failed_for_unrelated_messages() {
2126 let err = classify_worker_spawn_failure(
2130 "agent spawn failed: subprocess exited with code 2",
2131 true,
2132 SpawnFailureKind::Unclassified,
2133 );
2134 let WorkerSpawnError::DispatchFailed { message } = err else {
2135 panic!("expected DispatchFailed; got {err:?}");
2136 };
2137 assert!(message.contains("subprocess exited"));
2138 }
2139
2140 #[test]
2141 fn classify_with_worktree_word_but_non_git_repo_falls_through() {
2142 let err = classify_worker_spawn_failure(
2149 "Error creating worktree: something went wrong",
2150 false,
2151 SpawnFailureKind::Unclassified,
2152 );
2153 assert!(
2154 matches!(err, WorkerSpawnError::DispatchFailed { .. }),
2155 "expected DispatchFailed; got {err:?}",
2156 );
2157 }
2158
2159 #[test]
2170 fn classify_reads_a_named_variant_over_a_worktrees_path_in_the_message() {
2171 let message = "forge-sdk session resume failed: claude subprocess working directory \
2172 `/repo/.claude/worktrees/reviewer` does not exist";
2173 let named =
2174 classify_worker_spawn_failure(message, true, SpawnFailureKind::WorkingDirMissing);
2175 assert!(
2176 matches!(named, WorkerSpawnError::DispatchFailed { .. }),
2177 "a named missing-working-directory failure is not a worktree-creation \
2178 failure, however its path renders; got {named:?}",
2179 );
2180
2181 let unclassified =
2182 classify_worker_spawn_failure(message, true, SpawnFailureKind::Unclassified);
2183 assert!(
2184 matches!(unclassified, WorkerSpawnError::WorktreeCreationFailed { .. }),
2185 "control: the same message with no named variant must still route on the \
2186 heuristic; got {unclassified:?}",
2187 );
2188 }
2189
2190 #[test]
2191 fn classify_is_case_insensitive_on_match_terms() {
2192 let err = classify_worker_spawn_failure(
2196 "WORKTREE creation failed",
2197 true,
2198 SpawnFailureKind::Unclassified,
2199 );
2200 assert!(
2201 matches!(err, WorkerSpawnError::WorktreeCreationFailed { .. }),
2202 "expected case-insensitive match to WorktreeCreationFailed; got {err:?}",
2203 );
2204 }
2205
2206 #[test]
2207 fn classify_preserves_original_casing_in_reason() {
2208 let original = "Error creating Worktree: nope";
2212 let err = classify_worker_spawn_failure(original, true, SpawnFailureKind::Unclassified);
2213 let WorkerSpawnError::WorktreeCreationFailed { reason } = err else {
2214 panic!("expected WorktreeCreationFailed; got {err:?}");
2215 };
2216 assert_eq!(reason, original);
2217 }
2218
2219 #[test]
2233 fn bridge_prefix_does_not_collide_with_worktree_predicate() {
2234 let prefixes = [
2236 "forge-sdk session spawn failed: ",
2237 "forge-sdk session resume failed: ",
2238 "forge-sdk session spawn failed after resume fallback (resume err: ",
2239 ];
2240 for prefix in prefixes {
2241 let lower = prefix.to_lowercase();
2242 assert!(
2243 !lower.contains("worktree"),
2244 "bridge prefix {prefix:?} must not contain 'worktree' or the \
2245 classifier will misclassify generic errors as worktree failures",
2246 );
2247 assert!(
2248 !lower.contains("failed to resolve base branch"),
2249 "bridge prefix {prefix:?} must not contain the 'failed to resolve base branch' \
2250 discriminator",
2251 );
2252 assert!(
2253 !lower.contains("already used by worktree"),
2254 "bridge prefix {prefix:?} must not contain the 'already used by worktree' \
2255 discriminator",
2256 );
2257 }
2258
2259 let wrapped = "forge-sdk session resume failed: subprocess exited with code 2";
2262 let err = classify_worker_spawn_failure(
2263 wrapped,
2264 true,
2265 SpawnFailureKind::Unclassified,
2266 );
2267 assert!(
2268 matches!(err, WorkerSpawnError::DispatchFailed { .. }),
2269 "wrapped generic failure must stay DispatchFailed; got {err:?}",
2270 );
2271 }
2272
2273 #[test]
2274 fn the_cap_refusal_stays_a_dispatch_failure() {
2275 let message = crate::spawn::worker_limit_reached_message("my-worktree-proj", 2, 2);
2276 assert_eq!(
2277 classify_worker_spawn_failure(&message, true, SpawnFailureKind::Unclassified),
2278 WorkerSpawnError::DispatchFailed { message },
2279 "the cap refusal stays DispatchFailed even when the project key carries 'worktree'",
2280 );
2281 }
2282
2283 #[test]
2289 fn the_known_project_refusal_stays_a_dispatch_failure_whatever_the_project_is_called() {
2290 let message = crate::spawn::known_project_refusal("worktree-lab", "forge");
2291 assert_eq!(
2292 classify_worker_spawn_failure(&message, true, SpawnFailureKind::Unclassified),
2293 WorkerSpawnError::DispatchFailed { message: message.clone() },
2294 "the known-project refusal stays DispatchFailed however its project reads",
2295 );
2296
2297 let own = crate::spawn::known_project_refusal("worktree-lab", "worktree-lab");
2299 assert_eq!(
2300 classify_worker_spawn_failure(&own, true, SpawnFailureKind::Unclassified),
2301 WorkerSpawnError::DispatchFailed { message: own.clone() },
2302 "and the same for a project whose own name carries the word",
2303 );
2304 }
2305
2306 #[test]
2311 fn the_cleanup_refusal_stays_a_dispatch_failure_whatever_the_label_spells() {
2312 let message =
2313 "the previous despawn of 'worktree-fix' is still cleaning up; retry once it finishes";
2314 assert_eq!(
2315 classify_worker_spawn_failure(message, true, SpawnFailureKind::Unclassified),
2316 WorkerSpawnError::DispatchFailed { message: message.to_owned() },
2317 "the cleanup refusal stays DispatchFailed however its label reads",
2318 );
2319 }
2320}
2321
2322#[cfg(test)]
2323mod discard_tests {
2324 use super::*;
2325 use std::sync::Arc;
2326
2327 fn run_git(dir: &std::path::Path, args: &[&str]) {
2328 let status =
2329 std::process::Command::new("git").arg("-C").arg(dir).args(args).status().expect("git");
2330 assert!(status.success(), "git {args:?} in {}", dir.display());
2331 }
2332
2333 fn linked_projects() -> (Arc<Workspace>, std::path::PathBuf, Vec<tempfile::TempDir>) {
2336 let repo = tempfile::tempdir().expect("repo");
2337 let main = repo.path().join("main");
2338 std::fs::create_dir_all(&main).expect("main dir");
2339 run_git(&main, &["init", "-q"]);
2340 run_git(&main, &["config", "user.email", "t@example.com"]);
2341 run_git(&main, &["config", "user.name", "Test"]);
2342 run_git(&main, &["config", "commit.gpgsign", "false"]);
2343 std::fs::write(main.join("README.md"), "seed").expect("write seed");
2344 run_git(&main, &["add", "."]);
2345 run_git(&main, &["commit", "-q", "-m", "init"]);
2346 let main = std::fs::canonicalize(&main).expect("canonical main");
2347 let holder = tempfile::tempdir().expect("holder");
2348 let linked_a = holder.path().join("proj-a");
2349 let linked_b = holder.path().join("proj-b");
2350 run_git(
2351 &main,
2352 &["worktree", "add", "-q", "-b", "proj-a", linked_a.to_str().expect("utf8")],
2353 );
2354 run_git(
2355 &main,
2356 &["worktree", "add", "-q", "-b", "proj-b", linked_b.to_str().expect("utf8")],
2357 );
2358 let cfg = tempfile::tempdir().expect("cfg");
2359 let forge_dir = cfg.path().join("forge");
2360 std::fs::create_dir_all(&forge_dir).expect("forge dir");
2361 std::fs::write(
2362 forge_dir.join("forge.toml"),
2363 format!(
2364 "[[orgs]]\nname = \"Default\"\naccounts = [\"a\"]\n\
2365 [[orgs.projects]]\nname = \"proj-a\"\npath = \"{}\"\n\
2366 [[orgs.projects]]\nname = \"proj-b\"\npath = \"{}\"\n\
2367 [[accounts]]\ndisplay_name = \"a\"\ntoken = \"t\"\n\
2368 models = [\"claude-sonnet-5\"]\nprovider = \"anthropic\"\n",
2369 linked_a.display(),
2370 linked_b.display()
2371 ),
2372 )
2373 .expect("write forge.toml");
2374 let ws = Arc::new(Workspace::new_for_test(cfg.path().to_owned()).expect("boot"));
2375 (ws, main, vec![repo, holder, cfg])
2376 }
2377
2378 fn key_of(ws: &Arc<Workspace>, name: &str) -> crate::ProjectKey {
2379 ws.list_projects().into_iter().find(|v| v.name == name).expect("project").key
2380 }
2381
2382 fn winner_entry(project: &str, label: &str) -> crate::mcp::workers::types::WorkerEntry {
2383 crate::mcp::workers::types::WorkerEntry {
2384 label: label.to_owned(),
2385 charter: "test".to_owned(),
2386 slot: SessionSlot::worker("Default", project, label),
2387 session_id: None,
2388 status: forge_primitives::WorkerLiveness::Running,
2389 spawned_at: std::time::SystemTime::UNIX_EPOCH,
2390 spawned_by: SessionSlot::lead("Default", project),
2391 needs_tag: false,
2392 is_git_repo_at_spawn: true,
2393 cwd: None,
2394 diagnostic: None,
2395 kick: None,
2396 }
2397 }
2398
2399 fn shared_worktree(main: &std::path::Path) -> std::path::PathBuf {
2400 let shared = main.join(".claude").join("worktrees").join("shared");
2401 std::fs::create_dir_all(shared.parent().expect("worktrees root")).expect("mkdir");
2402 run_git(
2403 main,
2404 &["worktree", "add", "-q", "-b", "worktree-shared", shared.to_str().expect("utf8")],
2405 );
2406 shared
2407 }
2408
2409 fn branch_stands(main: &std::path::Path) -> bool {
2410 let branches = std::process::Command::new("git")
2411 .args(["branch", "--list", "worktree-shared"])
2412 .current_dir(main)
2413 .output()
2414 .expect("git branch");
2415 !String::from_utf8_lossy(&branches.stdout).trim().is_empty()
2416 }
2417
2418 #[test]
2422 fn a_rollback_keeps_a_tree_another_project_holds() {
2423 let (ws, main, _keep) = linked_projects();
2424 let shared = shared_worktree(&main);
2425 ws.insert_live_worker(&key_of(&ws, "proj-a"), winner_entry("proj-a", "shared"));
2426
2427 discard_refused_worktree(
2428 &ws,
2429 &key_of(&ws, "proj-b"),
2430 &main,
2431 "shared",
2432 Some((
2433 shared.clone(),
2434 forge_agent::env::worktree::WorktreeEnsure::CreatedWithNewBranch,
2435 )),
2436 );
2437
2438 assert!(shared.exists(), "another project's live worker's tree must survive the rollback");
2439 assert!(branch_stands(&main), "and its branch is not reaped");
2440 }
2441
2442 #[test]
2449 fn a_losing_resume_leaves_the_winners_worktree_alone() {
2450 let (ws, main, _keep) = linked_projects();
2451 let shared = shared_worktree(&main);
2452 ws.insert_live_worker(&key_of(&ws, "proj-a"), winner_entry("proj-a", "shared"));
2453
2454 discard_refused_worktree(
2455 &ws,
2456 &key_of(&ws, "proj-a"),
2457 &main,
2458 "shared",
2459 Some((
2460 shared.clone(),
2461 forge_agent::env::worktree::WorktreeEnsure::CreatedWithNewBranch,
2462 )),
2463 );
2464
2465 assert!(shared.exists(), "the winner's tree survives the losing resume's rollback");
2466 assert!(branch_stands(&main), "and the branch the loser minted is not reaped");
2467 }
2468}