trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
//! `#3455` "launch on main" opt-out — spawn a managed session directly in a
//! project's main checkout, with NO per-session git worktree.
//!
//! Why: extracted from `lifecycle.rs` (which is grandfathered at a frozen
//! SLOC budget on `.line-cap-allowlist.tsv`) to keep the opt-out's three
//! pieces — the registry lookup that decides whether isolation is on, the
//! pure concurrent-collision detector, and the `spawn_managed_on_main` flow —
//! in one small sibling module, matching the existing `inproject.rs` split.
//! The functions are `pub(super)` so `lifecycle::spawn_managed_routed` (the
//! only caller) can route to them; the shared spawn helpers they reuse
//! (`prepare_inproject_session`/`front_gate_or_escalate`/`resolve_gh_env`)
//! stay owned by `lifecycle` and are called back into. `write_task_md` is
//! deliberately NOT among them (#7879) — see [`spawn_managed_on_main`].
//! What: [`has_concurrent_main_checkout_session`] (pure collision detector)
//! and [`spawn_managed_on_main`] (the no-worktree spawn flow).
//!
//! #5274 made this the DEFAULT placement for a session rather than a
//! per-project opt-out, and with that the registry lookup that used to gate it
//! left this module for good. It was `worktree_enabled_for_origin` here, then
//! `worktree_isolated` (a thin `DaemonState` → [`crate::project::worktree_policy`]
//! adapter) after #4300 hoisted the policy out of `daemon::` so the `tm` CLI
//! could reach it. `spawn_managed_routed` now branches on
//! `SpawnParams::worktree` — an explicit launch-time request — because the
//! project's `worktree` flag answers a DIFFERENT question: whether the agents a
//! session dispatches get isolated. That flag and its resolver family
//! ([`crate::project::worktree_enabled_for_project`] and friends) are untouched
//! and still authoritative for agent isolation; they simply have no say in where
//! the session itself runs.
//! Test: `spawn_managed_on_main_creates_record_without_worktree` and
//! `spawn_managed_on_main_warns_on_concurrent_main_checkout_session` in
//! `lifecycle_tests.rs` (this module's tests live with `lifecycle`'s, since
//! the opt-out is a branch of the same spawn surface).

use std::sync::Arc;

use tracing::{info, warn};

use super::deployment_check::{RepairHost, ensure_deployment_complete};
use super::lifecycle::{
    SpawnParams, front_gate_or_escalate, prepare_inproject_session, resolve_gh_env,
};
use crate::daemon::state::DaemonState;
use crate::runtime::RuntimeKind;
use crate::session_manager::{ManagedSessionId, ManagedSessionState, SessionRecord};

/// Return the FIRST already-`Active` session whose cwd is EXACTLY
/// `local_path`, if any (#3455 collision detector).
///
/// Why: pure, testable core of the "two sessions on one main checkout"
/// warning. Extracted from `spawn_managed_on_main`'s inline `.any(...)` so
/// the WARN path has direct unit coverage and so the caller can name the
/// colliding session (its id + tmux name) in the log for diagnosability.
/// What: returns `Some(&record)` for the first Active record sharing
/// `local_path` as its cwd, else `None` — worktree sessions never match
/// (their cwd is a `.worktrees/<name>` slice, never the bare main checkout).
/// Test: `spawn_managed_on_main_warns_on_concurrent_main_checkout_session`.
pub(super) fn has_concurrent_main_checkout_session<'a>(
    existing: &'a [SessionRecord],
    local_path: &std::path::Path,
) -> Option<&'a SessionRecord> {
    existing
        .iter()
        .find(|r| r.state == ManagedSessionState::Active && r.cwd == local_path)
}

