use std::sync::Arc;
pub(super) use super::session_prep::prepare_inproject_session;
use tracing::{info, warn};
use super::deployment_check::{RepairHost, ensure_deployment_complete};
use super::inproject::try_inproject_spawn;
use super::local_only_spawn::SessionSource;
use super::managed_checkout::deny_worktree_fallback;
use super::resume_error::ResumeManagedError;
use crate::daemon::state::DaemonState;
use crate::runtime::{RuntimeKind, build_adapter};
use crate::session_manager::{ManagedSessionId, ManagedSessionState, SessionRecord};
#[cfg(test)]
use crate::session_manager::ManagedError;
pub(super) async fn resolve_gh_env(
state: &Arc<DaemonState>,
cwd: &std::path::Path,
) -> Vec<(String, String)> {
let registry = state.project_registry().await;
crate::core::gh_account::resolve_gh_account_env_for_registry(®istry, cwd).await
}
#[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 inject_task: Option<bool>,
pub deliverable_id: Option<String>,
pub force_new: bool,
pub worktree: bool,
}
pub async fn spawn_managed(
state: &Arc<DaemonState>,
session_id: ManagedSessionId,
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())?,
};
crate::core::local_repo_url::require_local_repo_url(¶ms.repo_url)
.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 emitter = crate::core::provisioning_stage::StageEmitter::new(
session_id.to_string(),
params.repo_url.clone(),
state.event_tx.clone(),
);
let inject_flag = params.inject_task != Some(false);
let deliverable_id = params.deliverable_id.clone();
let mut record = crate::core::provisioning_stage::scoped(
emitter,
spawn_managed_routed(state, session_id, params, runtime, config),
)
.await?;
if let Some(raw_id) = deliverable_id {
match raw_id.parse::<crate::deliverable::DeliverableId>() {
Ok(did) => match state
.session_manager()
.await
.set_deliverable_id(&record.id, did)
.await
{
Ok(()) => record.deliverable_id = Some(did),
Err(e) => warn!(
id = %record.id,
"spawn_managed: set_deliverable_id failed: {e}; deliverable link not recorded"
),
},
Err(e) => warn!(
id = %record.id,
"spawn_managed: deliverable_id {raw_id:?} failed to re-parse (already validated by the HTTP layer): {e}"
),
}
}
spawn_task_injection(state.clone(), record.clone(), inject_flag);
Ok(record)
}
fn spawn_task_injection(state: Arc<DaemonState>, record: SessionRecord, inject_flag: bool) {
if !crate::session_manager::should_inject_task(
inject_flag,
&record.task,
record.runtime,
&record.state,
) {
return;
}
tokio::spawn(async move {
let mgr = state.session_manager().await;
match mgr.inject_task_when_ready(&record.id, &record.task).await {
Ok(true) => {}
Ok(false) => warn!(
id = %record.id,
name = %record.tmux_name,
"turnkey task injection skipped (runtime never became ready); task retained as metadata"
),
Err(e) => warn!(
id = %record.id,
name = %record.tmux_name,
"turnkey task injection failed: {e}; task retained as metadata"
),
}
});
}
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);
if let Some(origin_url) = super::inproject::get_origin_url(local_path)?
&& let Some(gh) = trusty_common::github_path::parse_github_path(&origin_url)
{
if !params.worktree {
info!(
id = %session_id,
launch_dir = %local_path.display(),
"spawn_managed: no explicit worktree request (#5274); \
launching the session in the managed checkout"
);
{
let mgr = state.session_manager().await;
let existing = mgr.list().await;
let source_id = format!("{}/{}", gh.owner, gh.repo);
if let Some(live) = reconnect_candidate(
params.force_new,
&existing,
&source_id,
&*mgr.tmux_driver(),
) {
info!(
id = %live.id,
source_id = %source_id,
"spawn_managed (launch-on-main): reconnecting to existing live session"
);
return Ok(live);
}
}
let placement =
super::managed_checkout::resolve_placement(local_path, &gh, &origin_url)?;
super::foreign_harness::warn_for_launch(local_path, &placement);
return super::launch_on_main::spawn_managed_on_main(
state,
&session_id,
¶ms,
runtime,
&placement,
&SessionSource::github(&gh.owner, &gh.repo),
)
.await;
}
}
if crate::core::remote_mode::is_local_only_root(local_path)? {
return super::local_only_spawn::spawn_managed_local_only(
state,
&session_id,
¶ms,
runtime,
local_path,
&config,
)
.await;
}
match try_inproject_spawn(local_path) {
Ok(Some((base, owner, repo))) => {
{
let mgr = state.session_manager().await;
let existing = mgr.list().await;
let source_id = format!("{owner}/{repo}");
if let Some(live) = reconnect_candidate(
params.force_new,
&existing,
&source_id,
&*mgr.tmux_driver(),
) {
info!(
id = %live.id,
source_id = %source_id,
"spawn_managed (inproject): reconnecting to existing live session"
);
return Ok(live);
}
}
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,
SessionSource::github(&owner, &repo),
reserved_name,
)
.await;
}
Err(e) => deny_worktree_fallback(&session_id, params.worktree, &e)?,
}
}
Ok(None) => {
}
Err(e) => deny_worktree_fallback(&session_id, params.worktree, &e)?,
}
return Err(super::local_only_spawn::spawn_managed_local(
&session_id,
¶ms,
));
}
Err(crate::core::local_repo_url::NonLocalRepoUrl {
repo_url: params.repo_url.clone(),
}
.to_string())
}
pub(super) 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_daemon_session_worktree(base, &reserved_name, session_id)
.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))
}
pub use crate::core::local_repo_url::is_local_workdir;
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()
}
fn reconnect_candidate(
force_new: bool,
records: &[SessionRecord],
source_id: &str,
tmux: &dyn crate::session_manager::ManagedTmuxDriver,
) -> Option<SessionRecord> {
if force_new {
return None;
}
find_reusable_inproject_session(records, source_id, tmux)
}
pub(super) 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,
source: SessionSource,
reserved_name: String,
) -> Result<crate::session_manager::SessionRecord, String> {
use crate::core::provisioning_stage::{ProvisioningStage, emit};
use crate::session_manager::ManagedSessionState;
info!(
id = %session_id,
worktree = %worktree.display(),
source = ?source.source_id,
"spawn_managed: in-project worktree spawn"
);
write_task_md(&worktree, ¶ms.task, session_id);
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(&worktree);
let reachable =
prepare_inproject_session(&fw, session_id, &worktree, source.repo_url.as_deref())?;
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()),
source.repo_url.clone(),
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()
})?;
if let Some(source_id) = &source.source_id
&& let Err(e) = mgr.set_source_id(session_id, source_id).await
{
warn!(id = %session_id, "spawn_managed (inproject): set_source_id failed: {e}");
}
mgr.set_worktree_owner_best_effort(session_id, *session_id)
.await;
state
.project_registry()
.await
.register_from_session(&record)
.await;
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}");
}
let host = RepairHost::new(reachable, state.user_home());
let url = record.repo_url.as_deref();
if let Err(reason) = ensure_deployment_complete(&fw, &worktree, url, session_id, host) {
warn!(id = %session_id, "spawn_managed (inproject): deployment incomplete after auto-repair (non-blocking, launch proceeds): {reason}");
}
crate::core::session_launch::spawn_workstream_label_ensure(
record.repo_url.clone(),
worktree.clone(),
record.tmux_name.clone(),
);
emit(ProvisioningStage::LaunchingRuntime);
let tmux_arc = mgr.tmux_driver();
let tmux_driver = tmux_arc.clone();
let adapter = build_adapter(record.runtime, tmux_arc, reachable, state.framework_root());
let gh_env = resolve_gh_env(state, &worktree).await;
if let Err(e) = adapter.spawn(
&record.tmux_name,
&worktree,
¶ms.task,
&record.id.to_string(),
&gh_env,
) {
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 {
super::launch_verify::record_spawn_outcome(&mgr, tmux_driver.as_ref(), &record).await;
}
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 tmux_driver = tmux_arc.clone();
let adapter = build_adapter(record.runtime, tmux_arc, None, state.framework_root());
let gh_env = resolve_gh_env(state, &workspace).await;
if let Err(e) = adapter.spawn(
&record.tmux_name,
&workspace,
&record.task,
&record.id.to_string(),
&gh_env,
) {
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());
}
super::launch_verify::record_spawn_outcome(&mgr, tmux_driver.as_ref(), record).await;
Ok(())
}
pub(super) 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_else(|| "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()),
))
}
pub async fn resume_managed(
state: &Arc<DaemonState>,
id: &ManagedSessionId,
) -> Result<SessionRecord, ResumeManagedError> {
let mgr = state.session_manager().await;
let _in_flight = mgr.begin_resume(id).map_err(ResumeManagedError::from)?;
let record = mgr
.resume_inner(id)
.await
.map_err(ResumeManagedError::from)?;
mgr.note_operator_resume(id).await;
let workspace = record
.workspace_path
.clone()
.unwrap_or_else(|| record.cwd.clone());
crate::core::session_launch::resume_self_heal(&workspace, &record.id.to_string()).await;
if let Err(e) = state.ensure_status_line(&workspace) {
warn!(
id = %record.id,
"resume_managed: statusline self-heal failed (non-fatal): {e}"
);
}
let fw_root = state.framework_root();
let fw = crate::core::paths::FrameworkPaths::for_managed_project(fw_root, &workspace);
let host = RepairHost::new(None, state.user_home());
let url = record.repo_url.as_deref();
if let Err(reason) = ensure_deployment_complete(&fw, &workspace, url, &record.id, host) {
warn!(id = %record.id, "resume_managed: deployment incomplete after auto-repair (non-blocking, launch proceeds): {reason}");
}
if let Err(msg) =
super::session_prep::refresh_resume_compiled_prompt_in(fw_root, &workspace, &record.id)
{
warn!(id = %record.id, "resume_managed: refusing to resume: {msg}");
let _ = mgr.mark_errored(&record.id, &msg).await;
return Err(ResumeManagedError::Other(msg));
}
let adapter = build_adapter(record.runtime, mgr.tmux_driver(), None, fw_root);
let gh_env = resolve_gh_env(state, &workspace).await;
let granted = state.grant_resume_of(&record); if let Err(e) = super::auto_relaunch::spawn_resume_into_owned_pane(
&mgr,
adapter.as_ref(),
&record,
&workspace,
&gh_env,
) {
warn!(
id = %record.id,
name = %record.tmux_name,
runtime = %record.runtime.as_str(),
"resume_managed: runtime adapter spawn_resume failed: {e}"
);
state.revoke_resume_grant(granted); let msg = format!("resume spawn failed: {e}");
let _ = mgr.mark_errored(&record.id, &msg).await;
return Err(ResumeManagedError::Other(msg));
} else if let Some(msg) = super::launch_verify::record_resume_outcome(
&mgr,
&*mgr.tmux_driver(),
&record,
&workspace,
)
.await
{
return Err(ResumeManagedError::Other(msg));
}
Ok(mgr.get(id).await.unwrap_or(record))
}
#[cfg(test)]
#[path = "lifecycle_tests.rs"]
mod tests;