use std::sync::Arc;
use tracing::{info, warn};
use super::inproject::try_inproject_spawn;
use crate::daemon::state::DaemonState;
use crate::provisioner::WorkspaceProvisioner;
use crate::runtime::{RuntimeKind, build_adapter};
use crate::session_manager::{ManagedError, ManagedSessionId, ManagedSessionState, SessionRecord};
#[derive(Debug, Clone)]
pub struct SpawnParams {
pub repo_url: String,
pub git_ref: String,
pub task: String,
pub name_hint: Option<String>,
pub runtime: Option<String>,
pub ephemeral: Option<bool>,
pub mcp_initiated: bool,
}
pub async fn spawn_managed(
state: &Arc<DaemonState>,
params: SpawnParams,
) -> Result<SessionRecord, String> {
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let runtime = match params.runtime.as_deref() {
None => RuntimeKind::default(),
Some(raw) => raw.parse::<RuntimeKind>().map_err(|e| e.to_string())?,
};
if params.mcp_initiated {
let registry = state.project_registry().await;
super::mcp_spawn_gate::ensure_mcp_spawn_allowed(®istry, &config, ¶ms.repo_url)
.await?;
}
let session_id = ManagedSessionId::new();
let emitter = crate::core::provisioning_stage::StageEmitter::new(
session_id.to_string(),
params.repo_url.clone(),
state.event_tx.clone(),
);
crate::core::provisioning_stage::scoped(
emitter,
spawn_managed_routed(state, session_id, params, runtime, config),
)
.await
}
async fn spawn_managed_routed(
state: &Arc<DaemonState>,
session_id: ManagedSessionId,
params: SpawnParams,
runtime: RuntimeKind,
config: crate::core::trusty_tools_config::TrustyToolsConfig,
) -> Result<SessionRecord, String> {
if is_local_workdir(¶ms.repo_url) {
let local_path = std::path::Path::new(¶ms.repo_url);
match try_inproject_spawn(local_path) {
Ok(Some((base, owner, repo))) => {
match reserve_inproject_worktree(
state,
&session_id,
¶ms,
local_path,
&base,
&repo,
&config,
)
.await
{
Ok((worktree, reserved_name)) => {
return spawn_managed_inproject(
state,
&session_id,
¶ms,
runtime,
worktree,
owner,
repo,
reserved_name,
)
.await;
}
Err(e) => {
tracing::warn!(
id = %session_id,
"in-project spawn: {e}; falling back to local-path spawn"
);
}
}
}
Ok(None) => {
}
Err(e) => {
tracing::warn!(
id = %session_id,
"in-project spawn failed: {e}; falling back to local-path spawn"
);
}
}
return spawn_managed_local(state, &session_id, ¶ms, runtime).await;
}
spawn_managed_cloned(state, session_id, params, runtime, config).await
}
async fn reserve_inproject_worktree(
state: &Arc<DaemonState>,
session_id: &ManagedSessionId,
params: &SpawnParams,
local_path: &std::path::Path,
base: &std::path::Path,
repo: &str,
config: &crate::core::trusty_tools_config::TrustyToolsConfig,
) -> Result<(std::path::PathBuf, String), String> {
let mgr = state.session_manager().await;
let reserved_name = mgr
.resolve_session_name(
params.name_hint.as_deref(),
Some(repo),
local_path,
|candidate| super::inproject::worktree_name_collides(base, candidate),
)
.await
.map_err(|e| format!("name resolution failed for session {session_id}: {e}"))?;
let worktree = super::inproject::create_session_worktree(base, &reserved_name)
.map_err(|e| format!("worktree creation failed for session {session_id}: {e}"))?;
let resolved_sync =
crate::core::trusty_tools_config::resolve_untracked_sync(config, Some(repo));
if resolved_sync.enabled {
super::inproject::untracked_sync::sync_untracked_files(
local_path,
&worktree,
&resolved_sync.patterns,
);
}
Ok((worktree, reserved_name))
}
async fn spawn_managed_cloned(
state: &Arc<DaemonState>,
session_id: ManagedSessionId,
params: SpawnParams,
runtime: RuntimeKind,
config: crate::core::trusty_tools_config::TrustyToolsConfig,
) -> Result<SessionRecord, String> {
use crate::core::provisioning_stage::{ProvisioningStage, emit};
let git_identity = crate::core::git_identity::resolve_for_config(&config, ¶ms.repo_url)
.map_err(|e| {
warn!(id = %session_id, "spawn_managed: git identity resolution failed: {e}");
format!("git identity resolution failed: {e}")
})?;
let prepared = match trusty_common::github_path::parse_github_path(¶ms.repo_url) {
Some(gh) => {
let project_dir = crate::core::trusty_tools_config::workspace_subpath(&config, &gh);
let provisioner = WorkspaceProvisioner::new(
crate::provisioner::RealGitBackend::new(git_identity),
std::path::PathBuf::new(),
);
provisioner.provision_in(
&project_dir,
&session_id,
¶ms.repo_url,
¶ms.git_ref,
¶ms.task,
)
}
None => {
let workspace_root = crate::core::trusty_tools_config::workspace_root(&config);
let provisioner = WorkspaceProvisioner::new(
crate::provisioner::RealGitBackend::new(git_identity),
workspace_root,
);
provisioner.provision(&session_id, ¶ms.repo_url, ¶ms.git_ref, ¶ms.task)
}
}
.map_err(|e| {
warn!(id = %session_id, "spawn_managed: provision failed: {e}");
format!("workspace provisioning failed: {e}")
})?;
emit(ProvisioningStage::CreatingTmuxSession);
let mgr = state.session_manager().await;
let record = mgr
.create_with_id(
session_id,
params.task.clone(),
Some(prepared.path.clone()),
params.name_hint,
Some(prepared.path.clone()),
Some(params.repo_url.clone()),
Some(params.git_ref.clone()),
runtime,
params.ephemeral.unwrap_or(false),
false, )
.await
.map_err(|e| {
warn!(id = %session_id, "spawn_managed: session create failed: {e}");
e.to_string()
})?;
if let Some(record) =
front_gate_or_escalate(&mgr, &record, ¶ms.repo_url, ¶ms.task).await?
{
return Ok(record);
}
if let Err(e) = mgr
.set_workspace(
&record.id,
prepared.path.clone(),
ManagedSessionState::Active,
)
.await
{
warn!(id = %record.id, "spawn_managed: set_workspace failed: {e}");
}
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(&prepared.path);
if let Err(reason) =
ensure_deployment_complete(&fw, &prepared.path, record.repo_url.as_deref(), &record.id)
{
warn!(id = %record.id, "spawn_managed: deployment incomplete after auto-repair (non-blocking, launch proceeds): {reason}");
}
emit(ProvisioningStage::LaunchingRuntime);
let tmux_arc = mgr.tmux_driver();
let adapter = build_adapter(record.runtime, tmux_arc);
if let Err(e) = adapter.spawn(
&record.tmux_name,
&prepared.path,
¶ms.task,
&record.id.to_string(),
) {
warn!(
id = %record.id,
name = %record.tmux_name,
runtime = %record.runtime.as_str(),
"spawn_managed: runtime adapter spawn failed: {e}"
);
let _ = mgr
.mark_errored(&record.id, &format!("spawn failed: {e}"))
.await;
} else {
info!(
id = %record.id,
name = %record.tmux_name,
path = %prepared.path.display(),
"managed session spawned successfully"
);
}
emit(ProvisioningStage::Complete);
Ok(mgr.get(&record.id).await.unwrap_or(record))
}
pub fn is_local_workdir(s: &str) -> bool {
let p = std::path::Path::new(s);
p.is_absolute() && p.is_dir()
}
pub fn write_task_md(workspace: &std::path::Path, task: &str, session_id: &ManagedSessionId) {
if task.is_empty() {
return;
}
let task_file = workspace.join("TASK.md");
if let Err(e) = std::fs::write(&task_file, task) {
tracing::warn!(
session = %session_id,
path = %task_file.display(),
"failed to write TASK.md (local-path spawn): {e}"
);
}
}
fn find_reusable_inproject_session(
records: &[SessionRecord],
source_id: &str,
tmux: &dyn crate::session_manager::ManagedTmuxDriver,
) -> Option<SessionRecord> {
records
.iter()
.find(|r| {
r.source_id.as_deref() == Some(source_id)
&& r.state == ManagedSessionState::Active
&& tmux.session_exists(&r.tmux_name)
})
.cloned()
}
#[allow(clippy::too_many_arguments)]
async fn spawn_managed_inproject(
state: &std::sync::Arc<crate::daemon::state::DaemonState>,
session_id: &crate::session_manager::ManagedSessionId,
params: &SpawnParams,
runtime: crate::runtime::RuntimeKind,
worktree: std::path::PathBuf,
owner: String,
repo: String,
reserved_name: String,
) -> Result<crate::session_manager::SessionRecord, String> {
use crate::core::provisioning_stage::{ProvisioningStage, emit};
use crate::session_manager::ManagedSessionState;
let candidate_source_id = format!("{owner}/{repo}");
{
let mgr = state.session_manager().await;
let existing = mgr.list().await;
if let Some(live) =
find_reusable_inproject_session(&existing, &candidate_source_id, &*mgr.tmux_driver())
{
info!(
id = %live.id,
source_id = %candidate_source_id,
"spawn_managed (inproject): reconnecting to existing live session"
);
return Ok(live);
}
}
info!(
id = %session_id,
worktree = %worktree.display(),
owner = %owner,
repo = %repo,
"spawn_managed: in-project worktree spawn"
);
write_task_md(&worktree, ¶ms.task, session_id);
let synthetic_repo_url = format!("https://github.com/{owner}/{repo}");
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(&worktree);
prepare_inproject_session(&fw, session_id, &worktree, &synthetic_repo_url);
emit(ProvisioningStage::CreatingTmuxSession);
let mgr = state.session_manager().await;
let record = mgr
.create_with_reserved_name(
*session_id,
reserved_name,
params.task.clone(),
Some(worktree.clone()),
Some(worktree.clone()),
Some(synthetic_repo_url),
None,
runtime,
params.ephemeral.unwrap_or(false),
false, )
.await
.map_err(|e| {
warn!(id = %session_id, "spawn_managed (inproject): create failed: {e}");
e.to_string()
})?;
let source_id = format!("{owner}/{repo}");
if let Err(e) = mgr.set_source_id(session_id, &source_id).await {
warn!(id = %session_id, "spawn_managed (inproject): set_source_id failed: {e}");
}
if let Some(record) =
front_gate_or_escalate(&mgr, &record, ¶ms.repo_url, ¶ms.task).await?
{
return Ok(record);
}
if let Err(e) = mgr
.set_workspace(session_id, worktree.clone(), ManagedSessionState::Active)
.await
{
warn!(id = %session_id, "spawn_managed (inproject): set_workspace failed: {e}");
}
if let Err(reason) =
ensure_deployment_complete(&fw, &worktree, record.repo_url.as_deref(), session_id)
{
warn!(id = %session_id, "spawn_managed (inproject): deployment incomplete after auto-repair (non-blocking, launch proceeds): {reason}");
}
emit(ProvisioningStage::LaunchingRuntime);
let tmux_arc = mgr.tmux_driver();
let adapter = crate::runtime::build_adapter(record.runtime, tmux_arc);
if let Err(e) = adapter.spawn(
&record.tmux_name,
&worktree,
¶ms.task,
&record.id.to_string(),
) {
warn!(
id = %record.id,
name = %record.tmux_name,
"spawn_managed (inproject): runtime adapter spawn failed: {e}"
);
let _ = mgr
.mark_errored(&record.id, &format!("spawn failed: {e}"))
.await;
} else {
info!(
id = %record.id,
name = %record.tmux_name,
worktree = %worktree.display(),
"managed session spawned successfully (in-project worktree)"
);
}
emit(ProvisioningStage::Complete);
Ok(mgr.get(&record.id).await.unwrap_or(record))
}
fn prepare_inproject_session(
fw: &crate::core::paths::FrameworkPaths,
session_id: &ManagedSessionId,
worktree: &std::path::Path,
repo_url: &str,
) {
match crate::core::session_launch::prepare_session_with_repo_url(fw, worktree, Some(repo_url)) {
Ok(report) => {
info!(
id = %session_id,
deployed = report.deploy.deployed.len(),
skills_deployed = report.skill_deploy.deployed.len(),
worktree = %worktree.display(),
"spawn_managed (inproject): session prepared"
);
for err in &report.roster_errors {
tracing::error!(
id = %session_id,
worktree = %worktree.display(),
"spawn_managed (inproject): roster provisioning gap (session \
still launches with its trusty-mpm identity): {err}"
);
}
}
Err(e) => {
warn!(
id = %session_id,
worktree = %worktree.display(),
"spawn_managed (inproject): session prep failed (non-fatal): {e}"
);
}
}
}
async fn spawn_managed_local(
state: &Arc<DaemonState>,
session_id: &ManagedSessionId,
params: &SpawnParams,
runtime: RuntimeKind,
) -> Result<SessionRecord, String> {
use crate::core::provisioning_stage::{ProvisioningStage, emit};
let local_dir = std::path::PathBuf::from(¶ms.repo_url);
let origin_url = super::inproject::get_origin_url(&local_dir).ok_or_else(|| {
format!(
"spawn failed: '{}' has no git origin remote; \
managed sessions require a GitHub remote. \
Use `tm connect` / `tm launch --live` to run in the live checkout.",
local_dir.display()
)
})?;
let gh = trusty_common::github_path::parse_github_path(&origin_url).ok_or_else(|| {
format!(
"spawn failed: could not parse a GitHub owner/repo from origin remote \
'{origin_url}' for '{}'. \
Use `tm connect` to run in the live checkout instead.",
local_dir.display()
)
})?;
let source_id_str = format!("{}/{}", gh.owner, gh.repo);
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let project_dir = crate::core::trusty_tools_config::workspace_subpath(&config, &gh);
let git_identity = crate::core::git_identity::resolve_for_config(&config, &origin_url)
.map_err(|e| {
warn!(id = %session_id, "spawn_managed (local→managed): git identity resolution failed: {e}");
format!("git identity resolution failed: {e}")
})?;
let provisioner = crate::provisioner::WorkspaceProvisioner::new(
crate::provisioner::RealGitBackend::new(git_identity),
std::path::PathBuf::new(),
);
let prepared = provisioner
.provision_in(
&project_dir,
session_id,
&origin_url,
¶ms.git_ref,
¶ms.task,
)
.map_err(|e| {
warn!(id = %session_id, "spawn_managed (local→managed): provision failed: {e}");
format!("workspace provisioning failed: {e}")
})?;
let workspace = prepared.path;
info!(
id = %session_id,
live = %local_dir.display(),
managed = %workspace.display(),
source_id = %source_id_str,
"spawn_managed: local-path redirected to managed clone (#1590)"
);
emit(ProvisioningStage::CreatingTmuxSession);
let mgr = state.session_manager().await;
let record = mgr
.create_with_id(
*session_id,
params.task.clone(),
Some(workspace.clone()),
params.name_hint.clone(),
Some(workspace.clone()),
Some(origin_url.clone()),
if params.git_ref.is_empty() {
None
} else {
Some(params.git_ref.clone())
},
runtime,
params.ephemeral.unwrap_or(false),
true, )
.await
.map_err(|e| {
warn!(id = %session_id, "spawn_managed (local→managed): create failed: {e}");
e.to_string()
})?;
if let Err(e) = mgr.set_source_id(session_id, &source_id_str).await {
warn!(id = %session_id, "spawn_managed (local→managed): set_source_id failed: {e}");
}
if let Some(record) = front_gate_or_escalate(&mgr, &record, &origin_url, ¶ms.task).await? {
return Ok(record);
}
if let Err(e) = mgr
.set_workspace(&record.id, workspace.clone(), ManagedSessionState::Active)
.await
{
warn!(id = %record.id, "spawn_managed (local→managed): set_workspace failed: {e}");
}
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(&workspace);
if let Err(reason) =
ensure_deployment_complete(&fw, &workspace, record.repo_url.as_deref(), &record.id)
{
warn!(id = %record.id, "spawn_managed (local→managed): deployment incomplete after auto-repair (non-blocking, launch proceeds): {reason}");
}
emit(ProvisioningStage::LaunchingRuntime);
let tmux_arc = mgr.tmux_driver();
let adapter = build_adapter(record.runtime, tmux_arc);
if let Err(e) = adapter.spawn(
&record.tmux_name,
&workspace,
¶ms.task,
&record.id.to_string(),
) {
warn!(
id = %record.id,
name = %record.tmux_name,
runtime = %record.runtime.as_str(),
"spawn_managed (local→managed): runtime adapter spawn failed: {e}"
);
let _ = mgr
.mark_errored(&record.id, &format!("spawn failed: {e}"))
.await;
} else {
info!(
id = %record.id,
name = %record.tmux_name,
path = %workspace.display(),
"managed session spawned successfully (local→managed clone)"
);
}
emit(ProvisioningStage::Complete);
Ok(mgr.get(&record.id).await.unwrap_or(record))
}
pub async fn spawn_runtime_for(
state: &Arc<DaemonState>,
record: &SessionRecord,
) -> Result<(), String> {
let mgr = state.session_manager().await;
let workspace = record
.workspace_path
.clone()
.unwrap_or_else(|| record.cwd.clone());
if let Err(e) = mgr
.set_workspace(&record.id, workspace.clone(), ManagedSessionState::Active)
.await
{
warn!(id = %record.id, "spawn_runtime_for: set_workspace failed: {e}");
}
let tmux_arc = mgr.tmux_driver();
let adapter = build_adapter(record.runtime, tmux_arc);
if let Err(e) = adapter.spawn(
&record.tmux_name,
&workspace,
&record.task,
&record.id.to_string(),
) {
warn!(
id = %record.id,
name = %record.tmux_name,
"spawn_runtime_for: runtime adapter spawn failed: {e}"
);
let _ = mgr
.mark_errored(&record.id, &format!("spawn failed: {e}"))
.await;
return Err(e.to_string());
}
info!(
id = %record.id,
name = %record.tmux_name,
"FRONT-gate-escalated session spawned after human approval"
);
Ok(())
}
async fn front_gate_or_escalate(
mgr: &Arc<crate::session_manager::SessionManager>,
record: &SessionRecord,
repo_url: &str,
task: &str,
) -> Result<Option<SessionRecord>, String> {
use super::front_gate::{
HeadlessApproval, IsrConformanceGate, prework_autonomy_disposition, run_front_gate,
};
let (owner, repo) = match trusty_common::github_path::parse_github_path(repo_url) {
Some(gh) => (gh.owner, gh.repo),
None => return Ok(None),
};
let repo_root = record
.workspace_path
.clone()
.unwrap_or_else(|| record.cwd.clone());
let gate = IsrConformanceGate::new(repo_root);
let autonomy = prework_autonomy_disposition(task);
let approval = HeadlessApproval::current();
let outcome = run_front_gate(&gate, &owner, &repo, task, autonomy, approval).await;
if outcome.may_spawn() {
return Ok(None);
}
let reason = outcome
.escalation_reason()
.unwrap_or("conformance escalation")
.to_string();
warn!(
id = %record.id,
approval = ?outcome.approval,
"spawn_managed: FRONT gate escalated; withholding spawn: {reason}"
);
mgr.set_pending_decision(&record.id, &reason, outcome.proposed_default.as_deref())
.await
.map_err(|e| {
warn!(id = %record.id, "spawn_managed: set_pending_decision failed: {e}");
e.to_string()
})?;
Ok(Some(
mgr.get(&record.id).await.unwrap_or_else(|_| record.clone()),
))
}
#[derive(Debug, thiserror::Error)]
pub enum ResumeManagedError {
#[error("session not found: {0}")]
NotFound(String),
#[error("invalid state transition: {0}")]
InvalidState(String),
#[error("{0}")]
Other(String),
}
impl From<ManagedError> for ResumeManagedError {
fn from(e: ManagedError) -> Self {
match e {
ManagedError::SessionNotFound(id) => ResumeManagedError::NotFound(id),
ManagedError::InvalidState(_, reason) => ResumeManagedError::InvalidState(reason),
other => ResumeManagedError::Other(other.to_string()),
}
}
}
pub async fn resume_managed(
state: &Arc<DaemonState>,
id: &ManagedSessionId,
) -> Result<SessionRecord, ResumeManagedError> {
let mgr = state.session_manager().await;
let record = mgr.resume(id).await.map_err(ResumeManagedError::from)?;
let workspace = record
.workspace_path
.clone()
.unwrap_or_else(|| record.cwd.clone());
if let Err(e) = crate::core::session_launch::ensure_status_line(&workspace) {
warn!(
id = %record.id,
"resume_managed: statusline self-heal failed (non-fatal): {e}"
);
}
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(&workspace);
if let Err(reason) =
ensure_deployment_complete(&fw, &workspace, record.repo_url.as_deref(), &record.id)
{
warn!(id = %record.id, "resume_managed: deployment incomplete after auto-repair (non-blocking, launch proceeds): {reason}");
}
let tmux_arc = mgr.tmux_driver();
let adapter = build_adapter(record.runtime, tmux_arc);
if let Err(e) = adapter.spawn_resume(
&record.tmux_name,
&workspace,
&record.task,
record.claude_session_id.as_deref(),
&record.id.to_string(),
) {
warn!(
id = %record.id,
name = %record.tmux_name,
runtime = %record.runtime.as_str(),
"resume_managed: runtime adapter spawn_resume failed: {e}"
);
let _ = mgr
.mark_errored(&record.id, &format!("resume spawn failed: {e}"))
.await;
} else {
info!(
id = %record.id,
name = %record.tmux_name,
workspace = %workspace.display(),
"managed session resumed and runtime respawned"
);
}
Ok(mgr.get(id).await.unwrap_or(record))
}
fn ensure_deployment_complete(
fw: &crate::core::paths::FrameworkPaths,
workspace: &std::path::Path,
repo_url: Option<&str>,
session_id: &ManagedSessionId,
) -> Result<(), String> {
if workspace == std::path::Path::new("/unknown") || !workspace.is_dir() {
return Ok(());
}
let outcome = crate::core::deploy_validate::validate_and_repair(fw, workspace, repo_url);
warn_if_no_persona_carrier(&outcome.after.gaps, workspace, session_id);
if outcome.before.is_complete() {
return Ok(());
}
if outcome.is_complete() {
info!(
id = %session_id,
gaps = outcome.before.gaps.len(),
"deployment validation: auto-repair closed all gaps before handoff"
);
return Ok(());
}
let detail: Vec<String> = outcome.after.gaps.iter().map(|g| g.describe()).collect();
Err(format!(
"deployment incomplete after auto-repair ({} gap(s) remain): {}",
detail.len(),
detail.join("; ")
))
}
fn warn_if_no_persona_carrier(
gaps: &[crate::core::deploy_validate::DeploymentGap],
workspace: &std::path::Path,
session_id: &ManagedSessionId,
) {
if carrier_reachable(gaps, workspace) {
return;
}
warn!(
id = %session_id,
workspace = %workspace.display(),
"deployment self-check: no delegation-persona carrier reachable under \
--setting-sources project,local (no project-tier output-style file \
resolved from settings.json's outputStyle, and no \
.trusty-mpm/last-instructions.md prompt stash) — the launched PM may \
be missing its identity/instructions carrier; this is diagnostic only \
and does not block the launch (issue #2231). Re-run `tm doctor` or \
`tm repair` against this workspace to re-provision the .claude/ payload."
);
}
fn carrier_reachable(
gaps: &[crate::core::deploy_validate::DeploymentGap],
workspace: &std::path::Path,
) -> bool {
use crate::core::deploy_validate::DeploymentGap;
let output_style_ok = !gaps.iter().any(|g| {
matches!(
g,
DeploymentGap::OutputStyleKeyMissing
| DeploymentGap::OutputStyleUnknownId(_)
| DeploymentGap::OutputStyleFileMissing(_)
)
});
if output_style_ok {
return true;
}
workspace
.join(".trusty-mpm")
.join("last-instructions.md")
.metadata()
.map(|m| m.is_file() && m.len() > 0)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
struct StubTmux {
alive: Vec<String>,
}
impl crate::session_manager::ManagedTmuxDriver for StubTmux {
fn create_session(&self, _name: &str, _workdir: &str) -> Result<(), ManagedError> {
unimplemented!("not exercised by find_reusable_inproject_session tests")
}
fn kill_session(&self, _name: &str) -> Result<(), ManagedError> {
unimplemented!("not exercised by find_reusable_inproject_session tests")
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
unimplemented!("not exercised by find_reusable_inproject_session tests")
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
unimplemented!("not exercised by find_reusable_inproject_session tests")
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(self.alive.clone())
}
}
fn stub_record(
source_id: Option<&str>,
state: ManagedSessionState,
tmux_name: &str,
) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::new(),
tmux_name: tmux_name.to_owned(),
cwd: std::path::PathBuf::from("/tmp/project"),
task: "task".into(),
state,
created_at: chrono::Utc::now(),
last_activity_at: None,
workspace_path: None,
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: source_id.map(str::to_owned),
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
}
}
#[test]
fn find_reusable_inproject_session_matches_active_live_session() {
let records = vec![stub_record(
Some("bobmatnyc/trusty-tools"),
ManagedSessionState::Active,
"tmpm-trusty-tools-abc123",
)];
let tmux = StubTmux {
alive: vec!["tmpm-trusty-tools-abc123".to_owned()],
};
let found = find_reusable_inproject_session(&records, "bobmatnyc/trusty-tools", &tmux);
assert!(
found.is_some(),
"an Active record with a live tmux session for the same source_id must be reused"
);
assert_eq!(found.unwrap().tmux_name, "tmpm-trusty-tools-abc123");
}
#[test]
fn find_reusable_inproject_session_ignores_stopped_or_dead_or_other_project() {
let records = vec![
stub_record(
Some("bobmatnyc/xflux"),
ManagedSessionState::Active,
"tmpm-xflux-live",
),
stub_record(
Some("bobmatnyc/trusty-tools"),
ManagedSessionState::Stopped,
"tmpm-trusty-tools-stopped",
),
stub_record(
Some("bobmatnyc/trusty-tools"),
ManagedSessionState::Active,
"tmpm-trusty-tools-dead-tmux",
),
];
let tmux = StubTmux {
alive: vec!["tmpm-xflux-live".to_owned()],
};
let found = find_reusable_inproject_session(&records, "bobmatnyc/trusty-tools", &tmux);
assert!(
found.is_none(),
"must not reuse a different project's session, a Stopped record, \
or an Active record whose tmux session is no longer alive; got: {found:?}"
);
}
#[test]
fn prepare_inproject_session_writes_statusline() {
let tmp_home = tempfile::TempDir::new().expect("tmp home");
let worktree = tempfile::TempDir::new().expect("tmp worktree");
let fw = crate::core::paths::FrameworkPaths::under(tmp_home.path());
let session_id = ManagedSessionId::new();
prepare_inproject_session(
&fw,
&session_id,
worktree.path(),
"https://github.com/owner/repo",
);
let settings_path = worktree.path().join(".claude").join("settings.json");
let content = std::fs::read_to_string(&settings_path).unwrap_or_else(|e| {
panic!(
"prepare_inproject_session must write {}: {e}",
settings_path.display()
)
});
assert!(
content.contains("statusLine"),
"prepared worktree settings.json must carry the statusLine key \
(the #1913 symptom); got: {content}"
);
}
#[tokio::test]
async fn prepare_inproject_session_emits_stage_events_in_order() {
use crate::core::provisioning_stage::{ProvisioningStage, StageEmitter, scoped};
let tmp_home = tempfile::TempDir::new().expect("tmp home");
let worktree = tempfile::TempDir::new().expect("tmp worktree");
let fw = crate::core::paths::FrameworkPaths::under(tmp_home.path());
let session_id = ManagedSessionId::new();
let (tx, mut rx) = tokio::sync::broadcast::channel(32);
let emitter =
StageEmitter::new(session_id.to_string(), "https://github.com/owner/repo", tx);
scoped(emitter, async {
prepare_inproject_session(
&fw,
&session_id,
worktree.path(),
"https://github.com/owner/repo",
);
})
.await;
let mut stages = Vec::new();
while let Ok(value) = rx.try_recv() {
assert_eq!(value["kind"], "provisioning_stage");
assert_eq!(value["repo_url"], "https://github.com/owner/repo");
stages.push(value["stage"].as_str().unwrap().to_string());
}
assert_eq!(
stages,
vec![
ProvisioningStage::DeployingAgents.wire_name(),
ProvisioningStage::DeployingSkills.wire_name(),
ProvisioningStage::BuildingInstructions.wire_name(),
ProvisioningStage::ConfiguringMcp.wire_name(),
],
"prepare_inproject_session's call tree must emit exactly these \
four stages, in order, when a StageEmitter scope is active"
);
}
#[tokio::test]
async fn reserve_inproject_worktree_uses_semantic_name_not_uuid() {
let data_root = tempfile::TempDir::new().expect("tmp data root");
let state = std::sync::Arc::new(
crate::daemon::state::DaemonState::with_root_isolated_managed(
data_root.path().to_path_buf(),
)
.await,
);
let base_dir = tempfile::TempDir::new().expect("tmp base dir");
let base = base_dir.path().to_path_buf();
assert!(
std::process::Command::new("git")
.arg("init")
.current_dir(&base)
.status()
.map(|s| s.success())
.unwrap_or(false),
"git init must succeed in this test fixture"
);
for (k, v) in [("user.email", "t@example.com"), ("user.name", "T")] {
let _ = std::process::Command::new("git")
.args(["-C", base.to_str().unwrap(), "config", k, v])
.status();
}
assert!(
std::process::Command::new("git")
.args([
"-C",
base.to_str().unwrap(),
"commit",
"--allow-empty",
"-m",
"init",
])
.status()
.map(|s| s.success())
.unwrap_or(false),
"git commit must succeed in this test fixture"
);
let session_id = ManagedSessionId::new();
let params = SpawnParams {
repo_url: base.to_string_lossy().into_owned(),
git_ref: "main".into(),
task: "task".into(),
name_hint: None,
runtime: None,
ephemeral: Some(true),
mcp_initiated: false,
};
let config = crate::core::trusty_tools_config::TrustyToolsConfig::default();
let (worktree, reserved_name) = reserve_inproject_worktree(
&state,
&session_id,
¶ms,
&base,
&base,
"trusty-tools",
&config,
)
.await
.expect("reserve_inproject_worktree must succeed against a real git repo");
assert_eq!(
reserved_name, "tm-trusty-tools-01",
"the resolved name must be the SEMANTIC tm-<repo>-NN form, not the raw session UUID"
);
assert!(
worktree.ends_with(&reserved_name),
"the worktree path must end with the resolved semantic name, got {}",
worktree.display()
);
assert!(
!worktree.to_string_lossy().contains(&session_id.to_string()),
"the worktree path must NOT contain the raw session UUID (issue #2032), got {}",
worktree.display()
);
assert!(
worktree.is_dir(),
"the worktree directory must exist on disk, got {}",
worktree.display()
);
}
#[test]
fn ensure_deployment_complete_noops_for_unknown_workspace() {
let id = ManagedSessionId::new();
let fw = crate::core::paths::FrameworkPaths::under("/nonexistent-fw-base-for-test");
let result = ensure_deployment_complete(&fw, std::path::Path::new("/unknown"), None, &id);
assert!(result.is_ok());
let missing = std::path::Path::new("/this/path/does/not/exist/anywhere");
let result = ensure_deployment_complete(&fw, missing, None, &id);
assert!(result.is_ok());
}
#[test]
fn ensure_deployment_complete_ok_when_already_complete() {
use crate::core::agent_manifest::AgentManifest;
use crate::core::paths::FrameworkPaths;
use crate::core::skill_manifest::SkillManifest;
let tmp = tempfile::tempdir().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let mut fw = FrameworkPaths::for_managed_project(tmp.path(), &workspace);
fw.trusty_mpm_root = None;
let agents_dir = fw.claude_agents_dir();
std::fs::create_dir_all(&agents_dir).unwrap();
AgentManifest::default().save(&agents_dir).unwrap();
let skills_dir = fw.claude_skills_dir();
std::fs::create_dir_all(&skills_dir).unwrap();
SkillManifest::default().save(&skills_dir).unwrap();
let claude_dir = workspace.join(".claude");
std::fs::write(
claude_dir.join("settings.json"),
r#"{"outputStyle": "trusty-mpm", "hooks": {"SessionStart": []}}"#,
)
.unwrap();
let style_dir = claude_dir.join("output-styles");
std::fs::create_dir_all(&style_dir).unwrap();
let default_style = crate::core::bundle::OUTPUT_STYLES[0];
std::fs::write(
style_dir.join(default_style.file_name),
default_style.content,
)
.unwrap();
let id = ManagedSessionId::new();
let result = ensure_deployment_complete(&fw, &workspace, None, &id);
assert!(result.is_ok(), "expected Ok, got {result:?}");
}
#[test]
fn carrier_reachable_true_when_no_output_style_gap() {
let tmp = tempfile::tempdir().unwrap();
assert!(
carrier_reachable(&[], tmp.path()),
"no output-style gap at all must be treated as carrier-reachable"
);
}
#[test]
fn carrier_reachable_true_when_prompt_file_present_despite_style_gap() {
let tmp = tempfile::tempdir().unwrap();
let stash_dir = tmp.path().join(".trusty-mpm");
std::fs::create_dir_all(&stash_dir).unwrap();
std::fs::write(stash_dir.join("last-instructions.md"), "you are the PM").unwrap();
let gaps = vec![crate::core::deploy_validate::DeploymentGap::OutputStyleKeyMissing];
assert!(
carrier_reachable(&gaps, tmp.path()),
"a present, non-empty prompt-file stash must satisfy the carrier check \
even when the output-style carrier has a gap"
);
}
#[test]
fn carrier_reachable_false_when_neither_carrier_present() {
let tmp = tempfile::tempdir().unwrap();
let gaps = vec![
crate::core::deploy_validate::DeploymentGap::OutputStyleFileMissing(
"trusty-mpm".to_string(),
),
];
assert!(
!carrier_reachable(&gaps, tmp.path()),
"an output-style gap with no prompt-file stash must be unreachable"
);
}
#[test]
fn carrier_reachable_false_when_prompt_file_present_but_empty() {
let tmp = tempfile::tempdir().unwrap();
let stash_dir = tmp.path().join(".trusty-mpm");
std::fs::create_dir_all(&stash_dir).unwrap();
std::fs::write(stash_dir.join("last-instructions.md"), "").unwrap();
let gaps = vec![
crate::core::deploy_validate::DeploymentGap::OutputStyleUnknownId("bogus".to_string()),
];
assert!(!carrier_reachable(&gaps, tmp.path()));
}
#[test]
fn warn_if_no_persona_carrier_does_not_panic_when_neither_carrier_present() {
let tmp = tempfile::tempdir().unwrap();
let gaps = vec![crate::core::deploy_validate::DeploymentGap::OutputStyleKeyMissing];
let id = ManagedSessionId::new();
warn_if_no_persona_carrier(&gaps, tmp.path(), &id);
}
#[cfg(unix)]
#[test]
fn ensure_deployment_complete_does_not_abort_when_no_carrier_reachable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::set_permissions(&workspace, std::fs::Permissions::from_mode(0o555)).unwrap();
let probe = workspace.join(".probe");
if std::fs::write(&probe, "x").is_ok() {
let _ = std::fs::remove_file(&probe);
let _ = std::fs::set_permissions(&workspace, std::fs::Permissions::from_mode(0o755));
eprintln!(
"skipping ensure_deployment_complete_does_not_abort_when_no_carrier_reachable: \
read-only directory did not block a write (likely running as root)"
);
return;
}
let mut fw =
crate::core::paths::FrameworkPaths::for_managed_project(tmp.path(), &workspace);
fw.trusty_mpm_root = None;
let id = ManagedSessionId::new();
let result = ensure_deployment_complete(&fw, &workspace, None, &id);
let _ = std::fs::set_permissions(&workspace, std::fs::Permissions::from_mode(0o755));
assert!(
result.is_err(),
"expected the pre-existing incomplete-after-repair contract to still hold \
(unrelated to the new carrier self-check); got {result:?}"
);
}
}