/// Spawn a managed session directly in the project's main checkout, with NO
/// per-session git worktree (#3455 "launch on main" opt-out).
///
/// Why: some projects have a direct-main workflow (no PR flow, no isolation
/// requirement) where the standard per-session worktree is pure friction — a
/// worktree sitting unused at a stale commit, a cwd the agent must `cd` out
/// of on every command, and a stale-state misdiagnosis risk (issue #3455).
/// `spawn_managed_routed` calls this INSTEAD of the worktree-provisioning
/// branch when the matched registered project has `worktree ==
/// Some(false)`. Otherwise this is a normal managed session in every other
/// respect — agents/skills deployed, project hooks written, tracked in the
/// session manager, front-gated, reconnectable — it just never clones a base
/// checkout or adds a worktree; the session's cwd/workspace IS `local_path`.
/// What: (1) logs (never refuses), naming the colliding session's id + tmux
/// name via [`has_concurrent_main_checkout_session`], when a second Active
/// session already targets this EXACT `local_path` — the caller only reaches
/// this function after its own reconnect check found no live session for the
/// repo, so this can only fire when `force_new` deliberately bypassed that
/// check; running two sessions against ONE main checkout is not isolated the
/// way two worktrees are (uncommitted-change races, concurrent git
/// operations), so this is the collision caveat #3455 asks to surface; (2)
/// resolves the semantic tmux name via `SessionManager::resolve_session_name`
/// with no worktree-collision predicate (there is no worktree to collide
/// with); (3) writes NO `TASK.md` — #7879: this path's workspace IS the
/// operator's live checkout, and a brief file dropped there is an untracked
/// file the session never cleans up; the task reaches the runtime through
/// `adapter.spawn` and is persisted on the record; (4) runs
/// `prepare_inproject_session` directly
/// against `local_path` (mirrors `spawn_managed_inproject`'s #1913 fix — no
/// clone step wraps this path either); (5) creates the session record with
/// `workspace_owned = false` — the operator's own checkout must NEVER be
/// auto-deleted by decommission (verified safe: `decommission_with_root`
/// only ever removes a `workspace_owned = false` path when
/// `is_session_worktree` recognises it as living under `.worktrees/`, which
/// `local_path` never does); (6) sets `source_id` when `source` has one — a
/// local-only repository (#8934) has none, and its record carries no
/// `repo_url` either; (7) front gates; (8)
/// marks `Active`; (9) spawns the runtime.
/// Test: `spawn_managed_on_main_creates_record_without_worktree`,
/// `spawn_managed_on_main_warns_on_concurrent_main_checkout_session`,
/// `spawn_managed_on_main_never_writes_task_md_into_the_checkout` in
/// `lifecycle_tests.rs`.
#[allow(clippy::too_many_arguments)]
pub(super) async fn spawn_managed_on_main(
    state: &Arc<DaemonState>,
    session_id: &ManagedSessionId,
    params: &SpawnParams,
    runtime: RuntimeKind,
    local_path: &std::path::Path,
    source: &super::local_only_spawn::SessionSource,
) -> Result<SessionRecord, String> {
    use crate::core::provisioning_stage::{ProvisioningStage, emit};

    let mgr = state.session_manager().await;

    // #3455: record (never refuse) that a second Active session is launching
    // against this EXACT main checkout. The caller's reconnect check already
    // ruled out "same repo, some live session"; reaching here with a
    // collision means `force_new` explicitly asked for a second session on the
    // identical directory.
    //
    // ADR-0048 downgraded this from `warn!` to `info!`. It was written when
    // sharing a checkout WAS the hazard — writers had nowhere else to go and
    // nothing stopped them. Both halves of that are now false: a dispatched
    // writer is granted its own worktree, and a source write or commit landing
    // in the main checkout is denied outright, for every session. Two sessions
    // reading and writing documents in one read-only checkout is the intended
    // arrangement, not a race to warn about. What stays worth recording is
    // WHICH other session shares the directory, because that is what an
    // operator asks first when diagnosing anything here — and a warning nobody
    // can act on is one an operator learns to skip past.
    {
        let existing = mgr.list().await;
        if let Some(other) = has_concurrent_main_checkout_session(&existing, local_path) {
            info!(
                path = %local_path.display(),
                colliding_session_id = %other.id,
                colliding_session_name = %other.tmux_name,
                "spawn_managed (launch-on-main): a second Active session is launching in the \
                 SAME main checkout (#3455). Writers are isolated by the ADR-0048 worktree \
                 grant and the main-checkout write boundary, so this is expected; recorded so \
                 the sharing session can be identified"
            );
        }
    }

    let reserved_name = mgr
        .resolve_session_name(
            params.name_hint.as_deref(),
            Some(&source.name),
            local_path,
            |_| false,
        )
        .await
        .map_err(|e| format!("name resolution failed for session {session_id}: {e}"))?;

    // #7879: NO `write_task_md` here. `TASK.md` belongs in a workspace tm
    // provisioned — a per-session worktree — never in the operator's live
    // checkout, which this path runs directly in. Writing it there dirtied a
    // tracked-file guard and left a stray, untracked `TASK.md` behind that the
    // session could not clean up (the record was then undecommissionable under
    // the #3764 guard). Nothing is lost: `params.task` is persisted on the
    // record below and handed to `adapter.spawn` as the runtime's opening
    // brief, which is how this path has always delivered it.
    // #8934: a local-only repository has no GitHub URL to synthesise.
    let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(local_path);
    // #7685: keep the reachability preparation resolved; the adapter reuses it.
    let memory_reachable =
        prepare_inproject_session(&fw, session_id, local_path, source.repo_url.as_deref())?;

    emit(ProvisioningStage::CreatingTmuxSession);
    let record = mgr
        .create_with_reserved_name(
            *session_id,
            reserved_name,
            params.task.clone(),
            Some(local_path.to_path_buf()),
            Some(local_path.to_path_buf()),
            source.repo_url.clone(),
            None,
            runtime,
            params.ephemeral.unwrap_or(false),
            false, // workspace_owned: the operator's own main checkout, never auto-deletable
        )
        .await
        .map_err(|e| {
            warn!(id = %session_id, "spawn_managed (launch-on-main): create failed: {e}");
            e.to_string()
        })?;

    // #8934: a local-only record has no `owner/repo` to reconnect by.
    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 (launch-on-main): set_source_id failed: {e}");
    }

    if let Some(record) =
        front_gate_or_escalate(&mgr, &record, &params.repo_url, &params.task).await?
    {
        return Ok(record);
    }

    if let Err(e) = mgr
        .set_workspace(
            session_id,
            local_path.to_path_buf(),
            ManagedSessionState::Active,
        )
        .await
    {
        warn!(id = %session_id, "spawn_managed (launch-on-main): set_workspace failed: {e}");
    }

    // #7763: hand the gate the reachability preparation resolved, so its repair
    // step does not pay a second `PROBE_TIMEOUT` against the same daemon.
    if let Err(reason) = ensure_deployment_complete(
        &fw,
        local_path,
        record.repo_url.as_deref(),
        session_id,
        RepairHost::new(memory_reachable, state.user_home()),
    ) {
        warn!(
            id = %session_id,
            "spawn_managed (launch-on-main): deployment incomplete after auto-repair \
             (non-blocking, launch proceeds): {reason}"
        );
    }

    crate::core::session_launch::spawn_workstream_label_ensure(
        record.repo_url.clone(),
        local_path.to_path_buf(),
        record.tmux_name.clone(),
    );

    emit(ProvisioningStage::LaunchingRuntime);
    let tmux_arc = mgr.tmux_driver();
    // #8233: the post-send launch check below needs the driver after the adapter
    // has taken ownership of its Arc.
    let tmux_driver = tmux_arc.clone();
    // #7685: hand the adapter what preparation resolved, so it does not re-probe.
    // #8233: the daemon's own framework root, never the process home.
    let adapter = crate::runtime::build_adapter(
        record.runtime,
        tmux_arc,
        memory_reachable,
        state.framework_root(),
    );
    let gh_env = resolve_gh_env(state, local_path).await;
    if let Err(e) = adapter.spawn(
        &record.tmux_name,
        local_path,
        &params.task,
        &record.id.to_string(),
        &gh_env,
    ) {
        warn!(
            id = %record.id,
            name = %record.tmux_name,
            "spawn_managed (launch-on-main): runtime adapter spawn failed: {e}"
        );
        let _ = mgr
            .mark_errored(&record.id, &format!("spawn failed: {e}"))
            .await;
    } else {
        // #8233: the third `adapter.spawn` site, and until now the only one with
        // no post-send check — `spawn` returning Ok means tmux took the
        // keystrokes, not that `claude` started, so this path could leave the
        // record Active with no runtime behind it until the ~60 s reaper.
        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))
}