Files
Kigi-CLI/crates/codegen/kigi-shell/src/agent/subagent/mod.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

2609 lines
113 KiB
Rust

//! 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 `<background_context>` (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<String>,
pub child_session_id: acp::SessionId,
pub subagent_type: String,
pub persona: Option<String>,
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<String>,
/// 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<PathBuf>,
/// 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<kigi_agent::config::AgentColor>,
}
/// 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<u8>,
/// Subset of `cfg.config_models` whose `auto_compact_threshold_percent`
/// is set, keyed by the model entry's id (the table key in
/// `[model.<id>]`). 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<String, u8>,
/// `cfg.remote_settings.auto_compact_threshold_percent` (GB global).
pub remote_global: Option<u8>,
}
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<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
#[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<String>,
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<crate::auth::KimiAuth>,
pub parent_cwd: PathBuf,
pub parent_session_id: String,
pub yolo_mode: bool,
pub subagent_event_tx: mpsc::UnboundedSender<SubagentEvent>,
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<dyn AsyncFileSystem>,
/// Parent's terminal runner — shared so bash commands run in the
/// same terminal environment (env vars, cwd, color settings).
pub terminal: Arc<dyn AsyncTerminalRunner>,
/// 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<Arc<dyn kigi_tools::computer::types::TerminalBackend>>,
/// 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<kigi_tools::notification::types::ToolNotificationHandle>,
/// 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<kigi_tools::implementations::kigi::scheduler::types::SchedulerHandle>,
/// Parent's session environment variables (.envrc + color settings).
/// Shared so the child inherits the same env without re-loading.
pub session_env: Arc<HashMap<String, String>>,
/// Parent's memory config — shared so the child can access the same
/// cross-session memory store.
pub memory_config: Option<crate::config::MemoryConfig>,
/// 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<mpsc::UnboundedSender<SessionCommand>>,
/// Parent session info — used to locate parent session directory.
pub parent_session_info: Option<SessionInfo>,
/// Subagent roles config for role-based config layering.
pub subagent_roles:
std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentRole>,
/// Subagent personas config for persona/SOUL layering.
pub subagent_personas:
std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentPersona>,
/// Pre-rendered persona IO summaries for the task tool description.
/// Threaded through to child sessions for recursive persona discovery.
pub persona_io_summaries: Vec<String>,
/// 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<kigi_chat_state::ChatStateHandle>,
/// Parent session's resolved turn limit, for subagent inheritance.
pub parent_max_turns: Option<usize>,
/// All available models for resolving model IDs from overrides.
pub available_models: indexmap::IndexMap<String, crate::agent::config::ModelEntry>,
/// Per-subagent model ID overrides from config.toml `[subagents.models]`.
pub subagent_model_overrides: std::collections::HashMap<String, String>,
/// Per-subagent enable/disable toggles from config.toml `[subagents.toggle]`.
/// Omitted agents default to enabled (`true`).
pub subagent_toggle: std::collections::HashMap<String, bool>,
/// 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<crate::util::config::RemoteSettings>,
/// Inherited `--laziness-debug-log <path>` 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<std::path::PathBuf>,
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<std::sync::Arc<kigi_agent::plugins::PluginRegistry>>,
/// 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<Vec<kigi_tools::registry::types::ToolConfig>>,
/// Parent session's agent config snapshot.
pub agent_config: Option<crate::agent::config::Config>,
pub hook_registry: Option<std::sync::Arc<kigi_hooks::discovery::HookRegistry>>,
#[expect(
dead_code,
reason = "unused in production; remove expect when wired or delete the item"
)]
pub hook_workspace_root: String,
pub permission_handle: Option<kigi_workspace::permission::PermissionHandle>,
pub worktree_type: crate::util::config::WorktreeType,
pub api_key_provider: Option<kigi_tools::types::SharedApiKeyProvider>,
pub image_description_model: String,
/// Dual-mode workspace operations handle.
pub workspace_ops: kigi_workspace::WorkspaceOps,
pub auth_manager: std::sync::Arc<crate::auth::AuthManager>,
/// 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<kigi_sampler::SharedAttributionCallback>,
/// Parent session's agent name (e.g. "kigi").
pub parent_agent_name: Option<String>,
/// `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<String>,
pub allowed_subagent_types: Option<Vec<String>>,
/// 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<agent_client_protocol::McpServer>,
/// Snapshot of the parent session's MCP client pool at spawn time.
pub parent_mcp_pool: Option<crate::session::mcp_servers::SharedMcpPool>,
/// 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<Vec<kigi_sampling_types::ToolSpec>>,
/// Pre-discovered skills from the parent session, captured at spawn time.
pub parent_skills: Option<Vec<kigi_tools::implementations::skills::types::SkillInfo>>,
/// 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<kigi_tools::reminders::task_completion::AutoWakeDeliveredIds>,
/// 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<std::sync::atomic::AtomicBool>,
/// 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<std::sync::atomic::AtomicUsize>,
}
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.<id>] > 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<String>,
pub child_session_id: String,
pub description: String,
pub subagent_type: String,
pub persona: Option<String>,
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<String>,
/// 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<PathBuf>,
/// Durable git ref snapshotting the worktree's working state, if captured.
pub snapshot_ref: Option<String>,
/// 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<String>,
pub parent_prompt_id: Option<String>,
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<kigi_agent::config::AgentColor>,
/// 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<String>,
parent_session_id: String,
persona: Option<String>,
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<std::cell::RefCell<Option<oneshot::Sender<Option<SubagentSnapshot>>>>>;
/// 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<String, PendingSubagent>,
/// subagent_id → tracker (running subagents)
active: HashMap<String, SubagentTracker>,
/// subagent_id → completed result (finished subagents, kept for polling)
completed: HashMap<String, CompletedSubagent>,
/// Notified each time a subagent moves to the completed state.
/// Multi-wait infrastructure subscribes via `completion_notify()`.
completion_notify: Arc<Notify>,
/// Completions buffered for between-turn delivery.
pending_completions: Vec<SubagentCompletionSummary>,
/// 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<std::sync::atomic::AtomicBool>,
/// 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<std::sync::atomic::AtomicUsize>,
/// 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<String, Vec<BlockWaitSlot>>,
/// 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<String>,
}
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<SubagentCoordinator>` 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<String>,
pub(crate) signals_handle: crate::session::signals::SessionSignalsHandle,
}
/// Resolve an `Option<SnapshotLookup>` into `Option<SubagentSnapshot>`.
///
/// - `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<SnapshotLookup>) -> Option<SubagentSnapshot> {
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<String>,
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<RunningSubagentListSeed>,
) -> Vec<ResolvedRunningSubagent> {
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<String>,
/// ID of the source subagent this session was resumed from.
pub(crate) resumed_from: Option<String>,
}
/// 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>) -> String {
match key {
Some(k) => {
let len = k.len().min(8);
k[..len].to_string()
}
None => "<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<String>,
prefix_len: Option<usize>,
conversation: Vec<kigi_sampling_types::conversation::ConversationItem>,
/// 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<kigi_sampling_types::conversation::ConversationItem>,
) -> 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
/// `<background_context>` produced) fails open to `New`.
fn forked_initial_context(
mut items: Vec<kigi_sampling_types::conversation::ConversationItem>,
) -> 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 (`<system-reminder>`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<kigi_sampling_types::conversation::ConversationItem>,
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<String>,
model_id: &str,
inherited_prefix_len: Option<usize>,
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<SubagentCoordinator>,
id: String,
defused: bool,
/// Specific error message set by fail_subagent before returning.
/// Falls back to a generic message if unset.
error: Option<String>,
}
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<crate::session::mcp_servers::SharedMcpPool> {
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<kigi_agent::config::AgentDefinition> {
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<Arc<kigi_agent::plugins::PluginRegistry>>,
pub subagent_toggle: HashMap<String, bool>,
pub allowed_subagent_types: Option<Vec<String>>,
pub cli_agent_names: Vec<String>,
}
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<String> = 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<String> = 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 `"<namespace>:"` 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<ToolKind, String> = 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<String> = 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<String> = 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<u32>,
parent_max_turns: Option<usize>,
) -> Option<usize> {
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<Result<SubagentPromptTurnResult, oneshot::error::RecvError>>),
}
async fn await_subagent_turn_or_cancellation(
prompt_rx: oneshot::Receiver<SubagentPromptTurnResult>,
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::<u64>().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<kigi_file_utils::events::types::CancellationCategory>,
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<kigi_tools::reminders::task_completion::AutoWakeDeliveredIds>,
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
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<SubagentCoordinator>,
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<SessionCommand>>,
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<SubagentCoordinator>,
gateway: &GatewaySender,
parent_session_id: &str,
parent_cmd_tx: Option<&mpsc::UnboundedSender<SessionCommand>>,
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<SessionCommand>>,
) {
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(&notification)
.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<SessionCommand>>,
) -> Option<mpsc::UnboundedSender<SessionCommand>> {
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<mpsc::UnboundedSender<SessionCommand>>,
) {
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(&notification)
.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<chrono::Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turns: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Effective context source after bootstrap: "new" or "resumed".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_context_source: Option<String>,
/// 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<String>,
/// Named persona applied to this subagent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub persona: Option<String>,
/// ID of the source subagent this session was resumed from.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumed_from: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
/// 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<String>,
}
/// 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::<SubagentMeta>(&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::<SubagentMeta>(&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<SessionCommand>>,
) -> 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<SubagentMeta> {
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<SessionCommand>>,
) {
let subagents_dir = session_dir.join("subagents");
let mut candidates: std::collections::BTreeMap<String, Option<String>> =
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::<SubagentMeta>(&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;