//! Subagent coordinator — spawns and tracks hidden child sessions. //! //! All subagent-specific types, tracking state, and orchestration logic live here. //! `MvpAgent` only wires the channel and calls `handle_subagent_request()`. //! //! ## Design //! //! - `SubagentCoordinator` owns the active-subagent map (stored as a field on `MvpAgent`). //! - `handle_subagent_request()` is a free async function that receives a //! `SubagentSpawnContext` parameter bag — it never borrows `MvpAgent`. //! - Child sessions share the parent's hunk tracker, filesystem, terminal, and env //! so that edits, bash commands, and file reads go through the same backends. #![allow(unused_imports)] use crate::extensions::notification::{SessionNotification, SessionUpdate}; use crate::session::{ self, SessionCommand, SessionHandle, SessionThread, commands::{PromptCompletionKind, PromptTurnResult as SubagentPromptTurnResult}, fs_watch::FsWatchCapabilities, info::Info as SessionInfo, }; use crate::terminal::AsyncTerminalRunner; use crate::tools::ToolContext; use agent_client_protocol as acp; use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender; use kigi_hunk_tracker::HunkTrackerHandle; use kigi_tools::implementations::kigi::task::types::*; use kigi_workspace::file_system::AsyncFileSystem; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; #[cfg(test)] use std::sync::OnceLock; use tokio::sync::{Notify, mpsc, oneshot}; use tokio_util::sync::CancellationToken; mod coordinator_lifecycle; mod coordinator_query; mod handle_request; pub(crate) use handle_request::handle_subagent_request; /// How the child session's initial context was bootstrapped. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum InitialContextSource { /// Fresh session — no inherited history. New, /// Parent history as `` (harness-only chat-prefix fork). Forked, /// Resumed from a previously completed peer subagent. The child inherits /// the source's raw transcript, tool state, and model. System prompt and /// prompt context are freshly rendered from the current agent definition. Resumed, } /// Tracks a single active subagent for progress polling and cleanup. pub(crate) struct SubagentTracker { pub subagent_id: String, pub parent_session_id: String, pub parent_prompt_id: Option, pub child_session_id: acp::SessionId, pub subagent_type: String, pub persona: Option, pub description: String, pub started_at: std::time::Instant, pub child_handle: SessionHandle, #[expect( dead_code, reason = "unused in production; remove expect when wired or delete the item" )] pub child_thread: SessionThread, pub cancel_token: CancellationToken, /// ID of the source subagent this session was resumed from. pub resumed_from: Option, /// Effective cwd used by the child session. Retained so /// `move_to_completed` can propagate it to `CompletedSubagent`. pub child_cwd: String, /// Worktree path if the child used `isolation=worktree`. pub worktree_path: Option, /// Effective model ID used by the child session. pub effective_model_id: String, /// Whether the subagent was launched with `run_in_background: true`. pub run_in_background: bool, /// Mirrors `SubagentRequest::surface_completion`. pub surface_completion: bool, /// Set when a `block=true` waiter consumed this subagent's result. pub block_waited: bool, /// Set when the model explicitly killed this subagent via the kill tool. pub explicitly_killed: bool, #[expect( dead_code, reason = "unused in production; remove expect when wired or delete the item" )] pub color: Option, } /// Captured parent-side tier inputs for resolving /// `auto_compact_threshold_percent` once the subagent's actual model id is /// known. Stored on [`SubagentSpawnContext`] so the resolver can run at /// spawn time and the per-model lookup honors the SUBAGENT's model rather /// than the parent's. #[derive(Debug, Clone, Default)] pub(crate) struct AutoCompactThresholdTiers { /// `cfg.session.auto_compact_threshold_percent` (user global TOML). pub user_session: Option, /// Subset of `cfg.config_models` whose `auto_compact_threshold_percent` /// is set, keyed by the model entry's id (the table key in /// `[model.]`). Looked up by the subagent's resolved model id at /// spawn time so user per-model overrides for the subagent's model are /// honored (not just the parent's). pub user_per_model: std::collections::HashMap, /// `cfg.remote_settings.auto_compact_threshold_percent` (GB global). pub remote_global: Option, } impl AutoCompactThresholdTiers { /// Slice the parent's `Config` into the four tier inputs we'll resolve /// against later. Only fields relevant to the auto-compact threshold /// are captured; the parent's `Config` is not held by reference. pub fn capture(cfg: &crate::agent::config::Config) -> Self { let user_per_model = cfg .config_models .iter() .filter_map(|(k, v)| v.auto_compact_threshold_percent.map(|t| (k.clone(), t))) .collect(); Self { user_session: cfg.session.auto_compact_threshold_percent, user_per_model, remote_global: cfg .remote_settings .as_ref() .and_then(|r| r.auto_compact_threshold_percent), } } } /// Everything the coordinator needs from MvpAgent to spawn a child session. /// /// Avoids passing `&MvpAgent` (which would require the coordinator to know /// about the full agent struct). Built by `MvpAgent::build_subagent_spawn_context()`. pub(crate) struct SubagentSpawnContext { /// Parent's LSP runtime — inherited via ToolContext, same as fs/terminal. pub lsp: Option>, #[expect( dead_code, reason = "unused in production; remove expect when wired or delete the item" )] pub gateway: GatewaySender, /// Parent's client-registered hooks, inherited so the subagent's tool calls hit the /// same PreToolUse gate and its events fire the same observe hooks over the parent's /// connection. Empty when the parent has none. Filled by the coordinator after the /// context is built (an async snapshot from the parent session actor). pub client_hooks: crate::extensions::hooks::ClientHooks, pub sampling_config: kigi_sampler::SamplerConfig, /// The staging auth header value propagated from the parent. Used /// when materialising subagent `SamplerConfig`s for auth-flow tracking /// and for `inject_url_derived_headers` in the construction helpers. pub alpha_test_key: Option, pub auth_method_id: acp::AuthMethodId, pub model_id: acp::ModelId, #[expect( dead_code, reason = "unused in production; remove expect when wired or delete the item" )] pub storage_mode: crate::config::StorageMode, pub auth: Option, pub parent_cwd: PathBuf, pub parent_session_id: String, pub yolo_mode: bool, pub subagent_event_tx: mpsc::UnboundedSender, pub parent_depth: u32, /// Inference idle timeout (secs), resolved from the parent's model config at spawn-context creation time. pub inference_idle_timeout_secs: u64, /// Tier inputs for resolving `auto_compact_threshold_percent` at /// spawn time — once the subagent's actual model id is known. /// Lazy because the subagent may be assigned a different model from /// the parent (via `[subagents.models]` or `AgentDefinition.model`); /// we want the resolver's per-model /// tiers to be looked up against the SUBAGENT's model, not the /// parent's. Call [`Self::resolve_auto_compact_threshold_percent`] /// once the subagent's `effective_sampling_config.model` is known. pub auto_compact_threshold_tiers: AutoCompactThresholdTiers, /// Parent's hunk tracker handle — cheap Clone, backed by an mpsc channel /// to the parent's HunkTrackerActor. Subagent edits are attributed to /// the same hunk tracker so the parent sees all file changes. pub hunk_tracker_handle: HunkTrackerHandle, /// Parent's hunk-tracking gate, inherited so a disabled parent's subagent /// also skips the per-event forward instead of paying it into a noop handle. pub hunk_tracking_enabled: bool, /// Parent's filesystem implementation (LocalFs or AcpSessionFs). /// Shared so the child reads/writes the same working tree. pub fs: Arc, /// Parent's terminal runner — shared so bash commands run in the /// same terminal environment (env vars, cwd, color settings). pub terminal: Arc, /// Parent's terminal backend — shared so background tasks, monitors, and /// scheduled tasks survive subagent exit. When `Some`, the subagent session /// reuses this backend instead of creating a new `LocalTerminalBackend`. pub parent_terminal_backend: Option>, /// Parent's notification handle for reparenting on subagent exit. /// When a subagent exits, its surviving tasks (monitors, bg commands) /// need their notification handles swapped to this so events route /// to the parent's notification bridge. pub parent_notification_handle: Option, /// Parent's scheduler handle. When `Some`, the subagent reuses the /// parent's scheduler actor so scheduled tasks survive subagent exit. pub parent_scheduler_handle: Option, /// Parent's session environment variables (.envrc + color settings). /// Shared so the child inherits the same env without re-loading. pub session_env: Arc>, /// Parent's memory config — shared so the child can access the same /// cross-session memory store. pub memory_config: Option, /// Resolved sampling config for web_search. pub web_search_config: kigi_tools::implementations::WebSearchConfig, /// Resolved config for web fetch. pub web_fetch_config: kigi_tools::implementations::kigi::web_fetch::WebFetchConfig, /// Resolved config for the deploy service. pub app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig, /// Whether the write_file tool is enabled. pub write_file_enabled: bool, /// Whether goal mode (`/goal`) is enabled. pub goal_enabled: bool, /// Whether the `ask_user_question` tool is exposed to this subagent, /// inherited from the parent session (see `build_subagent_spawn_context`). pub ask_user_question_enabled: bool, /// Parent session command channel. Carries lifecycle notifications the /// parent persists (`SubagentSpawned` / `SubagentFinished`) and — when /// goal mode is on — transient `SubagentProgress` ticks the parent /// consumes for token accounting without persisting. pub parent_cmd_tx: Option>, /// Parent session info — used to locate parent session directory. pub parent_session_info: Option, /// Subagent roles config for role-based config layering. pub subagent_roles: std::collections::HashMap, /// Subagent personas config for persona/SOUL layering. pub subagent_personas: std::collections::HashMap, /// Pre-rendered persona IO summaries for the task tool description. /// Threaded through to child sessions for recursive persona discovery. pub persona_io_summaries: Vec, /// Parent session's ChatStateHandle — used to read the actual live /// sampling config and credentials from the parent session actor (async). /// Cheap Clone (mpsc sender). `None` when parent SessionHandle not found. pub parent_chat_state: Option, /// Parent session's resolved turn limit, for subagent inheritance. pub parent_max_turns: Option, /// All available models for resolving model IDs from overrides. pub available_models: indexmap::IndexMap, /// Per-subagent model ID overrides from config.toml `[subagents.models]`. pub subagent_model_overrides: std::collections::HashMap, /// Per-subagent enable/disable toggles from config.toml `[subagents.toggle]`. /// Omitted agents default to enabled (`true`). pub subagent_toggle: std::collections::HashMap, /// Whether web search is force-disabled via `--disable-web-search`. /// Inherited from the parent session. pub disable_web_search: bool, /// Whether the runtime turn-end TodoGate is force-enabled via /// `--todo-gate`. Inherited from the parent session. pub todo_gate: bool, /// Remote settings snapshot from the parent session. Used to resolve /// `ReminderPolicy.todo_gate` (CLI > remote > default) for the subagent. pub remote_settings: Option, /// Inherited `--laziness-debug-log ` from the parent session. /// Subagent classifier fires append to the same log file. `None` /// when the parent did not enable debug mode. pub laziness_debug_log: Option, pub backend_tools_enabled: bool, /// Whether tools should respect `.gitignore` patterns. /// Inherited from the parent session. pub respect_gitignore: bool, /// Whether to enrich path-not-found errors with hints. /// Inherited from the parent session. pub path_not_found_hints: bool, /// Plugin registry for plugin-aware agent lookup. pub plugin_registry: Option>, /// Shared models manager for etag-triggered refresh. pub models_manager: crate::agent::models::ModelsManager, /// Pre-resolved file tool overrides (hashline vs standard) from the parent. /// `None` means use the standard (default) file tools. pub file_tool_overrides: Option>, /// Parent session's agent config snapshot. pub agent_config: Option, pub hook_registry: Option>, #[expect( dead_code, reason = "unused in production; remove expect when wired or delete the item" )] pub hook_workspace_root: String, pub permission_handle: Option, pub worktree_type: crate::util::config::WorktreeType, pub api_key_provider: Option, pub image_description_model: String, /// Dual-mode workspace operations handle. pub workspace_ops: kigi_workspace::WorkspaceOps, pub auth_manager: std::sync::Arc, /// The parent SessionActor's live /// `Auth401AttributionCallback`, captured at spawn time. /// Subagents inherit this so the child's `OaiCompatClient` 401 /// sites emit attribution under the parent's session id, joined /// with the parent's live `AuthManager`. /// /// Note: this is the load-bearing source of the inherited /// callback. Reading from `ctx.sampling_config.attribution_callback` /// would not work because the baseline `MvpAgent.sampling_config` /// goes through `agent/config.rs::sampling_config_for_model` /// which always sets that field to `None`. pub attribution_callback: Option, /// Parent session's agent name (e.g. "kigi"). pub parent_agent_name: Option, /// `agent_type` of the parent's current model — the harness-flavor fallback /// when `parent_agent_name` is not a recognized harness, e.g. a custom /// client profile keeps its own name but runs a strict-harness model. /// `None` when the model is not in the catalog. pub parent_model_agent_type: Option, pub allowed_subagent_types: Option>, /// Parent's MCP server configs for resolving named references in agent mcpServers. /// /// NOTE: This is a snapshot from `SessionHandle` (populated at spawn_session_actor /// time). Servers added later via `UpdateMcpServers` (managed MCPs, plugin reload) /// will not appear here. Named references only resolve against the initial config. pub parent_mcp_configs: Vec, /// Snapshot of the parent session's MCP client pool at spawn time. pub parent_mcp_pool: Option, /// Snapshot of the parent session's resolved tool schema at spawn time. /// `Some` only when a fork parent's actor answered; threaded to the child so a /// verbatim mirror-fork sends the parent's exact tool prefix for cache reuse. pub parent_tool_snapshot: Option>, /// Pre-discovered skills from the parent session, captured at spawn time. pub parent_skills: Option>, /// Parent's skills config for the child's SkillManager. pub parent_skills_config: kigi_agent::prompt::skills::SkillsConfig, /// Parent's resolved vendor-compat config, inherited by the child so its /// skills / rules / AGENTS.md discovery honors the same vendor toggles. pub parent_compat: kigi_tools::types::compat::CompatConfig, /// Shared set of IDs delivered via auto-wake synthetic prompts. pub auto_wake_delivered: Option, /// Resolved name of the `BackgroundTaskAction` tool in the parent's toolset. pub task_output_tool_name: String, /// Whether auto-wake is enabled. When `false`, subagent completions /// are not injected as synthetic prompts. pub auto_wake_enabled: bool, /// Parent's live goal-loop gate (shared `Arc`). When set, the subagent /// auto-wake synthetic prompt is suppressed so an async completion wake /// doesn't derail the parent mid-`/goal`; surfaces 2/3 still drain it. pub goal_loop_active: Arc, /// Parent's `blocking_wait_depth` (same `Arc`). A foreground spawn holds a /// `BlockingWaitGuard` on it for the blocking await so `queue_input` routes /// a prompt sent during the wait onto send-now; never for background spawns. pub parent_blocking_wait_depth: Arc, } impl SubagentSpawnContext { /// Check if a subagent is enabled via the toggle config. /// Returns `true` if the agent is not in the toggle map (default enabled). fn is_subagent_enabled(&self, name: &str) -> bool { self.subagent_toggle.get(name).copied().unwrap_or(true) } /// Resolve `auto_compact_threshold_percent` for the subagent's actual /// model id (the one selected by `resolve_subagent_sampling_config`, /// not the parent's). Walks the same precedence as the main session's /// resolver: env > user [model.] > user [session] > GB per-model /// > GB global > 85. /// /// The GB per-model tier is read from `available_models` (the same /// catalog used to pick the subagent's `SamplerConfig`); user TOML and /// GB global tiers are sourced from the parent's snapshot captured at /// spawn-context build time. pub fn resolve_auto_compact_threshold_percent(&self, subagent_model_id: &str) -> u8 { let gb_per_model = crate::agent::config::find_model_by_id(&self.available_models, subagent_model_id) .and_then(|e| e.info.auto_compact_threshold_percent); crate::util::config::resolve_auto_compact_threshold_percent_from_tiers( self.auto_compact_threshold_tiers .user_per_model .get(subagent_model_id) .copied(), self.auto_compact_threshold_tiers.user_session, gb_per_model, self.auto_compact_threshold_tiers.remote_global, ) } /// Bind a spawned subagent by the parent session's `--tools`/ /// `--disallowed-tools`/`--permission-mode` restrictions. fn apply_session_cli_overrides(&self, def: &mut kigi_agent::config::AgentDefinition) { if let Some(ref cfg) = self.agent_config { cfg.cli_agent_overrides.apply_to_subagent_definition(def); } } /// Subagent verbatim-input flag, mirroring `Config::resolve_compaction_verbatim_input` (env > config > remote settings > default `true`). pub fn resolve_compaction_verbatim_input(&self) -> bool { crate::agent::config::BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT") .config( self.agent_config .as_ref() .and_then(|c| c.features.compaction_verbatim_input), ) .feature_flag( self.remote_settings .as_ref() .and_then(|r| r.compaction_verbatim_input), ) .default(true) .resolve() .value } /// Whether a completed subagent's worktree is snapshotted into a durable ref /// and its directory deleted. Resolution mirrors the other subagent gates /// (env > config > remote settings > default). Default `false` so it ships dark; /// `managed_config.toml` `[features] subagent_worktree_snapshot` is the /// per-deployment rollout lever. pub fn resolve_subagent_worktree_snapshot_enabled(&self) -> bool { crate::agent::config::BoolFlag::env("KIGI_SUBAGENT_WORKTREE_SNAPSHOT") .config( self.agent_config .as_ref() .and_then(|c| c.features.subagent_worktree_snapshot), ) .feature_flag( self.remote_settings .as_ref() .and_then(|r| r.subagent_worktree_snapshot_enabled), ) .default(false) .resolve() .value } /// Per-tool params for the child's spawn. The ask_user_question timeout is /// session-level config, so it is resolved from the same tiers as the /// parent (requirements/env/user/managed from disk; remote from the /// parent's snapshot) and follows the session into subagents. Bash stays /// on tool defaults, as before that knob existed. pub fn resolve_tool_params_json( &self, ) -> crate::session::agent_rebuild::ResolvedToolParamsJson { let params = crate::util::config::resolve_ask_user_question_params_from_disk( self.remote_settings.as_ref(), ); crate::session::agent_rebuild::ResolvedToolParamsJson { bash: None, ask_user_question: match serde_json::to_value(params) { Ok(serde_json::Value::Object(map)) => Some(map), _ => None, }, } } } /// A completed subagent entry retained for `TaskOutputTool` polling /// and `resume_from` resolution. pub(crate) struct CompletedSubagent { pub subagent_id: String, pub parent_session_id: String, pub parent_prompt_id: Option, pub child_session_id: String, pub description: String, pub subagent_type: String, pub persona: Option, pub started_at: std::time::Instant, /// When the subagent moved to the completed map. Used for TTL eviction. pub completed_at: std::time::Instant, pub result: SubagentResult, /// ID of the source subagent this session was resumed from. pub resumed_from: Option, /// Effective cwd used by the child session (worktree path or parent cwd). /// Required to reconstruct `SessionInfo` for `resume_from`. pub child_cwd: String, /// Path to the isolated worktree, if the child used `isolation=worktree`. pub worktree_path: Option, /// Durable git ref snapshotting the worktree's working state, if captured. pub snapshot_ref: Option, /// Effective model ID used by the child session. pub effective_model_id: String, /// Set when a `block=true` waiter consumed this subagent's result. pub block_waited: bool, /// Set when the model explicitly killed this subagent via the kill tool. pub explicitly_killed: bool, } /// Lightweight entry for subagents that have been requested but are still /// initializing (creating worktree, resolving config, spawning session). /// Promoted to a full `SubagentTracker` once the child session is ready. pub(crate) struct PendingSubagent { pub subagent_id: String, pub subagent_type: String, pub description: String, pub persona: Option, pub parent_prompt_id: Option, pub parent_session_id: String, pub started_at: std::time::Instant, pub run_in_background: bool, /// Mirrors `SubagentRequest::surface_completion`. pub surface_completion: bool, #[expect( dead_code, reason = "unused in production; remove expect when wired or delete the item" )] pub color: Option, /// Spawn-future cancel token; firing it aborts the spawn at the promote /// checkpoint and emits a cancelled `SubagentFinished`. pub cancel_token: CancellationToken, } /// Parameter bag for `SubagentCoordinator::record_failure_completion`. struct FailureCompletion<'a> { subagent_id: String, subagent_type: String, description: String, parent_prompt_id: Option, parent_session_id: String, persona: Option, started_at: std::time::Instant, error: &'a str, surface_completion: bool, /// Terminal status `"cancelled"` rather than `"failed"` (pending subagent /// killed mid-initialization). cancelled: bool, } /// Shared reply slot for one live blocking `SubagentQueryRequest`. /// /// The query poll loop (`subagent_coordinator.rs`) parks the oneshot sender /// here and delivers through it; the completion handler reads it to verify — /// at auto-wake decision time — whether a blocking waiter can still receive /// the result. `Rc` is safe: the coordinator and all its users live on the /// agent's single-threaded `LocalSet` (see `spawn_local` in /// `start_subagent_coordinator`). pub(crate) type BlockWaitSlot = std::rc::Rc>>>>; /// Owns the active-subagent map and completed-result cache. /// Stored as a field on `MvpAgent`. /// /// Methods on this struct contain all the orchestration logic so /// `mvp_agent/mod.rs` stays thin. pub(crate) struct SubagentCoordinator { /// subagent_id → pending entry (initializing subagents) pending: HashMap, /// subagent_id → tracker (running subagents) active: HashMap, /// subagent_id → completed result (finished subagents, kept for polling) completed: HashMap, /// Notified each time a subagent moves to the completed state. /// Multi-wait infrastructure subscribes via `completion_notify()`. completion_notify: Arc, /// Completions buffered for between-turn delivery. pending_completions: Vec, /// Whether the model's turn is currently active. Shared with the session /// via `Arc` so it can be toggled at turn boundaries from `handle_prompt`. is_turn_active: Arc, /// Gauge of initializing + running subagents (`pending.len() + /// active.len()`), kept in sync by [`Self::sync_running_gauge`]. Read by /// [`crate::agent::activity::AgentActivity::is_busy`] to defer leader /// auto-update shutdown while subagents are in flight. running_gauge: Arc, /// subagent_id → live blocking-query reply slots. Registered together /// with `block_waited` so the completion handler can verify at decision /// time that a waiter is actually able to receive the result (a /// cancelled turn drops the receiver; the sticky flag alone would /// wrongly suppress the completion auto-wake). block_wait_slots: HashMap>, /// Prompts whose subagent usage landed session-only or failed to apply. /// A report-level incomplete signal only: not a token sink, and it never /// marks ledgers by itself (a true apply-miss marks them at fold time). /// Cleared on freeze/cancel. See AGENTS.md rule 3 for the completeness model. subagent_usage_not_applied_prompts: std::collections::HashSet, } fn tracker_to_summary(t: &SubagentTracker) -> ActiveSubagentSummary { ActiveSubagentSummary { subagent_id: t.subagent_id.clone(), subagent_type: t.subagent_type.clone(), description: t.description.clone(), elapsed_ms: t.started_at.elapsed().as_millis() as u64, } } /// Result of `SubagentCoordinator::lookup()`. /// /// Separates the synchronous map lookup from the async signals query so /// callers can drop the `RefCell` borrow before /// awaiting. pub(crate) enum SnapshotLookup { /// Subagent is finished — snapshot is fully resolved. Ready(SubagentSnapshot), /// Subagent is still running — caller must await `resolve_snapshot()` /// to populate the live progress fields. NeedsSignals(RunningSnapshotSeed), } /// Metadata extracted synchronously from an active `SubagentTracker`, /// plus a cloned `SessionSignalsHandle` for the async progress query. pub(crate) struct RunningSnapshotSeed { pub(crate) subagent_id: String, pub(crate) description: String, pub(crate) subagent_type: String, pub(crate) started_at_epoch_ms: u64, pub(crate) duration_ms: u64, pub(crate) persona: Option, pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle, } /// Resolve an `Option` into `Option`. /// /// - `None` → `None` (subagent not found). /// - `Ready` → returns the completed snapshot unchanged. /// - `NeedsSignals` → awaits `signals_handle.snapshot()` to populate the /// `Running { ... }` fields. /// /// This is the **single** async helper used by both the immediate query /// path and the `block=true` polling loop. pub(crate) async fn resolve_snapshot(lookup: Option) -> Option { match lookup? { SnapshotLookup::Ready(snap) => Some(snap), SnapshotLookup::NeedsSignals(seed) => { let signals = seed.signals_handle.snapshot().await.unwrap_or_default(); Some(SubagentSnapshot { subagent_id: seed.subagent_id, description: seed.description, subagent_type: seed.subagent_type, started_at_epoch_ms: seed.started_at_epoch_ms, duration_ms: seed.duration_ms, persona: seed.persona, status: SubagentSnapshotStatus::Running { turn_count: signals.turn_count, tool_call_count: signals.tool_call_count, tokens_used: signals.context_tokens_used, context_window_tokens: signals.context_window_tokens, context_usage_pct: signals.context_window_usage, tools_used: signals.tools_used, error_count: signals.error_count, }, }) } } } /// Check whether a resolved snapshot is still in the `Running` state. pub(crate) fn is_running(snap: &SubagentSnapshot) -> bool { matches!( snap.status, SubagentSnapshotStatus::Running { .. } | SubagentSnapshotStatus::Initializing ) } /// Seed for one running subagent returned by /// `SubagentCoordinator::list_running_for_parent()`. /// /// Includes identity fields not present on the single-item /// `RunningSnapshotSeed` (`parent_session_id`, `child_session_id`). pub(crate) struct RunningSubagentListSeed { pub(crate) subagent_id: String, pub(crate) parent_session_id: String, pub(crate) child_session_id: String, pub(crate) subagent_type: String, pub(crate) description: String, pub(crate) started_at_epoch_ms: u64, pub(crate) duration_ms: u64, pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle, } /// Resolved running subagent with live progress from `SessionSignals`. /// /// Produced by `resolve_running_list()` and consumed by the ACP extension /// layer for DTO conversion. pub(crate) struct ResolvedRunningSubagent { pub(crate) subagent_id: String, pub(crate) parent_session_id: String, pub(crate) child_session_id: String, pub(crate) subagent_type: String, pub(crate) description: String, pub(crate) started_at_epoch_ms: u64, pub(crate) duration_ms: u64, pub(crate) turn_count: u32, pub(crate) tool_call_count: u32, pub(crate) tokens_used: u64, pub(crate) context_window_tokens: u64, pub(crate) context_usage_pct: u8, pub(crate) tools_used: Vec, pub(crate) error_count: u32, } /// Resolve a list of running subagent seeds concurrently into /// `ResolvedRunningSubagent` values with live progress data. /// /// Uses `join_all` to pull signal snapshots in parallel rather than /// serially awaiting each handle. pub(crate) async fn resolve_running_list( seeds: Vec, ) -> Vec { let futs = seeds.into_iter().map(|seed| async move { let signals = seed.signals_handle.snapshot().await.unwrap_or_default(); ResolvedRunningSubagent { subagent_id: seed.subagent_id, parent_session_id: seed.parent_session_id, child_session_id: seed.child_session_id, subagent_type: seed.subagent_type, description: seed.description, started_at_epoch_ms: seed.started_at_epoch_ms, duration_ms: seed.duration_ms, turn_count: signals.turn_count, tool_call_count: signals.tool_call_count, tokens_used: signals.context_tokens_used, context_window_tokens: signals.context_window_tokens, context_usage_pct: signals.context_window_usage, tools_used: signals.tools_used, error_count: signals.error_count, } }); futures::future::join_all(futs).await } use kigi_subagent_resolution::ResumeSourceData; /// Resume provenance metadata for a subagent. #[derive(Debug, Clone, Default)] pub(crate) struct SubagentProvenance { pub(crate) fork_parent_prompt_id: Option, /// ID of the source subagent this session was resumed from. pub(crate) resumed_from: Option, } /// Convert a `std::time::Instant` to approximate epoch milliseconds. /// /// `Instant` has no absolute epoch, so we compute the offset from /// `SystemTime::now()` at the time of the call. This is approximate /// (a few ms of drift) but sufficient for display purposes. fn instant_to_epoch_ms(instant: std::time::Instant) -> u64 { let now_instant = std::time::Instant::now(); let now_system = std::time::SystemTime::now(); let elapsed_since_instant = now_instant.saturating_duration_since(instant); let system_at_instant = now_system .checked_sub(elapsed_since_instant) .unwrap_or(now_system); system_at_instant .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64 } use kigi_subagent_resolution::resolve_effective_overrides; /// Resolve the sampling config and model ID for a subagent. /// /// Subagents inherit the parent session's model by default. Only an /// EXPLICIT per-agent pin can override that inheritance; there is no global /// default model and no parent-model gate. Precedence (highest to lowest): /// /// 1. `config.toml [subagents.models].{agent_name}` override, if it /// resolves to a known model. Applies unconditionally. /// /// 2. `AgentDefinition.model = Override(id)`, if it resolves to a known /// model. Applies unconditionally. /// /// 3. Inherit the parent session's actual live sampling config (from /// `ChatStateHandle`). /// /// Both explicit pins apply regardless of which model the parent is on. If a /// pin references an unknown model it is ignored (with a `tracing::warn!`) /// and resolution falls through to the next priority. /// /// NOTE: the persona/role/runtime override (`effective_runtime.model`) is /// applied by the caller (`handle_subagent_request`) BEFORE this function /// runs, so it is not handled here. /// /// NOTE: `agent_type` and `use_concise` on the resolved model are /// intentionally ignored. Subagent prompt/toolset is always determined by /// the `AgentDefinition`, not the model. See design spec /// "Behavioral Rules section 3". async fn resolve_subagent_sampling_config( agent_name: &str, agent_model: &kigi_agent::config::ModelOverride, ctx: &SubagentSpawnContext, ) -> (kigi_sampler::SamplerConfig, acp::ModelId) { use kigi_agent::config::ModelOverride; let (parent_config, parent_mid) = read_parent_sampling_config(ctx).await; let try_pin = |model_id: &str, source: &'static str, unknown_msg: &'static str| { match resolve_model_override_to_config(model_id, ctx) { Some((config, canonical_id)) => { log_subagent_model_resolution( agent_name, source, &config, &canonical_id, &parent_config, ); Some((config, canonical_id)) } None => { tracing::warn!(agent = agent_name, model_id, "{unknown_msg}"); None } } }; if let Some(model_id) = ctx.subagent_model_overrides.get(agent_name) && let Some(resolved) = try_pin( model_id, "config_override", "Subagent model override references unknown model, falling through to inherit", ) { return resolved; } if let ModelOverride::Override(model_id) = agent_model && let Some(resolved) = try_pin( model_id, "agent_definition", "Agent definition model references unknown model, falling through to inherit", ) { return resolved; } log_subagent_model_resolution( agent_name, "inherit_parent", &parent_config, &parent_mid, &parent_config, ); (parent_config, parent_mid) } /// Resolve a subagent's effective sampling config + model id, honoring the /// model-resolution precedence (Key Decision #16). /// /// An explicit `runtime_override_model` — the goal role model or a persona /// override carried on `effective_runtime.model` — is resolved HERE, BEFORE /// [`resolve_subagent_sampling_config`] (where the user `[subagents.models]` /// pin and `AgentDefinition.model` apply). So a goal/persona override WINS /// over a user per-agent pin. An override that does not resolve to a known /// model warns and falls through to the pin path; `None` (inherit) hands /// precedence back to the pin path entirely (pin > agent-def > inherit). /// /// Extracted from `handle_subagent_request` so the precedence is unit-testable /// without spawning a child session. async fn resolve_effective_model_config( runtime_override_model: Option<&str>, subagent_type: &str, definition_model: &kigi_agent::config::ModelOverride, ctx: &SubagentSpawnContext, ) -> (kigi_sampler::SamplerConfig, acp::ModelId) { if let Some(model_id) = runtime_override_model { if let Some(resolved) = resolve_model_override_to_config(model_id, ctx) { return resolved; } tracing::warn!( model_id, "Runtime model override references unknown model, falling through" ); } resolve_subagent_sampling_config(subagent_type, definition_model, ctx).await } /// Truncate an API key to a safe prefix for logging. fn key_prefix(key: &Option) -> String { match key { Some(k) => { let len = k.len().min(8); k[..len].to_string() } None => "".to_string(), } } /// Emit a unified log entry recording which model and credentials a subagent /// resolved to, and how they compare to the parent's. fn log_subagent_model_resolution( agent_name: &str, priority: &str, resolved: &kigi_sampler::SamplerConfig, resolved_id: &acp::ModelId, parent: &kigi_sampler::SamplerConfig, ) { let child_key = key_prefix(&resolved.api_key); let parent_key = key_prefix(&parent.api_key); let keys_match = resolved.api_key == parent.api_key; kigi_log::unified_log::debug( "subagent model resolved", None, Some(serde_json::json!( { "agent" : agent_name, "priority" : priority, "child_model" : resolved_id.0.as_ref(), "child_base_url" : & resolved.base_url, "child_key_prefix" : child_key, "parent_model" : & parent.model, "parent_base_url" : & parent.base_url, "parent_key_prefix" : parent_key, "keys_match" : keys_match, } )), ); } /// Read the parent session's actual current sampling config. /// /// Prefers the live state from `ChatStateHandle` (authoritative). Falls back /// to the baseline on `SubagentSpawnContext` if the actor is unavailable. /// The returned [`acp::ModelId`] is the parent session catalog id (`ctx.model_id`), /// not the process-global default or chat-state routing slug. async fn read_parent_sampling_config( ctx: &SubagentSpawnContext, ) -> (kigi_sampler::SamplerConfig, acp::ModelId) { if let Some(ref chat_state) = ctx.parent_chat_state { if let Some(cfg) = chat_state.get_sampling_config().await { let creds = chat_state.get_credentials().await; let mut extra_headers = cfg.extra_headers; crate::agent::config::inject_url_derived_headers( &mut extra_headers, creds.alpha_test_key.as_deref(), &cfg.base_url, ); let auth_scheme = crate::agent::config::try_resolve_model_credentials(&cfg.model, None) .map(|r| r.auth_scheme) .unwrap_or_default(); let inherited = kigi_sampler::SamplerConfig { api_key: creds.api_key, base_url: cfg.base_url, model: cfg.model.clone(), max_completion_tokens: cfg.max_completion_tokens, temperature: cfg.temperature, top_p: cfg.top_p, api_backend: cfg.api_backend, auth_scheme, extra_headers, context_window: cfg.context_window.get(), reasoning_effort: cfg.reasoning_effort, force_http1: false, max_retries: None, stream_tool_calls: cfg.stream_tool_calls.unwrap_or(false), idle_timeout_secs: None, origin_client: ctx.sampling_config.origin_client.clone(), attribution_callback: ctx.attribution_callback.clone(), bearer_resolver: None, supports_backend_search: ctx .models_manager .model_supports_backend_search(ctx.model_id.0.as_ref()), compactions_remaining: ctx .models_manager .model_compactions_remaining(ctx.model_id.0.as_ref()), compaction_at_tokens: ctx .models_manager .model_compaction_at_tokens(ctx.model_id.0.as_ref()), doom_loop_recovery: ctx.sampling_config.doom_loop_recovery, header_injector: ctx.sampling_config.header_injector.clone(), }; let model_id = ctx.model_id.clone(); let global_model_id = ctx.models_manager.current_model_id(); kigi_log::unified_log::debug( "subagent read parent config (live)", None, Some(serde_json::json!( { "parent_model" : & inherited.model, "parent_base_url" : & inherited.base_url, "parent_key_prefix" : key_prefix(& inherited .api_key), "session_model_id" : model_id.0.as_ref(), "global_model_id" : global_model_id.0.as_ref(), "source" : "chat_state", } )), ); return (inherited, model_id); } tracing::warn!( "Parent chat state actor returned None for sampling config, \ falling back to spawn context baseline" ); } kigi_log::unified_log::warn( "subagent read parent config (fallback)", None, Some(serde_json::json!( { "parent_model" : & ctx.sampling_config.model, "parent_base_url" : & ctx .sampling_config.base_url, "parent_key_prefix" : key_prefix(& ctx .sampling_config.api_key), "source" : "spawn_context_baseline", "has_chat_state" : ctx.parent_chat_state.is_some(), } )), ); let mut fallback = ctx.sampling_config.clone(); fallback.supports_backend_search = ctx .models_manager .model_supports_backend_search(ctx.model_id.0.as_ref()); fallback.compactions_remaining = ctx .models_manager .model_compactions_remaining(ctx.model_id.0.as_ref()); fallback.compaction_at_tokens = ctx .models_manager .model_compaction_at_tokens(ctx.model_id.0.as_ref()); (fallback, ctx.model_id.clone()) } /// `AuthType` for a subagent: BYOK ⇒ `ApiKey` (don't overwrite the BYOK /// key); session-based ACP method ⇒ `SessionToken` (keep refresh wired); /// otherwise `ApiKey`. fn subagent_auth_type( model: Option<&crate::agent::config::ModelEntry>, auth_method_id: &acp::AuthMethodId, ) -> kigi_chat_state::AuthType { if model.is_some_and(|m| m.has_own_credentials()) { kigi_chat_state::AuthType::ApiKey } else if crate::agent::auth_method::is_session_based_method(auth_method_id) { kigi_chat_state::AuthType::SessionToken } else { kigi_chat_state::AuthType::ApiKey } } /// Resolve a model override string (config key or model ID) to a /// `(SamplerConfig, ModelId)` pair. fn resolve_model_override_to_config( model_id: &str, ctx: &SubagentSpawnContext, ) -> Option<(kigi_sampler::SamplerConfig, acp::ModelId)> { use crate::agent::config::{resolve_credentials, sampling_config_for_model}; let entry = crate::agent::config::find_model_by_id(&ctx.available_models, model_id).cloned()?; let canonical_model_id = if ctx.available_models.contains_key(model_id) { acp::ModelId::new(model_id) } else { acp::ModelId::new(entry.info().model.clone()) }; let session_key = ctx.auth.as_ref().map(|a| a.key.as_str()); let has_session_key = session_key.is_some(); let mut credentials = resolve_credentials(&entry, session_key); credentials.auth_type = subagent_auth_type(Some(&entry), &ctx.auth_method_id); let resolved_auth_type = credentials.auth_type; let config = sampling_config_for_model(&entry, credentials, ctx.alpha_test_key.clone()); kigi_log::unified_log::debug( "subagent resolve_model_override_to_config", None, Some(serde_json::json!( { "model_id" : model_id, "canonical_model" : canonical_model_id.0 .as_ref(), "resolved_model_raw" : & config.model, "base_url" : & config .base_url, "key_prefix" : key_prefix(& config.api_key), "has_own_credentials" : entry.has_own_credentials(), "has_session_key" : has_session_key, "auth_type" : format!("{:?}", resolved_auth_type), "auth_method_id" : ctx.auth_method_id.0.as_ref(), } )), ); Some((config, canonical_model_id)) } /// Leading items to preserve across compaction on resume: the System head only, so the /// resumed body (the child's own work) stays compactable. Returns 0 when there's no /// leading System; the spawn path then inserts one and bumps the prefix to 1. pub(crate) fn resume_inherited_prefix_len( conversation: &[kigi_sampling_types::conversation::ConversationItem], ) -> usize { use kigi_sampling_types::conversation::ConversationItem; conversation .iter() .take_while(|i| matches!(i, ConversationItem::System(_))) .count() } /// How a subagent's initial conversation was bootstrapped. struct InitialContext { source: InitialContextSource, copy_error: Option, prefix_len: Option, conversation: Vec, /// True only for a verbatim mirror-fork (parent items copied byte-for-byte). /// Gates sending the parent tool snapshot so the child's full request prefix /// matches the parent. A summarized-fork fallback leaves this false. verbatim_fork: bool, } /// Resume bootstrap: preserve only the System head (see `resume_inherited_prefix_len`). fn resume_initial_context( conversation: Vec, ) -> InitialContext { InitialContext { source: InitialContextSource::Resumed, copy_error: None, prefix_len: Some(resume_inherited_prefix_len(&conversation)), conversation, verbatim_fork: false, } } /// Apply `fork_filter_chat` then normalize; empty or System-only input (no /// `` produced) fails open to `New`. fn forked_initial_context( mut items: Vec, ) -> InitialContext { crate::session::storage::jsonl::fork_filter_chat(&mut items); if items.is_empty() { return InitialContext { source: InitialContextSource::New, copy_error: Some("empty parent conversation".to_string()), prefix_len: None, conversation: vec![], verbatim_fork: false, }; } let (conversation, prefix_len) = kigi_subagent_resolution::context::normalize_forked_context(items); if prefix_len < 2 { return InitialContext { source: InitialContextSource::New, copy_error: Some("no inheritable parent content".to_string()), prefix_len: None, conversation: vec![], verbatim_fork: false, }; } InitialContext { source: InitialContextSource::Forked, copy_error: None, prefix_len: Some(prefix_len), conversation, verbatim_fork: false, } } /// A verbatim mirror requires a coherent tail: the conversation must end on a /// plain assistant text response (a clean turn boundary). A dangling assistant /// (unanswered tool calls), a trailing ToolResult (mid-turn), or a trailing /// user/reasoning means the prefix would be incoherent, so the caller falls back /// to the summarized path instead of partial-trimming. fn conversation_tail_is_complete( items: &[kigi_sampling_types::conversation::ConversationItem], ) -> bool { use kigi_sampling_types::conversation::ConversationItem; matches!( items.last(), Some(ConversationItem::Assistant(a)) if a.tool_calls.is_empty() ) } /// Decide the live-fork context. /// /// Verbatim mirror (the cache-preserving path): when the parent fits the child /// window (same 80% guard as resume) AND ends at a clean turn boundary, keep the /// items BYTE-FOR-BYTE. We deliberately do NOT run `fork_filter_chat` here — its /// step 1 strips synthetic-reason user items (``s, drained /// monitor events, doom-loop warnings) that the parent actually sent and cached; /// stripping them would diverge the child prefix at the first removed item and /// cap radix reuse there. At planner spawn the conversation is between turns /// (the `/goal` user message is not yet pushed), so the tail is already complete /// and no trimming is needed; an incomplete tail falls back to summarized. /// /// Summarized fallback (oversize OR incomplete tail): the reasoning-aware /// `fork_filter_chat` drops synthetics + trims the incomplete tail, then /// `normalize_forked_context` summarizes. (This is the ONLY path that filters; /// the verbatim path never does.) /// /// Input that is empty or only `System` item(s) — before OR after filtering — /// inherited nothing, so it fails open to `New` rather than a hollow fork. fn verbatim_or_normalize_fork( items: Vec, child_context_window: u64, ) -> InitialContext { use kigi_sampling_types::conversation::ConversationItem; if !items .iter() .any(|i| !matches!(i, ConversationItem::System(_))) { return InitialContext { source: InitialContextSource::New, copy_error: Some("forked parent conversation has no inheritable content".to_string()), prefix_len: None, conversation: vec![], verbatim_fork: false, }; } let estimated_tokens = kigi_chat_state::estimate_conversation_tokens(&items); const SAFE_FORK_PERCENT: u64 = 80; let threshold = child_context_window * SAFE_FORK_PERCENT / 100; if estimated_tokens <= threshold && conversation_tail_is_complete(&items) { let prefix_len = items.len(); return InitialContext { source: InitialContextSource::Forked, copy_error: None, prefix_len: Some(prefix_len), conversation: items, verbatim_fork: true, }; } let mut filtered = items; crate::session::storage::jsonl::fork_filter_chat(&mut filtered); if !filtered .iter() .any(|i| !matches!(i, ConversationItem::System(_))) { return InitialContext { source: InitialContextSource::New, copy_error: Some("no inheritable parent content after filtering".to_string()), prefix_len: None, conversation: vec![], verbatim_fork: false, }; } let (conversation, prefix_len) = kigi_subagent_resolution::context::normalize_forked_context(filtered); InitialContext { source: InitialContextSource::Forked, copy_error: None, prefix_len: Some(prefix_len), conversation, verbatim_fork: false, } } /// `true` only when the fork actually summarized (ran `normalize_forked_context`). /// A verbatim mirror-fork inherits items as-is and never normalizes, so it reports /// `false` even though its source is `Forked`. fn fork_context_normalized(source: &InitialContextSource, verbatim_fork: bool) -> bool { matches!(source, InitialContextSource::Forked) && !verbatim_fork } /// Stamp `subagent_fork` / `forked` on the child summary (live path; disk copy already stamps). fn stamp_live_fork_session_metadata( child_session_info: &SessionInfo, parent_session_id: &str, parent_prompt_id: Option, model_id: &str, inherited_prefix_len: Option, fork_context_source: &str, ) { let dir = session::persistence::session_dir(child_session_info); if let Err(e) = std::fs::create_dir_all(&dir) { tracing::warn!( error = % e, "live fork: could not create child session dir for metadata stamp" ); return; } let summary_path = dir.join("summary.json"); let model = acp::ModelId::new(model_id); let mut summary = std::fs::read(&summary_path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .or_else(|| session::persistence::Summary::new(child_session_info, model).ok()); let Some(ref mut summary) = summary else { tracing::warn!("live fork: could not load or create child summary"); return; }; summary.session_kind = Some("subagent_fork".to_string()); summary.fork_context_source = Some(fork_context_source.to_string()); summary.parent_session_id = Some(parent_session_id.to_string()); summary.fork_parent_prompt_id = parent_prompt_id; summary.inherited_prefix_len = inherited_prefix_len; summary.forked_at = Some(chrono::Utc::now()); if let Ok(bytes) = serde_json::to_vec_pretty(summary) && let Err(e) = std::fs::write(&summary_path, bytes) { tracing::warn!(error = % e, "live fork: failed to write forked session summary"); } } enum BootstrapInitialContext { Ready(InitialContext), /// Explicit resume_from failed — abort spawn (fail closed). ResumeAbort(String), } /// Phase 3: resume (fail-closed on copy error) > fork (live then disk, fail-open) > New. /// Unresolved non-empty resume is aborted by the caller before this runs. async fn bootstrap_initial_context( request: &SubagentRequest, resume_source: Option<&ResumeSourceData>, ctx: &SubagentSpawnContext, child_session_info: &SessionInfo, child_session_dir: &std::path::Path, effective_model_id: &str, child_context_window: u64, ) -> BootstrapInitialContext { if request.fork_context && request.resume_from.is_some() { tracing::info!( subagent_id = % request.id, resume_from = ? request.resume_from, resume_resolved = resume_source.is_some(), "resume_from and fork_context both set; resolved resume wins (fail-closed on copy error, never forks)" ); } if let Some(source) = resume_source { let source_session_info = SessionInfo { id: acp::SessionId::new(source.child_session_id.clone()), cwd: source.child_cwd.clone(), }; let storage = crate::session::storage::jsonl::JsonlStorageAdapter::with_root( crate::util::kigi_home::kigi_home(), ); let copy_options = crate::session::storage::CopySessionOptions { parent_session_id: Some(source.child_session_id.clone()), new_model_id: Some(effective_model_id.to_string()), session_kind: Some("subagent_resume".to_string()), fork_context_source: Some("resumed".to_string()), fork_parent_prompt_id: request.parent_prompt_id.clone(), copy_plan_state: false, copy_plan_mode_state: false, copy_signals: false, copy_tool_state: true, fork_filter: false, ..Default::default() }; return match storage.copy_session_data_sync( &source_session_info, child_session_info, copy_options, ) { Ok(result) => { let conversation = match storage.load_chat_history_from_dir(child_session_dir) { Ok(items) if !items.is_empty() => items, Ok(_) => { return BootstrapInitialContext::ResumeAbort(format!( "Cannot resume from subagent '{}': \ copied transcript is empty", source.subagent_id, )); } Err(e) => { return BootstrapInitialContext::ResumeAbort(format!( "Cannot resume from subagent '{}': \ failed to load copied transcript: {e}", source.subagent_id, )); } }; let estimated_tokens = kigi_chat_state::estimate_conversation_tokens(&conversation); const SAFE_RESUME_PERCENT: u64 = 80; let threshold = child_context_window * SAFE_RESUME_PERCENT / 100; if estimated_tokens > threshold { return BootstrapInitialContext::ResumeAbort(format!( "Cannot resume from subagent '{}': source transcript \ (~{estimated_tokens} tokens) exceeds {SAFE_RESUME_PERCENT}% of \ the model's context window ({child_context_window} tokens). \ The source conversation is too large for the current model.", source.subagent_id, )); } tracing::info!( subagent_id = % request.id, source_subagent = % source.subagent_id, chat_messages = result.chat_messages_copied, tool_state = result .tool_state_copied, estimated_tokens, "Resume-copied source child session data into new child" ); BootstrapInitialContext::Ready(resume_initial_context(conversation)) } Err(e) => BootstrapInitialContext::ResumeAbort(format!( "Cannot resume from subagent '{}': failed to copy source session data: {e}", source.subagent_id, )), }; } if !request.fork_context { return BootstrapInitialContext::Ready(InitialContext { source: InitialContextSource::New, copy_error: None, prefix_len: None, conversation: vec![], verbatim_fork: false, }); } let live_items = match ctx.parent_chat_state.as_ref() { Some(chat_state) => { let items = chat_state.get_conversation().await; if items.is_empty() { None } else { Some(items) } } None => None, }; if let Some(items) = live_items { let ctx_out = verbatim_or_normalize_fork(items, child_context_window); tracing::info!( subagent_id = % request.id, subagent_type = % request.subagent_type, loaded_items = ctx_out.conversation.len(), source = ? ctx_out.source, verbatim = ctx_out.verbatim_fork, "Forked context from live parent_chat_state" ); if matches!(ctx_out.source, InitialContextSource::Forked) { let marker = if ctx_out.verbatim_fork { "forked_verbatim" } else { "forked_summarized" }; stamp_live_fork_session_metadata( child_session_info, &ctx.parent_session_id, request.parent_prompt_id.clone(), effective_model_id, ctx_out.prefix_len, marker, ); } return BootstrapInitialContext::Ready(ctx_out); } if let Some(ref parent_info) = ctx.parent_session_info { let storage = crate::session::storage::jsonl::JsonlStorageAdapter::with_root( crate::util::kigi_home::kigi_home(), ); let copy_options = crate::session::storage::CopySessionOptions { parent_session_id: Some(ctx.parent_session_id.clone()), new_model_id: Some(effective_model_id.to_string()), session_kind: Some("subagent_fork".to_string()), fork_context_source: Some("forked".to_string()), fork_parent_prompt_id: request.parent_prompt_id.clone(), copy_plan_state: false, copy_plan_mode_state: false, copy_signals: false, copy_tool_state: false, fork_filter: true, ..Default::default() }; return match storage.copy_session_data_sync(parent_info, child_session_info, copy_options) { Ok(result) => { tracing::info!( subagent_id = % request.id, subagent_type = % request.subagent_type, chat_messages = result.chat_messages_copied, tool_state = result .tool_state_copied, "Fork-copied parent session data into child (disk fallback)" ); let items = storage .load_chat_history_from_dir(child_session_dir) .unwrap_or_else(|e| { tracing::warn!( error = % e, "Failed to load forked chat history, starting with empty context" ); vec![] }); BootstrapInitialContext::Ready(forked_initial_context(items)) } Err(e) => { let err_msg = format!("{e}"); tracing::warn!( subagent_id = % request.id, subagent_type = % request.subagent_type, error = % e, "Failed to fork-copy parent session, falling back to fresh" ); BootstrapInitialContext::Ready(InitialContext { source: InitialContextSource::New, copy_error: Some(err_msg), prefix_len: None, conversation: vec![], verbatim_fork: false, }) } }; } tracing::warn!( subagent_id = % request.id, subagent_type = % request.subagent_type, "fork_context=true but no live parent conversation or parent_session_info; falling back to fresh" ); BootstrapInitialContext::Ready(InitialContext { source: InitialContextSource::New, copy_error: Some("parent conversation unavailable".to_string()), prefix_len: None, conversation: vec![], verbatim_fork: false, }) } /// Drop guard that moves a pending coordinator entry to completed-as-failed /// on early return. Call `defuse()` after promoting to active. struct PendingGuard<'a> { coordinator: &'a std::cell::RefCell, id: String, defused: bool, /// Specific error message set by fail_subagent before returning. /// Falls back to a generic message if unset. error: Option, } impl PendingGuard<'_> { fn defuse(mut self) { self.defused = true; } fn set_error(&mut self, error: String) { self.error = Some(error); } } impl Drop for PendingGuard<'_> { fn drop(&mut self) { if !self.defused { let error = self .error .take() .unwrap_or_else(|| "Subagent failed during initialization".to_string()); self.coordinator .borrow_mut() .move_pending_to_failed(&self.id, &error); } } } /// Resolve the effective working directory for a child session. /// /// Precedence: worktree path > `override_cwd` (non-empty) > parent cwd. The /// caller selects `override_cwd`: a resumed child inherits the source's /// effective cwd, a fresh spawn honors its `request.cwd`. fn resolve_child_cwd( worktree_path: Option<&Path>, override_cwd: Option<&str>, parent_cwd: &Path, ) -> PathBuf { worktree_path .map(Path::to_path_buf) .or_else(|| override_cwd.filter(|s| !s.is_empty()).map(PathBuf::from)) .unwrap_or_else(|| parent_cwd.to_path_buf()) } /// The cwd a resumed child inherits from its source subagent, or `None` when /// there is nothing to inherit (the caller then falls back to the parent cwd). /// /// Only non-worktree sources inherit here — worktree-backed sources are reused /// by the worktree path. The cwd is existence-checked because a source can be /// pinned into a sibling's worktree that the snapshot stack later disposes; /// resume otherwise skips cwd validation. fn resume_inherited_cwd(source: Option<&ResumeSourceData>) -> Option<&str> { let source = source?; if source.worktree_path.is_some() || source.child_cwd.is_empty() { return None; } if !Path::new(&source.child_cwd).is_dir() { tracing::warn!( source_subagent_id = % source.subagent_id, child_cwd = % source.child_cwd, "Resume source cwd no longer exists; using parent workspace" ); return None; } Some(source.child_cwd.as_str()) } /// Select the cwd override for a child: a resume inherits the source's cwd /// (never its own `request.cwd`); a fresh spawn uses `request.cwd`. fn select_override_cwd<'a>( resume_source: Option<&'a ResumeSourceData>, request_cwd: Option<&'a str>, ) -> Option<&'a str> { if resume_source.is_some() { resume_inherited_cwd(resume_source) } else { request_cwd } } /// Apply `McpInheritance` filtering to a parent MCP pool snapshot. /// /// Returns `None` for `McpInheritance::None` (no pool at all — avoids /// an empty import call downstream). For `Named`/`Except`, retains or /// removes the matching server names in-place. fn filter_pool_by_inheritance( mut pool: crate::session::mcp_servers::SharedMcpPool, inheritance: &kigi_agent::config::McpInheritance, ) -> Option { use kigi_agent::config::McpInheritance; match inheritance { McpInheritance::All => Some(pool), McpInheritance::None => None, McpInheritance::Named(names) => { let before = pool.server_names().count(); pool.retain_clients(|name| names.iter().any(|n| n == name)); tracing::debug!( before, after = pool.server_names().count(), ?names, "MCP inheritance: Named filter applied" ); Some(pool) } McpInheritance::Except(names) => { let before = pool.server_names().count(); pool.retain_clients(|name| !names.iter().any(|n| n == name)); tracing::debug!( before, after = pool.server_names().count(), ?names, "MCP inheritance: Except filter applied" ); Some(pool) } } } /// Resolve a subagent type name to its `AgentDefinition`, with the parent /// session's CLI tool/permission overrides already applied (so the spawn path /// can never obtain a definition that skips them). fn resolve_agent_definition( subagent_type: &str, ctx: &SubagentSpawnContext, ) -> Option { let mut def = kigi_agent::discovery::by_name_in_cwd_with_plugins( subagent_type, &ctx.parent_cwd, ctx.plugin_registry.as_deref(), ) .or_else(|| { ctx.agent_config.as_ref().and_then(|cfg| { cfg.cli_agents .iter() .find(|d| d.name == subagent_type) .cloned() }) })?; ctx.apply_session_cli_overrides(&mut def); Some(def) } /// Minimal per-session context for `validate_subagent_type`. /// Avoids the heavy `SubagentSpawnContext` clone on the validation hot path. #[derive(Default)] pub(crate) struct SubagentValidationContext { pub parent_cwd: PathBuf, pub plugin_registry: Option>, pub subagent_toggle: HashMap, pub allowed_subagent_types: Option>, pub cli_agent_names: Vec, } impl SubagentValidationContext { /// Toggle lookup; absent keys default to enabled. pub(crate) fn is_subagent_enabled(&self, name: &str) -> bool { self.subagent_toggle.get(name).copied().unwrap_or(true) } } /// Synchronously validate a subagent type against discovery + toggle + allow-list. /// `Unknown { available }` is sorted by `str::cmp` for stable rendering. pub(crate) fn validate_subagent_type( subagent_type: &str, ctx: &SubagentValidationContext, ) -> SubagentValidateTypeOutcome { let resolves = ctx.cli_agent_names.iter().any(|n| n == subagent_type) || kigi_agent::discovery::by_name_in_cwd_with_plugins( subagent_type, &ctx.parent_cwd, ctx.plugin_registry.as_deref(), ) .is_some(); if !resolves { let mut available: Vec = kigi_agent::discovery::all_subagents_with_plugins( &ctx.parent_cwd, &ctx.subagent_toggle, ctx.plugin_registry.as_deref(), ) .into_iter() .map(|e| e.name) .collect(); let mut seen: std::collections::HashSet = available.iter().cloned().collect(); for name in &ctx.cli_agent_names { if !ctx.is_subagent_enabled(name) { continue; } if seen.insert(name.clone()) { available.push(name.clone()); } } available.sort(); return SubagentValidateTypeOutcome::Unknown { available }; } if !ctx.is_subagent_enabled(subagent_type) { return SubagentValidateTypeOutcome::Disabled; } if let Some(ref allowed) = ctx.allowed_subagent_types && !allowed .iter() .any(|t| t.eq_ignore_ascii_case(subagent_type)) { return SubagentValidateTypeOutcome::NotAllowed { allowed: allowed.clone(), }; } SubagentValidateTypeOutcome::Ok } /// Gate an already-resolved subagent type against the `[subagents.toggle]` /// disable map and the parent's allow-list. /// /// The caller must have already confirmed the type resolves to an /// `AgentDefinition`; this checks ONLY the toggle + allow-list gates, /// returning `Ok` when the type may run and `Disabled` / `NotAllowed` /// otherwise (never `Unknown` / `ValidationUnavailable`). Shared by /// [`handle_subagent_request`] and [`describe_subagent_type`] so both apply /// identical gates. fn gate_subagent_type( subagent_type: &str, ctx: &SubagentSpawnContext, ) -> SubagentValidateTypeOutcome { if !ctx.is_subagent_enabled(subagent_type) { return SubagentValidateTypeOutcome::Disabled; } if let Some(ref allowed) = ctx.allowed_subagent_types && !allowed .iter() .any(|t| t.eq_ignore_ascii_case(subagent_type)) { return SubagentValidateTypeOutcome::NotAllowed { allowed: allowed.clone(), }; } SubagentValidateTypeOutcome::Ok } /// `false` twin: the alternate flavors re-select toolset presets and /// templates, so none is representable when the optional /// harness is compiled out. Keeps ungated call sites compiling. pub(crate) fn subagent_harness_flavor_is_representable(_agent_type: &str) -> bool { false } /// Apply the harness-dependent toolset/prompt re-selection to a resolved /// agent definition. /// /// The harness flavor (alternate vs kigi) normally follows the PARENT /// agent: `KigiOrchestrator` parents give children /// the alternate harness; the orchestrator keeps children lean, and other parents /// inherit the file-tool override (hashline vs standard). A `/goal` role may /// pass `harness_agent_type` to OVERRIDE that flavor regardless of the parent /// (so a kigi session can run an alternate-harness verifier and vice-versa); /// `None` for every non-goal spawn ⇒ the parent decides (unchanged). The base /// toolset stays role-dependent on `subagent_type` (general-purpose → /// implementer, else explorer), so the role keeps a capable toolset on the /// chosen harness. /// /// Extracted so both [`handle_subagent_request`] (real spawn) and /// [`describe_subagent_type`] (read-only probe) build the SAME `tool_config` /// for a given `(subagent_type, harness_agent_type, parent_name)` — no /// duplication. fn resolve_subagent_toolset( #[allow(unused_variables)] subagent_type: &str, harness_agent_type: Option<&str>, ctx: &SubagentSpawnContext, definition: &mut kigi_agent::config::AgentDefinition, ) { let flavor_agent = match harness_agent_type { Some(h) => Some(h), None => ctx .parent_agent_name .as_deref() .filter(|s| subagent_harness_flavor_is_representable(s)) .or(ctx.parent_model_agent_type.as_deref()), }; if flavor_agent.is_some_and(subagent_harness_flavor_is_representable) { } else if let Some(ref file_tools) = ctx.file_tool_overrides { definition.override_file_tools(file_tools.clone()); } } /// Map a resolved `ToolServerConfig` into a [`SubagentTypeSummary`]. /// /// Keys on each entry's `ToolConfig.kind` (first tool per kind wins). /// Entries with `kind: None` — `from_id`/MCP/custom tools — are SKIPPED, so /// this is NOT a byte-for-byte equivalent of the finalize-time `kind_to_name` /// map (which keys on the registry `entry.kind`); the two agree for the /// builtin goal toolsets, where every tool's kind is populated by /// `From<&T: Tool>`, but diverge for `kind: None` tools (which carry no /// capability signal anyway). The client-facing name is /// `ToolConfig::resolve_client_name(default_id)` where `default_id` is the /// unqualified tool id (the `":"` prefix on `tc.id` is stripped), /// so a `name_override` is reflected. The read/search/execute flags are what /// the per-role capability gates key on. fn summarize_tool_config( config: &kigi_tools::registry::types::ToolServerConfig, ) -> SubagentTypeSummary { use kigi_tools::types::tool::ToolKind; use std::collections::HashMap; let mut tool_names: HashMap = HashMap::new(); for tc in &config.tools { let Some(kind) = tc.kind else { continue }; let default_id = tc.id.rsplit(':').next().unwrap_or(tc.id.as_str()); let client_name = tc.resolve_client_name(default_id); tool_names.entry(kind).or_insert(client_name); } SubagentTypeSummary { can_read: tool_names.contains_key(&ToolKind::Read), can_search: tool_names.contains_key(&ToolKind::Search), can_execute: tool_names.contains_key(&ToolKind::Execute), tool_names, } } /// Describe a subagent type's resolved toolset WITHOUT spawning it. /// /// Runs the same resolution path as [`handle_subagent_request`] — /// [`resolve_agent_definition`] + [`gate_subagent_type`] + /// [`resolve_subagent_toolset`] — then summarizes the resulting /// `tool_config`. Backs the `SubagentEvent::DescribeType` drain arm; the /// parent uses the summary for the per-role capability gate and prompt /// rendering before committing a configured `/goal` `{model, agent_type}` pair. /// /// `harness_agent_type` is the `/goal`-only harness override: when set it must /// resolve to an `AgentDefinition` via this module's [`resolve_agent_definition`] /// (name-based project/plugin/builtin lookup — `by_name_in_cwd_with_plugins` + /// `BuiltinAgentName`). That is equivalent to the main session for builtin /// harness names but does NOT apply the main session's env / ACP-profile / /// strict-harness precedence. An unresolvable harness returns `Unknown` so the /// `/goal` caller fails open to the session harness; otherwise it decides the /// summarized toolset's flavor. `None` (every non-goal probe) defers the flavor /// to the parent agent (unchanged). pub(crate) fn describe_subagent_type( subagent_type: &str, harness_agent_type: Option<&str>, ctx: &SubagentSpawnContext, ) -> SubagentDescribeOutcome { if let Some(harness) = harness_agent_type && resolve_agent_definition(harness, ctx).is_none() { let mut available: Vec = kigi_agent::discovery::all_subagents_with_plugins( &ctx.parent_cwd, &ctx.subagent_toggle, ctx.plugin_registry.as_deref(), ) .into_iter() .map(|e| e.name) .collect(); available.sort(); return SubagentDescribeOutcome::Unknown { available }; } let Some(mut definition) = resolve_agent_definition(subagent_type, ctx) else { let mut available: Vec = kigi_agent::discovery::all_subagents_with_plugins( &ctx.parent_cwd, &ctx.subagent_toggle, ctx.plugin_registry.as_deref(), ) .into_iter() .map(|e| e.name) .collect(); available.sort(); return SubagentDescribeOutcome::Unknown { available }; }; match gate_subagent_type(subagent_type, ctx) { SubagentValidateTypeOutcome::Disabled => return SubagentDescribeOutcome::Disabled, SubagentValidateTypeOutcome::NotAllowed { allowed } => { return SubagentDescribeOutcome::NotAllowed { allowed }; } _ => {} } resolve_subagent_toolset(subagent_type, harness_agent_type, ctx, &mut definition); SubagentDescribeOutcome::Ok(summarize_tool_config(&definition.tool_config)) } /// Resolve a subagent's turn limit: its own `maxTurns` wins, else inherit the parent's. fn resolve_subagent_max_turns( definition_max_turns: Option, parent_max_turns: Option, ) -> Option { definition_max_turns .map(|v| v as usize) .or(parent_max_turns) } /// What to do with a resumed subagent's isolated worktree directory. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ResumeWorktreeAction { /// Directory on disk and no snapshot ref — reuse it as-is. Reuse, /// Directory gone but a snapshot ref exists — rehydrate from it. Rehydrate, /// Directory gone and no snapshot — fall back to the shared workspace. Shared, } /// Decide how to recover a resumed subagent's worktree from its on-disk state /// and whether a durable snapshot is available. Pure so the three outcomes are /// unit-testable without git/async. fn resume_worktree_action(dir_exists: bool, snapshot_ref: Option<&str>) -> ResumeWorktreeAction { if snapshot_ref.is_some() { ResumeWorktreeAction::Rehydrate } else if dir_exists { ResumeWorktreeAction::Reuse } else { ResumeWorktreeAction::Shared } } /// The parent session's working directory — the source path for a subagent /// worktree. Prefers the reconstructed `SessionInfo` cwd, falling back to /// `parent_cwd`. fn parent_source_cwd(ctx: &SubagentSpawnContext) -> std::path::PathBuf { ctx.parent_session_info .as_ref() .map(|i| std::path::PathBuf::from(&i.cwd)) .unwrap_or_else(|| std::path::PathBuf::from(&ctx.parent_cwd)) } /// Effective permission mode for a spawned subagent. Plugin agents never honor a /// non-default mode; under the pin, `bypassPermissions` downgrades to `Default` /// so a repo/profile/`--agents` def can't restore auto-approve. Caller logs it. fn resolve_subagent_permission_mode( requested: kigi_agent::config::PermissionMode, is_plugin: bool, policy_block: Option<&'static str>, ) -> kigi_agent::config::PermissionMode { use kigi_agent::config::PermissionMode; if is_plugin { return PermissionMode::Default; } if policy_block.is_some() && requested == PermissionMode::BypassPermissions { return PermissionMode::Default; } requested } /// Main repo root for a subagent's source: the durable repo a completion snapshot is transferred into and the repo a resume rehydrates from — both arms MUST resolve this identically. fn resolve_subagent_source_repo(ctx: &SubagentSpawnContext) -> std::path::PathBuf { let source_cwd = parent_source_cwd(ctx); kigi_workspace::session::git::find_main_repo_root_from_path(&source_cwd).unwrap_or(source_cwd) } enum SubagentWaitOutcome { Cancelled, TurnResult(Box>), } async fn await_subagent_turn_or_cancellation( prompt_rx: oneshot::Receiver, cancel_token: CancellationToken, ) -> SubagentWaitOutcome { tokio::select! { _ = cancel_token.cancelled() => SubagentWaitOutcome::Cancelled, turn_result = prompt_rx => SubagentWaitOutcome::TurnResult(Box::new(turn_result)), } } /// Max time a blocking `spawn_subagent` may hold the turn before it is /// auto-backgrounded (non-destructively). Env override: `KIGI_SUBAGENT_AWAIT_BUDGET_MS`. const SUBAGENT_AWAIT_BUDGET: std::time::Duration = std::time::Duration::from_secs(600); fn subagent_await_budget() -> std::time::Duration { std::env::var("KIGI_SUBAGENT_AWAIT_BUDGET_MS") .ok() .and_then(|v| v.parse::().ok()) .filter(|&ms| ms > 0) .map(std::time::Duration::from_millis) .unwrap_or(SUBAGENT_AWAIT_BUDGET) } /// Fallback for cancelled/errored paths where TurnDeltaSnapshot is unavailable. async fn signals_snapshot_counts(child_handle: &SessionHandle) -> (u32, u32) { child_handle .signals_handle .snapshot() .await .map(|s| (s.tool_call_count, s.turn_count)) .unwrap_or((0, 0)) } fn cancellation_error_message( category: Option, context: Option<&crate::session::commands::CancellationContext>, ) -> String { use kigi_file_utils::events::types::CancellationCategory; let detail = context.and_then(|ctx| { let tool = ctx.tool_name.as_deref(); let reason = ctx.reason.as_deref(); let hook = ctx.hook_name.as_deref(); match (tool, reason, hook) { (Some(t), Some(r), Some(h)) => Some(format!("{r} for tool `{t}` (hook: {h})")), (Some(t), Some(r), None) => Some(format!("{r} for tool `{t}`")), (Some(t), None, _) => Some(format!("tool `{t}`")), _ => None, } }); match (category, &detail) { (Some(CancellationCategory::PermissionRejected), Some(d)) => { format!("Subagent turn was cancelled: user rejected permission — {d}") } (Some(CancellationCategory::PermissionRejected), None) => { "Subagent turn was cancelled: user rejected a permission prompt".to_string() } (Some(CancellationCategory::PermissionCancelled), _) => { "Subagent turn was cancelled: user cancelled a permission prompt".to_string() } (Some(CancellationCategory::HookDenied), Some(d)) => { format!("Subagent turn was cancelled: hook denied — {d}") } (Some(CancellationCategory::HookDenied), None) => { "Subagent turn was cancelled: blocked by a hook".to_string() } (Some(CancellationCategory::MidTurnAbort), _) => { "Subagent turn was cancelled: aborted mid-turn".to_string() } _ => "Subagent turn was cancelled".to_string(), } } /// Whether a completed subagent should trigger an auto-wake synthetic prompt. /// /// Returns `true` only for background subagents with auto-wake enabled whose /// result has not already been consumed (via block-wait or explicit kill). /// Also suppressed while the parent's goal loop is active (mirrors the bash /// gate in `notification_bridge`); skipping the inject also skips the /// `auto_wake_delivered.insert`, leaving surfaces 2/3 free to drain it. /// `parent_channel_open` folds `inject_subagent_completed_prompt`'s own /// no-channel bail into the decision, so the `will_wake` stamped on the /// completion notification can never promise a wake the inject won't do. /// /// `cancelled` results never wake: a child dies cancelled because the user /// (or parent teardown) killed it — most acutely the Ctrl+C race where /// `ParentGone` detaches a foreground child to background moments before the /// in-flight `SubagentEvent::Cancel` lands its token, which would otherwise /// wake the model right after the user stopped everything. The completion is /// still recorded, so reminder/drain surfaces can report it later. fn should_auto_wake_subagent( run_in_background: bool, cancelled: bool, auto_wake_enabled: bool, block_waited: bool, explicitly_killed: bool, goal_loop_active: bool, parent_channel_open: bool, ) -> bool { run_in_background && !cancelled && auto_wake_enabled && !block_waited && !explicitly_killed && !goal_loop_active && parent_channel_open } /// Inject a synthetic prompt into the parent session for a completed background /// subagent, enabling auto-wake when the agent is idle. /// /// Only called for background subagents when auto-wake is enabled /// and the result has not been consumed (via block-wait or explicit kill). fn inject_subagent_completed_prompt( subagent_id: &str, result: &SubagentResult, request: &SubagentRequest, auto_wake_delivered: &Option, parent_cmd_tx: Option<&mpsc::UnboundedSender>, task_output_tool_name: &str, ) { let Some(cmd_tx) = parent_cmd_tx else { return; }; if let Some(auto_wake) = auto_wake_delivered { auto_wake.insert(subagent_id.to_string()); } let summary = SubagentCompletionSummary { subagent_id: subagent_id.to_string(), subagent_type: request.subagent_type.clone(), description: request.description.clone(), success: result.success && !result.cancelled, duration_ms: result.duration_ms, tool_calls: result.tool_calls, turns: result.turns, output: result.output.clone(), }; let message = kigi_tools::reminders::task_completion::format_subagent_completion( &summary, Some(task_output_tool_name), ); let wrapped = kigi_tools::reminders::wrap_reminder(&message); let prompt_id = format!("subagent-completed-{subagent_id}"); let (respond_to, _completion_rx) = tokio::sync::oneshot::channel(); let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(wrapped))]; let _ = cmd_tx.send(SessionCommand::Prompt { prompt_id, prompt_blocks, prompt_mode: crate::session::plan_mode::PromptMode::Agent, client_identifier: None, screen_mode: None, verbatim: true, traceparent: None, json_schema: None, send_now: false, respond_to, persist_ack: None, }); } /// Post-`insert_pending`, pre-`SubagentSpawned` failure: just send via oneshot; /// `PendingGuard::drop` handles the queue side effects. fn send_failure(request: SubagentRequest, error: &str) { let _ = request.result_tx.send(SubagentResult { success: false, error: Some(error.to_string()), ..Default::default() }); } /// Fail BEFORE `insert_pending`. Sends via oneshot; for background-mode /// requests also records a synthetic `CompletedSubagent` + emits a /// `SubagentFinished` notification (persisted + live). fn send_pre_spawn_failure( request: SubagentRequest, error: &str, coordinator: &std::cell::RefCell, ctx: &SubagentSpawnContext, gateway: &GatewaySender, ) { let SubagentRequest { id, subagent_type, description, parent_prompt_id, result_tx, run_in_background, surface_completion, .. } = request; if run_in_background { let notification_subagent_id = id.clone(); coordinator.borrow_mut().record_pre_spawn_failure( id, subagent_type, description, parent_prompt_id, ctx.parent_session_id.clone(), error, surface_completion, ); emit_subagent_notification( gateway, &ctx.parent_session_id, SessionUpdate::SubagentFinished { subagent_id: notification_subagent_id, child_session_id: String::new(), status: "failed".to_string(), error: Some(error.to_string()), tool_calls: 0, turns: 0, duration_ms: 0, tokens_used: 0, output: None, will_wake: false, }, ctx.parent_cmd_tx.as_ref(), ); } let _ = result_tx.send(SubagentResult { success: false, error: Some(error.to_string()), ..Default::default() }); } /// Post-`SubagentSpawned` failure: oneshot + `SubagentFinished` + `meta.json` update. fn fail_subagent( request: SubagentRequest, error: &str, subagent_id: &str, child_session_id: &acp::SessionId, subagent_meta_dir: &Path, gateway: &GatewaySender, parent_session_id: &str, parent_cmd_tx: Option<&mpsc::UnboundedSender>, duration_ms: u64, ) { let result = SubagentResult { success: false, error: Some(error.to_string()), subagent_id: subagent_id.to_string(), child_session_id: child_session_id.0.to_string(), duration_ms, ..Default::default() }; update_subagent_meta_completed(subagent_meta_dir, &result); emit_subagent_notification( gateway, parent_session_id, SessionUpdate::SubagentFinished { subagent_id: subagent_id.to_string(), child_session_id: child_session_id.0.to_string(), status: result.status().to_string(), error: result.error.clone(), tool_calls: 0, turns: 0, duration_ms, tokens_used: 0, output: None, will_wake: false, }, parent_cmd_tx, ); let _ = request.result_tx.send(result); } /// Tear down a subagent killed while pending: shut the idle child, dispose its /// worktree (only if `worktree_freshly_created` — a resumed subagent's aliases /// the source's and must survive), persist + emit a single cancelled /// `SubagentFinished`, move the entry to completed-as-cancelled (stays /// queryable), and deliver the result. Defuse the `PendingGuard` before calling. async fn cancel_pending_subagent_at_promote( request: SubagentRequest, child_handle: &SessionHandle, subagent_id: &str, child_session_id: &acp::SessionId, subagent_meta_dir: &Path, coordinator: &std::cell::RefCell, gateway: &GatewaySender, parent_session_id: &str, parent_cmd_tx: Option<&mpsc::UnboundedSender>, worktree_path: Option<&Path>, worktree_freshly_created: bool, duration_ms: u64, ) { let _ = child_handle.cmd_tx.send(SessionCommand::Shutdown); if worktree_freshly_created && let Some(wt_path) = worktree_path && let Err(e) = crate::session::worktree::remove_subagent_worktree(wt_path).await { tracing::warn!( subagent_id, worktree_path = % wt_path.display(), error = % e, "failed to remove pristine worktree for killed-while-pending subagent" ); } let result = SubagentResult { success: false, cancelled: true, error: Some("Subagent was cancelled".to_string()), subagent_id: subagent_id.to_string(), child_session_id: child_session_id.0.to_string(), duration_ms, ..Default::default() }; update_subagent_meta_completed(subagent_meta_dir, &result); emit_subagent_notification( gateway, parent_session_id, SessionUpdate::SubagentFinished { subagent_id: subagent_id.to_string(), child_session_id: child_session_id.0.to_string(), status: result.status().to_string(), error: result.error.clone(), tool_calls: 0, turns: 0, duration_ms, tokens_used: 0, output: None, will_wake: false, }, parent_cmd_tx, ); coordinator .borrow_mut() .move_pending_to_cancelled(subagent_id, "Subagent was cancelled"); let _ = request.result_tx.send(result); } fn emit_subagent_notification( gateway: &GatewaySender, parent_session_id: &str, update: SessionUpdate, parent_cmd_tx: Option<&mpsc::UnboundedSender>, ) { let mut meta = None; crate::util::event_id::ensure_event_id_meta(parent_session_id, &mut meta); let notification = SessionNotification { session_id: acp::SessionId::new(parent_session_id), update, meta: meta.map(serde_json::Value::Object), }; if let Some(cmd_tx) = parent_cmd_tx { let _ = cmd_tx.send(SessionCommand::XaiSessionNotification { notification: notification.clone(), }); } let params = serde_json::to_value(¬ification) .and_then(|v| serde_json::value::to_raw_value(&v)) .ok(); if let Some(params) = params { let ext_notification = acp::ExtNotification::new("kigi/session_notification", params.into()); gateway.forward_fire_and_forget(ext_notification); } } /// Progress notification emission interval. const PROGRESS_PUBLISH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); /// Change signature for the progress-publisher dedupe: /// `(turn_count, tool_call_count, context_usage_pct, error_count, tokens_used)`. /// /// `tokens_used` is part of the signature so rising child token spend always /// publishes a tick: goal token accounting (subagent records, live totals, /// and the turn-end budget check) keys off prompt token movement, which can /// climb while turn/tool counts and the coarse context-usage *percent* bucket /// stay flat. Omitting it would stall those updates until the heartbeat or an /// unrelated field moved. type ProgressSignature = (u32, u32, u8, u32, u64); /// Whether a progress tick should be emitted given the previous and current /// [`ProgressSignature`]s. Emits on any change, or when `heartbeat_due` /// forces a keep-alive after an idle gap. fn progress_tick_should_emit( prev: ProgressSignature, cur: ProgressSignature, heartbeat_due: bool, ) -> bool { cur != prev || heartbeat_due } /// Parent-actor tick channel for [`spawn_progress_publisher`]: goal token /// accounting is the only consumer, so a goal-disabled session sends no /// per-tick commands at all. fn goal_tick_cmd_tx( goal_enabled: bool, parent_cmd_tx: Option<&mpsc::UnboundedSender>, ) -> Option> { if goal_enabled { parent_cmd_tx.cloned() } else { None } } /// Spawn a background task that periodically emits `SubagentProgress` /// notifications on the parent session's notification channel. /// /// The publisher samples the child's `SessionSignalsHandle` every /// [`PROGRESS_PUBLISH_INTERVAL`] and emits a `SubagentProgress` /// notification if the subagent is still running. It stops automatically /// when `cancel_token` is cancelled (subagent completion/cancellation). /// /// When `parent_cmd_tx` is `Some`, each tick is also delivered to the /// parent `SessionActor` so goal mode can advance its live subagent /// token accounting; the actor's `SubagentProgress` arm never persists /// these ticks. /// /// Notifications are **not** persisted to JSONL — they are transient UI /// hints, not authoritative lifecycle events. The TUI can resync via /// `kigi/subagent/list_running` on reconnect. fn spawn_progress_publisher( signals_handle: crate::session::signals::SessionSignalsHandle, gateway: GatewaySender, parent_session_id: String, subagent_id: String, child_session_id: String, started_at: std::time::Instant, cancel_token: tokio_util::sync::CancellationToken, parent_cmd_tx: Option>, ) { tokio::task::spawn_local(async move { let mut interval = tokio::time::interval(PROGRESS_PUBLISH_INTERVAL); interval.tick().await; let mut last_signature: ProgressSignature = (0, 0, 0, 0, 0); let mut last_emit_at = tokio::time::Instant::now(); let heartbeat_max = tokio::time::Duration::from_secs(8); loop { tokio::select! { _ = cancel_token.cancelled() => break, _ = interval.tick() => {} } let signals = match signals_handle.snapshot().await { Some(s) => s, None => break, }; let sig: ProgressSignature = ( signals.turn_count, signals.tool_call_count, signals.context_window_usage, signals.error_count, signals.context_tokens_used, ); let heartbeat_due = last_emit_at.elapsed() >= heartbeat_max; if !progress_tick_should_emit(last_signature, sig, heartbeat_due) { continue; } last_signature = sig; last_emit_at = tokio::time::Instant::now(); let duration_ms = started_at.elapsed().as_millis() as u64; let update = SessionUpdate::SubagentProgress { subagent_id: subagent_id.clone(), parent_session_id: parent_session_id.clone(), child_session_id: child_session_id.clone(), duration_ms, turn_count: signals.turn_count, tool_call_count: signals.tool_call_count, tokens_used: signals.context_tokens_used, context_window_tokens: signals.context_window_tokens, context_usage_pct: signals.context_window_usage, tools_used: signals.tools_used, error_count: signals.error_count, }; let notification = SessionNotification { session_id: acp::SessionId::new(parent_session_id.clone()), update, meta: None, }; let params = serde_json::to_value(¬ification) .and_then(|v| serde_json::value::to_raw_value(&v)) .ok(); if let Some(ref cmd_tx) = parent_cmd_tx { let _ = cmd_tx.send(SessionCommand::XaiSessionNotification { notification }); } if let Some(params) = params { let ext_notification = acp::ExtNotification::new("kigi/session_notification", params.into()); gateway.forward_fire_and_forget(ext_notification); } } }); } #[cfg(test)] mod progress_publisher_tests { use super::{ProgressSignature, progress_tick_should_emit}; const BASE: ProgressSignature = (3, 7, 12, 0, 30_000); #[test] fn token_only_change_emits() { let cur: ProgressSignature = (3, 7, 12, 0, 45_000); assert!(progress_tick_should_emit(BASE, cur, false)); } #[test] fn unchanged_without_heartbeat_skips() { assert!(!progress_tick_should_emit(BASE, BASE, false)); } #[test] fn heartbeat_forces_emit_when_unchanged() { assert!(progress_tick_should_emit(BASE, BASE, true)); } } /// Metadata stored as `meta.json` in the child session directory. /// Links the child session back to its parent. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct SubagentMeta { pub subagent_id: String, pub parent_session_id: String, pub child_session_id: String, pub subagent_type: String, pub description: String, pub prompt: String, /// "running" | "completed" | "failed" | "cancelled" pub status: String, pub started_at: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub completed_at: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub duration_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option, #[serde(skip_serializing_if = "Option::is_none")] pub turns: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, /// Effective context source after bootstrap: "new" or "resumed". #[serde(default, skip_serializing_if = "Option::is_none")] pub effective_context_source: Option, /// True only for a summarized (normalized) fork; false for verbatim /// mirror-forks, resume, and new sessions. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub context_normalized: bool, /// Error message if fork-copy failed and fell back to fresh. #[serde(default, skip_serializing_if = "Option::is_none")] pub fork_copy_error: Option, /// Named persona applied to this subagent. #[serde(default, skip_serializing_if = "Option::is_none")] pub persona: Option, /// ID of the source subagent this session was resumed from. #[serde(default, skip_serializing_if = "Option::is_none")] pub resumed_from: Option, /// Effective cwd used by the child session. Persisted for durable /// `resume_from` reconstruction after in-memory cache eviction. #[serde(default, skip_serializing_if = "Option::is_none")] pub child_cwd: Option, /// Worktree path if the child used `isolation=worktree`. Persisted /// for durable `resume_from` reconstruction. #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree_path: Option, /// Durable git ref holding a snapshot of the child's worktree working /// state. Persisted so a deleted worktree can be rehydrated on resume. #[serde(default, skip_serializing_if = "Option::is_none")] pub snapshot_ref: Option, /// Effective model ID used by the child session. Persisted for /// durable `resume_from` identity validation. #[serde(default, skip_serializing_if = "Option::is_none")] pub effective_model_id: Option, } /// Write `meta.json`. Returns `true` on success so callers on the resume-pointer /// path can gate worktree disposal on a durable write. fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool { if let Err(e) = std::fs::create_dir_all(dir) { tracing::warn!(error = % e, "failed to create subagent meta dir"); return false; } let meta_path = dir.join("meta.json"); let json = match serde_json::to_string_pretty(meta) { Ok(json) => json, Err(e) => { tracing::warn!(error = % e, "failed to serialize subagent meta"); return false; } }; if let Err(e) = std::fs::write(&meta_path, json) { tracing::warn!(error = % e, "failed to write subagent meta"); return false; } true } /// Persist the durable worktree `snapshot_ref` into the on-disk `meta.json` /// after completion, so `resumable_source_for` can rehydrate the disposed /// worktree on resume. Returns `true` only when the ref is persisted to disk; /// any read/parse/write failure is `warn!`-logged (this is the critical resume /// pointer) so the caller keeps the worktree rather than removing it without a /// recoverable ref. Also re-asserts the terminal `status` so a failed /// `update_subagent_meta_completed` write can't leave a non-terminal record that /// `resumable_source_for` rejects after the worktree is removed. fn update_subagent_meta_snapshot_ref(dir: &Path, snapshot_ref: &str, status: &str) -> bool { let meta_path = dir.join("meta.json"); let mut meta = match std::fs::read_to_string(&meta_path) { Ok(data) => match serde_json::from_str::(&data) { Ok(meta) => meta, Err(e) => { tracing::warn!( error = % e, "failed to parse subagent meta; snapshot_ref not persisted (resume pointer lost)" ); return false; } }, Err(e) => { tracing::warn!( error = % e, "failed to read subagent meta; snapshot_ref not persisted (resume pointer lost)" ); return false; } }; meta.snapshot_ref = Some(snapshot_ref.to_string()); meta.status = status.to_string(); write_subagent_meta(dir, &meta) } fn update_subagent_meta_completed(dir: &Path, result: &SubagentResult) { let meta_path = dir.join("meta.json"); if let Ok(data) = std::fs::read_to_string(&meta_path) && let Ok(mut meta) = serde_json::from_str::(&data) { meta.status = result.status().to_string(); meta.completed_at = Some(chrono::Utc::now()); meta.duration_ms = Some(result.duration_ms); meta.tool_calls = Some(result.tool_calls); meta.turns = Some(result.turns); meta.error = result.error.clone(); write_subagent_meta(dir, &meta); } } const ORPHAN_RECONCILE_REASON: &str = "interrupted by process restart"; /// `SubagentFinished` for a force-terminated orphan; interrupt counts are zeroed. fn cancelled_orphan_finish( subagent_id: String, child_session_id: String, duration_ms: u64, ) -> SessionUpdate { SessionUpdate::SubagentFinished { subagent_id, child_session_id, status: "cancelled".to_string(), error: Some(ORPHAN_RECONCILE_REASON.to_string()), tool_calls: 0, turns: 0, duration_ms, tokens_used: 0, output: None, will_wake: false, } } /// Flip a stale `running` meta to `cancelled` and emit the missing finish. /// On meta-write failure returns `false` and skips the notify, so a reload re-heals. fn finalize_orphaned_subagent( subagent_meta_dir: &Path, mut meta: SubagentMeta, gateway: &GatewaySender, parent_cmd_tx: Option<&mpsc::UnboundedSender>, ) -> bool { let completed_at = chrono::Utc::now(); let duration_ms = (completed_at - meta.started_at).num_milliseconds().max(0) as u64; meta.status = "cancelled".to_string(); meta.completed_at = Some(completed_at); meta.duration_ms = Some(duration_ms); meta.tool_calls = Some(0); meta.turns = Some(0); meta.error = Some(ORPHAN_RECONCILE_REASON.to_string()); if !write_subagent_meta(subagent_meta_dir, &meta) { return false; } emit_subagent_notification( gateway, &meta.parent_session_id, cancelled_orphan_finish(meta.subagent_id, meta.child_session_id, duration_ms), parent_cmd_tx, ); true } /// Parse `meta_path` and return it only when it is a stale `running` orphan /// owned by `parent_session_id` and not tracked live. Malformed metas → `None`. fn running_orphan_meta( meta_path: &Path, coordinator: &SubagentCoordinator, parent_session_id: &str, ) -> Option { let data = std::fs::read_to_string(meta_path).ok()?; let meta: SubagentMeta = serde_json::from_str(&data).ok()?; if meta.status != "running" || meta.parent_session_id != parent_session_id { return None; } if coordinator.is_active_or_pending(&meta.subagent_id) { return None; } Some(meta) } /// Heal subagents stuck "Running" after a dead process: emit exactly one /// `SubagentFinished` per id, unioning two id-keyed sources (so a crash orphan /// in both heals once) — `unfinished` replayed spawns whose finish a rewind /// dropped (or a forked-in subagent with no meta), and on-disk `running` metas. /// Skipping ids still active or pending: a `running` meta → `cancelled` (unless /// the coordinator still holds its terminal result, then re-emit that); a terminal /// meta that survived a rewound finish re-emits its real outcome; a no-meta /// replayed spawn → `cancelled`. Runs after replay so the finish orders after the spawn. pub(crate) fn reconcile_orphaned_subagents( unfinished: &[(String, String)], coordinator: &SubagentCoordinator, session_dir: &Path, parent_session_id: &str, gateway: &GatewaySender, parent_cmd_tx: Option<&mpsc::UnboundedSender>, ) { let subagents_dir = session_dir.join("subagents"); let mut candidates: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for (id, child) in unfinished { candidates.insert(id.clone(), Some(child.clone())); } if let Ok(entries) = std::fs::read_dir(&subagents_dir) { for entry in entries.flatten() { let name = entry.file_name(); if running_orphan_meta( &entry.path().join("meta.json"), coordinator, parent_session_id, ) .is_some() && let Some(id) = name.to_str() { candidates.entry(id.to_string()).or_insert(None); } } } for (subagent_id, spawn_child) in candidates { if coordinator.is_active_or_pending(&subagent_id) { continue; } let subagent_dir = subagents_dir.join(&subagent_id); let meta = std::fs::read_to_string(subagent_dir.join("meta.json")) .ok() .and_then(|data| serde_json::from_str::(&data).ok()); match meta { Some(m) if m.parent_session_id != parent_session_id => {} Some(m) if m.status == "running" => { if let Some(finish) = coordinator.completed_finish(&subagent_id) { tracing::info!( subagent_id = % subagent_id, parent_session_id, "Re-emitting finish for completed subagent with a lost terminal meta write" ); emit_subagent_notification(gateway, parent_session_id, finish, parent_cmd_tx); } else { tracing::info!( subagent_id = % m.subagent_id, parent_session_id, "Reconciling orphaned subagent left running by a previous process" ); finalize_orphaned_subagent(&subagent_dir, m, gateway, parent_cmd_tx); } } Some(m) => { tracing::info!( subagent_id = % subagent_id, parent_session_id, status = % m.status, "Re-emitting finish for rewound subagent (terminal meta survived)" ); emit_subagent_notification( gateway, parent_session_id, SessionUpdate::SubagentFinished { subagent_id, child_session_id: m.child_session_id, status: m.status, error: m.error, tool_calls: m.tool_calls.unwrap_or(0), turns: m.turns.unwrap_or(0), duration_ms: m.duration_ms.unwrap_or(0), tokens_used: 0, output: None, will_wake: false, }, parent_cmd_tx, ); } None => { let Some(child_session_id) = spawn_child else { continue; }; tracing::info!( subagent_id = % subagent_id, parent_session_id, "Reconciling inherited subagent with no local meta (cancelled)" ); emit_subagent_notification( gateway, parent_session_id, cancelled_orphan_finish(subagent_id, child_session_id, 0), parent_cmd_tx, ); } } } } #[cfg(test)] mod tests;