Synced from monorepo

Synced from monorepo

Changes:
- Workspace server: report `/ready` as failed with dwell on hub connect failure
- Refresh OIDC token for the Grok agent in the shell
- ACP terminal output recorder
- Cross-platform provider auth commands in the shell
- Default `/resume` to Grok sessions with a hint for hidden external sessions
- Resume sessions by title with `--resume`
- Limit app-builder archive size
- Data-driven tag labels for slash commands
- Doctor fixes for tmux
- Custom provider gateways and subprocess environment policy in the shell
- `/tutorial` — opt-in onboarding tour of Grok Build
- Soft and required CLI version checks in the shell
- Privacy banner env overrides survive live settings updates
- Add remote flag to override the image-edit model
- Return profile fields from auth info even when the access token is expired
- Add edit control on queued prompt rows
- Keep fail-closed policy when clearing orphans with no team
- Setting to disable the Ctrl+Space/F8 voice shortcut
- Pass `--raw` to pw-record so Linux dictation works on older PipeWire
- Validate git URLs when adding marketplace entries
- Stop shipping stale tool-doc parameter and tool names
- Re-point dashboard attach after `/fork` only when the parent was attached
- Surface Grok Computer media-generation results as file-path chunks
- Clear web background-task tray on kill and keep the task description
- Show privacy upsell banner in agent view until acted on
- Add tools-server client callback surface
- Protect persistent global hook sources

Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
grokkybara[bot] 2026-07-23 17:12:33 +00:00
commit 69f0ba880a
286 changed files with 22939 additions and 9624 deletions

View file

@ -73,7 +73,7 @@ struct ActivityInner {
/// (see module docs), and are purged whenever the list is locked.
sessions: Mutex<Vec<SessionActivityEntry>>,
/// Subagents currently initializing or running; kept in sync by
/// `SubagentCoordinator::sync_running_gauge`.
/// the shared coordinator's `running_count_changed` callback.
subagents: Arc<AtomicUsize>,
}
@ -95,8 +95,8 @@ impl AgentActivity {
});
}
/// Shared gauge of initializing + running subagents; handed to the
/// `SubagentCoordinator`, which recomputes it on every state change.
/// Shared gauge of initializing + running subagents; updated from the
/// shared coordinator's lifecycle callback.
pub(crate) fn subagent_gauge(&self) -> Arc<AtomicUsize> {
self.inner.subagents.clone()
}

View file

@ -1002,10 +1002,20 @@ pub struct CliConfig {
pub worktree_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_registry: Option<bool>,
/// User-layer value; use [`crate::util::config::resolve_minimum_version`]
/// for enforcement (semver-max across layers; managed floors can't be lowered).
/// Env `GROK_MINIMUM_VERSION`. See [`crate::util::config::VersionPolicy`] for
/// the version-policy knobs. (Unrelated to
/// `version_overrides[].maximum_version`, which gates config patches.)
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum_version: Option<String>,
/// Env `GROK_MAXIMUM_VERSION`. See [`crate::util::config::VersionPolicy`].
#[serde(skip_serializing_if = "Option::is_none")]
pub maximum_version: Option<String>,
/// Env `GROK_REQUIRED_MINIMUM_VERSION`. See [`crate::util::config::VersionPolicy`].
#[serde(skip_serializing_if = "Option::is_none")]
pub required_minimum_version: Option<String>,
/// Env `GROK_REQUIRED_MAXIMUM_VERSION`. See [`crate::util::config::VersionPolicy`].
#[serde(skip_serializing_if = "Option::is_none")]
pub required_maximum_version: Option<String>,
/// Group sessions by repo in the picker and CLI listings.
#[serde(skip_serializing_if = "Option::is_none")]
pub session_picker_grouped: Option<bool>,
@ -1317,6 +1327,19 @@ pub struct PermissionKnownKeys {
/// Verbose `[[permission.rules]]` form.
pub rules: Option<toml::Value>,
}
/// `[shell_environment_policy]` known keys, for the unrecognized-key scan only;
/// the value is parsed at spawn by [`crate::util::config::resolve_shell_env_policy`].
/// `Option<toml::Value>` (no `deny_unknown_fields`) keeps a typo a warning, not a
/// load failure, like [`PermissionKnownKeys`].
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct ShellEnvironmentPolicyKnownKeys {
pub inherit: Option<toml::Value>,
pub ignore_default_excludes: Option<toml::Value>,
pub exclude: Option<toml::Value>,
pub set: Option<toml::Value>,
pub include_only: Option<toml::Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
pub features: Features,
@ -1358,6 +1381,9 @@ pub struct Config {
pub ui: UiConfig,
#[serde(default)]
pub toolset: ShellToolsetConfig,
/// Validation only; the value is parsed at spawn by `resolve_shell_env_policy`.
#[serde(default, skip_serializing)]
pub shell_environment_policy: ShellEnvironmentPolicyKnownKeys,
#[serde(default)]
pub endpoints: EndpointsConfig,
#[serde(default)]
@ -1782,6 +1808,7 @@ impl Default for Config {
hints: None,
ui: UiConfig::default(),
toolset: ShellToolsetConfig::default(),
shell_environment_policy: ShellEnvironmentPolicyKnownKeys::default(),
endpoints,
telemetry: TelemetryConfig::default(),
session: SessionConfig::default(),
@ -2607,6 +2634,17 @@ impl Config {
)
.map(|r| r.value)
}
pub(crate) fn resolve_image_edit_model_override(&self) -> Option<String> {
resolve_string_flag(
None,
"GROK_IMAGE_EDIT_MODEL_OVERRIDE",
self.features.image_edit_model_override.as_deref(),
self.remote_settings
.as_ref()
.and_then(|s| s.image_edit_model_override.as_deref()),
)
.map(|r| r.value)
}
/// Goal mode (`/goal`) master switch. Default ON: deployments that can't
/// reach cli-chat-proxy `/v1/settings` (custom `models_base_url`, external
/// `auth_provider_command`, air-gapped proxies) never receive the
@ -3938,6 +3976,10 @@ pub struct ConfigModelOverride {
pub api_backend: Option<ApiBackend>,
#[serde(default)]
pub extra_headers: IndexMap<String, String>,
#[serde(default)]
pub query_params: IndexMap<String, String>,
#[serde(default)]
pub env_http_headers: IndexMap<String, String>,
pub context_window: Option<u64>,
/// Per-model auto-compact threshold override (0-100) from `[model.<id>]`.
/// Read directly by `resolve_auto_compact_threshold_percent`; intentionally
@ -4002,6 +4044,12 @@ impl ConfigModelOverride {
if !self.extra_headers.is_empty() {
entry.info.extra_headers = self.extra_headers.clone();
}
if !self.query_params.is_empty() {
entry.info.query_params = self.query_params.clone();
}
if !self.env_http_headers.is_empty() {
entry.info.env_http_headers = self.env_http_headers.clone();
}
if let Some(cw) = self.context_window.and_then(NonZeroU64::new) {
entry.info.context_window = cw;
}
@ -4093,6 +4141,10 @@ pub struct ModelInfo {
pub api_backend: ApiBackend,
pub auth_scheme: AuthScheme,
pub extra_headers: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub query_params: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub env_http_headers: IndexMap<String, String>,
pub context_window: NonZeroU64,
/// Per-model auto-compact threshold (0-100). `None` defers to the
/// global / default tiers in `resolve_auto_compact_threshold_percent`.
@ -4158,6 +4210,8 @@ impl ModelInfo {
api_backend: ApiBackend::default(),
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
query_params: IndexMap::new(),
env_http_headers: IndexMap::new(),
context_window: NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
@ -4193,6 +4247,8 @@ impl ModelInfo {
api_backend: entry.api_backend.clone(),
auth_scheme: entry.auth_scheme.unwrap_or_default(),
extra_headers: entry.extra_headers.clone(),
query_params: IndexMap::new(),
env_http_headers: IndexMap::new(),
context_window: entry.context_window,
auto_compact_threshold_percent: entry.auto_compact_threshold_percent,
system_prompt_label: entry.system_prompt_label.clone(),
@ -4548,6 +4604,8 @@ pub struct Features {
/// (`image_gen_model_override`) / env / default (`grok-imagine-image-quality`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_gen_model_override: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_edit_model_override: Option<String>,
/// Write file tool. `None` = defer to remote settings / env / default (true).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write_file: Option<bool>,
@ -4931,6 +4989,8 @@ pub fn resolve_aux_model_sampling_config(
api_backend: ApiBackend::Responses,
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
query_params: IndexMap::new(),
env_http_headers: IndexMap::new(),
context_window: NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
@ -5066,6 +5126,8 @@ pub fn sampling_config_for_model(
api_backend,
auth_scheme: credentials.auth_scheme,
extra_headers,
query_params: info.query_params.clone(),
env_http_headers: info.env_http_headers.clone(),
context_window: info.context_window.get(),
client_version,
reasoning_effort: info.reasoning_effort,
@ -5160,6 +5222,8 @@ fn resolve_hidden_default_web_search_sampling_config(
api_backend: ApiBackend::Responses,
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
query_params: IndexMap::new(),
env_http_headers: IndexMap::new(),
context_window: NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
@ -5776,6 +5840,7 @@ reasoning_effort = "low"
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
cwd: None,
},
);
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
@ -5856,6 +5921,7 @@ reasoning_effort = "low"
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
cwd: None,
},
);
let mut entry = test_model_entry("m", "https://litellm.example/v1", None, None, None);
@ -6014,6 +6080,35 @@ reasoning_effort = "low"
);
}
#[test]
fn shell_environment_policy_typo_does_not_fail_config() {
let cfg: toml::Value = toml::from_str(
r#"
[shell_environment_policy]
inhert = "core"
exclude = 123
"#,
)
.unwrap();
Config::new_from_toml_cfg(&cfg).expect("a policy typo must not fail the config");
}
#[test]
fn shell_environment_policy_known_keys_track_the_policy_struct() {
let xai_grok_tools::util::ShellEnvironmentPolicy {
inherit: _,
ignore_default_excludes: _,
exclude: _,
set: _,
include_only: _,
} = xai_grok_tools::util::ShellEnvironmentPolicy::default();
let ShellEnvironmentPolicyKnownKeys {
inherit: _,
ignore_default_excludes: _,
exclude: _,
set: _,
include_only: _,
} = ShellEnvironmentPolicyKnownKeys::default();
}
#[test]
fn web_search_disable_api_key_auth_swaps_first_party_key_for_session() {
let endpoints = EndpointsConfig::default();
let mut models = IndexMap::new();
@ -6088,6 +6183,7 @@ reasoning_effort = "low"
args: Some(vec!["--scope".into(), "corp".into()]),
token_ttl_secs: Some(3600),
timeout_secs: Some(10),
cwd: None,
})
);
let resolved = resolve_model_list(&cfg, None);
@ -6179,6 +6275,7 @@ reasoning_effort = "low"
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
cwd: None,
},
);
model.auth_provider = Some(provider.clone());
@ -6205,6 +6302,7 @@ reasoning_effort = "low"
args: None,
token_ttl_secs: Some(3600),
timeout_secs: None,
cwd: None,
},
);
model.auth_provider = Some(provider.clone());
@ -6249,6 +6347,7 @@ reasoning_effort = "low"
args: None,
token_ttl_secs: None,
timeout_secs: None,
cwd: None,
},
);
let resolved = resolve_model_list(&cfg, Some(prefetched));
@ -6302,6 +6401,8 @@ reasoning_effort = "low"
api_backend: ApiBackend::default(),
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
query_params: IndexMap::new(),
env_http_headers: IndexMap::new(),
context_window: NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
@ -9347,6 +9448,40 @@ reasoning_effort = "low"
}
#[test]
#[serial]
fn resolve_image_edit_model_override_remote_settings_or_config() {
unsafe { std::env::remove_var("GROK_IMAGE_EDIT_MODEL_OVERRIDE") };
let with = |config: Option<&str>, gb: Option<&str>| Config {
features: Features {
image_edit_model_override: config.map(String::from),
..Default::default()
},
remote_settings: Some(crate::util::config::RemoteSettings {
image_edit_model_override: gb.map(String::from),
..Default::default()
}),
..Default::default()
};
assert_eq!(Config::default().resolve_image_edit_model_override(), None);
assert_eq!(
with(None, Some("grok-imagine-image")).resolve_image_edit_model_override(),
Some("grok-imagine-image".to_owned())
);
assert_eq!(
with(Some("grok-imagine-image-pro"), Some("grok-imagine-image"))
.resolve_image_edit_model_override(),
Some("grok-imagine-image-pro".to_owned())
);
let gen_only = Config {
remote_settings: Some(crate::util::config::RemoteSettings {
image_gen_model_override: Some("grok-imagine-image".to_owned()),
..Default::default()
}),
..Default::default()
};
assert_eq!(gen_only.resolve_image_edit_model_override(), None);
}
#[test]
#[serial]
fn imagine_tools_disabled_gates_image_edit() {
unsafe { std::env::remove_var("GROK_IMAGE_EDIT") };
let with_list = |tools: Vec<&str>| Config {
@ -11668,6 +11803,8 @@ default = "grok-4.5"
api_backend,
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
query_params: IndexMap::new(),
env_http_headers: IndexMap::new(),
context_window: NonZeroU64::new(context_window).unwrap(),
use_concise: false,
agent_type: default_agent_type(),

View file

@ -682,6 +682,12 @@ mod tests {
extra_headers: [("x-team".to_owned(), "codegen".to_owned())]
.into_iter()
.collect(),
query_params: [("api-version".to_owned(), "2026-07-22".to_owned())]
.into_iter()
.collect(),
env_http_headers: [("x-tenant-token".to_owned(), "TENANT_TOKEN_VAR".to_owned())]
.into_iter()
.collect(),
context_window: Some(200_000),
auto_compact_threshold_percent: Some(80),
system_prompt_label: Some("label".into()),

View file

@ -13,6 +13,11 @@ pub struct ModelProviderConfig {
pub api_key: Option<String>,
pub api_backend: Option<ApiBackend>,
pub extra_headers: IndexMap<String, String>,
/// Query parameters folded into every request URL; inherited by models.
pub query_params: IndexMap<String, String>,
/// Header name to environment variable; inherited by models, resolved at
/// client build.
pub env_http_headers: IndexMap<String, String>,
pub auth_provider: Option<String>,
pub auth: Option<crate::auth::AuthProviderConfig>,
pub context_window: Option<u64>,
@ -175,6 +180,8 @@ impl ConfigModelOverride {
api_key,
api_backend,
extra_headers,
query_params,
env_http_headers,
auth_provider,
auth,
context_window,
@ -186,9 +193,16 @@ impl ConfigModelOverride {
merged.api_base_url = merged.api_base_url.or_else(|| api_base_url.clone());
merged.api_backend = merged.api_backend.or_else(|| api_backend.clone());
merged.context_window = merged.context_window.or(*context_window);
// Inherited wholesale only when the model sets none of its own.
if merged.extra_headers.is_empty() {
merged.extra_headers = extra_headers.clone();
}
if merged.query_params.is_empty() {
merged.query_params = query_params.clone();
}
if merged.env_http_headers.is_empty() {
merged.env_http_headers = env_http_headers.clone();
}
let model_sets_own_api_key = self
.api_key
.as_deref()
@ -914,4 +928,83 @@ mod tests {
assert_eq!(provider.name.as_str(), "model_provider:gateway");
assert!(!provider.is_fail_closed());
}
#[test]
fn model_inherits_provider_query_params_and_env_http_headers() {
let toml_cfg: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
api_key = "sk-provider"
[model_providers.gateway.query_params]
api-version = "2026-07-22"
[model_providers.gateway.env_http_headers]
X-Tenant-Token = "GATEWAY_TENANT_TOKEN"
[model.via-gateway]
model = "m"
model_provider = "gateway"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&toml_cfg).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("via-gateway").expect("model should exist");
assert_eq!(
model
.info
.query_params
.get("api-version")
.map(String::as_str),
Some("2026-07-22"),
"the model inherits the provider's query params"
);
assert_eq!(
model
.info
.env_http_headers
.get("X-Tenant-Token")
.map(String::as_str),
Some("GATEWAY_TENANT_TOKEN"),
"the model inherits the provider's env_http_headers mapping (unresolved names)"
);
}
#[test]
fn model_query_params_shadow_provider_query_params() {
let toml_cfg: toml::Value = toml::from_str(
r#"
[model_providers.gateway]
base_url = "https://gateway.example/v1"
api_key = "sk-provider"
[model_providers.gateway.query_params]
api-version = "provider"
[model.via-gateway]
model = "m"
model_provider = "gateway"
[model.via-gateway.query_params]
api-version = "model"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&toml_cfg).expect("config should parse");
let resolved = resolve_model_list(&cfg, None);
let model = resolved.get("via-gateway").expect("model should exist");
assert_eq!(
model
.info
.query_params
.get("api-version")
.map(String::as_str),
Some("model"),
"a model that sets its own query params inherits none of the provider's"
);
}
}

View file

@ -1834,26 +1834,30 @@ impl acp::Agent for MvpAgent {
cwd.as_path(),
remote_settings.as_ref(),
);
if let Some((parent_cmd_tx, session_cwd)) = self
.sessions
.borrow()
.get(&session_id)
.map(|h| (h.cmd_tx.clone(), h.info.cwd.clone()))
{
let orphan_parent = {
let sessions = self.sessions.borrow();
sessions
.get(&session_id)
.map(|handle| (handle.cmd_tx.clone(), handle.info.cwd.clone()))
};
if let Some((parent_cmd_tx, session_cwd)) = orphan_parent {
let session_dir = crate::session::persistence::session_dir(
&SessionInfo {
id: session_id.clone(),
cwd: session_cwd,
},
);
crate::agent::subagent::reconcile_orphaned_subagents(
&unfinished_subagents,
&self.subagent_coordinator.borrow(),
&session_dir,
session_id.0.as_ref(),
&self.gateway,
Some(&parent_cmd_tx),
);
crate::agent::subagent::reconcile_orphaned_subagents_with_backend(
&unfinished_subagents,
&xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
self.subagent_event_tx.clone(),
),
&session_dir,
session_id.0.as_ref(),
&self.gateway,
Some(&parent_cmd_tx),
)
.await;
}
let persisted_model = summary.current_model_id.clone();
let models = self.models_manager.models();
@ -2638,9 +2642,11 @@ impl acp::Agent for MvpAgent {
tool_overrides: _,
} = turn_ok;
let subagent_refs = self
.subagent_coordinator
.borrow()
.spawned_refs_for_prompt(&prompt_id);
.spawned_subagent_refs_for_prompt(
arguments.session_id.0.as_ref(),
&prompt_id,
)
.await;
let permission_events = self
.collect_permission_events(&arguments.session_id);
let turn_messages: Option<xai_chat_state::TurnCapture> = {
@ -3091,9 +3097,11 @@ impl acp::Agent for MvpAgent {
}
Err(err) => {
let subagent_refs = self
.subagent_coordinator
.borrow()
.spawned_refs_for_prompt(&prompt_id);
.spawned_subagent_refs_for_prompt(
arguments.session_id.0.as_ref(),
&prompt_id,
)
.await;
let turn_messages: Option<xai_chat_state::TurnCapture> = {
let (tx, rx) = oneshot::channel();
if handle

View file

@ -3,6 +3,7 @@
//! Inherent [`MvpAgent`] helpers (MCP/clients/gateway, settings/models, session ops, spawn).
//! Co-located child of `mvp_agent` (`use super::*`).
use super::*;
use xai_grok_tools::implementations::grok_build::task::backend::SubagentBackend;
/// `preferred` model, else catalog `current`, else first with own credentials.
fn byok_from_models(
models: &indexmap::IndexMap<String, ModelEntry>,
@ -411,9 +412,6 @@ impl MvpAgent {
/// Must be called right after construction: entries registered on the
/// constructor-created default instance are NOT migrated.
pub fn set_activity(&mut self, activity: crate::agent::activity::AgentActivity) {
self.subagent_coordinator
.borrow_mut()
.set_running_gauge(activity.subagent_gauge());
self.activity = activity;
}
/// Install the channel that fans new session cwds into the leader's
@ -1339,6 +1337,7 @@ impl MvpAgent {
image_gen_enabled: cfg.resolve_image_gen().value,
image_edit_enabled: cfg.resolve_image_edit().value,
model_override: cfg.resolve_image_gen_model_override(),
edit_model_override: cfg.resolve_image_edit_model_override(),
tier_restricted,
}
}
@ -1534,8 +1533,6 @@ impl MvpAgent {
}
let (subagent_event_tx, subagent_event_rx) = tokio::sync::mpsc::unbounded_channel();
let activity = crate::agent::activity::AgentActivity::default();
let mut subagent_coordinator = crate::agent::subagent::SubagentCoordinator::new();
subagent_coordinator.set_running_gauge(activity.subagent_gauge());
let instance = Self {
sessions: RefCell::new(HashMap::new()),
activity,
@ -1604,7 +1601,9 @@ impl MvpAgent {
model_unavailable_sessions: RefCell::new(std::collections::HashMap::new()),
subagent_event_tx,
subagent_event_rx: RefCell::new(Some(subagent_event_rx)),
subagent_coordinator: RefCell::new(subagent_coordinator),
subagent_presentation: RefCell::new(
crate::agent::subagent::SubagentPresentation::new(),
),
monitor_event_buffer: xai_grok_tools::implementations::grok_build::task::types::MonitorEventBuffer::default(),
bundle_sync_in_flight: Arc::new(std::sync::atomic::AtomicBool::new(false)),
post_unblock_jwt_retry_in_flight: Arc::new(
@ -1895,46 +1894,74 @@ impl MvpAgent {
/// Cancel a subagent by id, returning a typed outcome that backs the pager's
/// `x.ai/subagent/cancel`. Active/pending → cancelled (a finish follows);
/// already-finished → its terminal status; unknown id → `NotFound`.
pub fn cancel_subagent(
pub async fn cancel_subagent(
&self,
subagent_id: &str,
) -> xai_grok_tools::implementations::grok_build::task::types::SubagentCancelOutcome {
self.subagent_coordinator.borrow_mut().cancel_with_outcome(subagent_id)
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
self.subagent_event_tx.clone(),
)
.cancel(subagent_id)
.await
}
/// List running subagent seeds for a given parent session.
///
/// Synchronously collects seeds from the coordinator, suitable for
/// async resolution via `resolve_running_list()` after the borrow is
/// dropped.
pub(crate) fn list_running_subagents(
pub(crate) async fn list_running_subagents(
&self,
parent_session_id: &str,
) -> Vec<crate::agent::subagent::RunningSubagentListSeed> {
self.subagent_coordinator.borrow().list_running_for_parent(parent_session_id)
) -> Vec<
xai_grok_tools::implementations::grok_build::task::types::SubagentInspection,
> {
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
self.subagent_event_tx.clone(),
)
.list_running(parent_session_id)
.await
}
/// Return fork provenance metadata for a subagent.
pub(crate) fn provenance_for_subagent(
pub(crate) async fn inspect_subagent(
&self,
subagent_id: &str,
) -> crate::agent::subagent::SubagentProvenance {
self.subagent_coordinator.borrow().provenance_for(subagent_id)
) -> Option<
xai_grok_tools::implementations::grok_build::task::types::SubagentInspection,
> {
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
self.subagent_event_tx.clone(),
)
.inspect(subagent_id)
.await
}
/// Return `(parent_session_id, child_session_id)` for a subagent.
pub(crate) fn session_ids_for_subagent(
pub(crate) async fn query_subagent(
&self,
subagent_id: &str,
) -> Option<(String, String)> {
self.subagent_coordinator.borrow().session_ids_for(subagent_id)
block: bool,
timeout_ms: Option<u64>,
) -> Option<
xai_grok_tools::implementations::grok_build::task::types::SubagentSnapshot,
> {
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
self.subagent_event_tx.clone(),
)
.query(subagent_id, block, timeout_ms)
.await
}
/// Synchronous lookup of a single subagent by ID.
///
/// Returns `Option<SnapshotLookup>` which must be resolved
/// asynchronously via `resolve_snapshot()` after the borrow is dropped.
pub(crate) fn lookup_subagent(
pub(super) async fn spawned_subagent_refs_for_prompt(
&self,
subagent_id: &str,
) -> Option<crate::agent::subagent::SnapshotLookup> {
self.subagent_coordinator.borrow().lookup(subagent_id)
parent_session_id: &str,
prompt_id: &str,
) -> Vec<crate::upload::trace::SubagentSpawnedRef> {
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
self.subagent_event_tx.clone(),
)
.spawned_refs_for_prompt(parent_session_id, prompt_id)
.await
.into_iter()
.map(|child| crate::upload::trace::SubagentSpawnedRef {
subagent_id: child.subagent_id,
child_session_id: child.child_session_id,
subagent_type: child.subagent_type,
description: child.description,
persona: child.persona,
resumed_from: child.resumed_from,
})
.collect()
}
/// List all background tasks for a session.
/// Routes through the session's tool bridge to the TerminalBackend.
@ -3153,19 +3180,19 @@ impl MvpAgent {
})?;
tool_ctx.subagent_event_tx = Some(self.subagent_event_tx.clone());
tool_ctx.synthetic_trace_tx = self
.subagent_coordinator
.subagent_presentation
.borrow()
.synthetic_trace_tx
.clone();
if let Some(ref shared) = tool_ctx.synthetic_trace_tx_shared {
*shared.lock().unwrap_or_else(|e| e.into_inner()) = self
.subagent_coordinator
.subagent_presentation
.borrow()
.synthetic_trace_tx
.clone();
}
tool_ctx.is_turn_active = Some(
self.subagent_coordinator.borrow().turn_active_flag(),
self.subagent_presentation.borrow().turn_active_flag(),
);
tool_ctx.monitor_event_buffer = Some(self.monitor_event_buffer.clone());
tool_ctx.subagent_depth = 0;

View file

@ -500,6 +500,7 @@ struct SettingsUpdateNotification {
privacy_banner_reshow_days: Option<u64>,
session_picker_grouped: Option<bool>,
tips: Option<Vec<String>>,
slash_command_tags: Option<std::collections::BTreeMap<String, String>>,
announcements: Option<Vec<xai_grok_announcements::RemoteAnnouncement>>,
gate_message: Option<String>,
gate_url: Option<String>,
@ -797,9 +798,8 @@ pub struct MvpAgent {
>,
>,
>,
/// Active subagent tracking — owns all subagent lifecycle state.
/// LEADER-SAFE(per-session): keyed by subagent_id, no cross-session iteration.
subagent_coordinator: RefCell<crate::agent::subagent::SubagentCoordinator>,
/// Shell-only presentation state; lifecycle lives in the channel actor.
subagent_presentation: RefCell<crate::agent::subagent::SubagentPresentation>,
/// Shared buffer for mid-turn monitor event notifications.
/// Pushed by the `InjectNotification` handler when a turn is active and the
/// notification has `Next` priority. Drained by the session turn loop
@ -1687,6 +1687,7 @@ impl MvpAgent {
block_waited: false,
explicitly_killed: false,
owner_session_id: None,
description: None,
};
let notification = crate::extensions::notification::SessionNotification {
session_id: session_id.clone(),
@ -2092,6 +2093,7 @@ impl MvpAgent {
.and_then(|s| s.privacy_banner_reshow_days),
session_picker_grouped: rs.and_then(|s| s.session_picker_grouped),
tips: rs.and_then(|s| s.tips.clone()),
slash_command_tags: rs.and_then(|s| s.slash_command_tags.clone()),
announcements: rs.and_then(|s| s.announcements.clone()),
gate_message: rs.and_then(|s| s.gate_message.clone()),
gate_url: rs.and_then(|s| s.gate_url.clone()),

View file

@ -44,9 +44,11 @@ impl MvpAgent {
if let Some(ops) = self.workspace_ops.borrow().as_ref() {
ops.end_local_session(id.0.as_ref());
}
self.subagent_coordinator
.borrow_mut()
.discard_pending_completions_for(id.0.as_ref());
let _ = self
.subagent_event_tx
.send(xai_grok_tools::implementations::grok_build::task::types::SubagentEvent::DiscardSessionCompletions {
parent_session_id: id.0.to_string(),
});
}
/// Get-or-create the per-session dispatch lock (see
/// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`.
@ -406,10 +408,14 @@ impl MvpAgent {
.unwrap_or(true)
}
/// Entry counts for every collection [`Self::remove_session`] drains,
/// plus the workspace binding and subagent maps.
pub(crate) fn registry_snapshot(&self) -> RegistrySnapshot {
let (subagent_pending, subagent_active, subagent_completed) =
self.subagent_coordinator.borrow().registry_snapshot();
/// plus workspace bindings and shared coordinator state.
pub(crate) async fn registry_snapshot(&self) -> RegistrySnapshot {
let subagents =
xai_grok_tools::implementations::grok_build::task::backend::ChannelBackend::new(
self.subagent_event_tx.clone(),
)
.registry_counts()
.await;
RegistrySnapshot {
sessions: self.sessions.borrow().len(),
session_threads: self.session_threads.borrow().len(),
@ -420,9 +426,9 @@ impl MvpAgent {
session_live_state: self.session_live_state.borrow().len(),
session_index_claims: self.session_index_claims.borrow().len(),
require_gateway_sessions: self.require_gateway_sessions.borrow().len(),
subagent_pending,
subagent_active,
subagent_completed,
subagent_pending: subagents.pending,
subagent_active: subagents.active,
subagent_completed: subagents.completed,
workspace_bindings: self
.workspace_ops
.borrow()

View file

@ -1,348 +1,204 @@
//! Subagent coordinator drain task and spawn-context construction for [`MvpAgent`].
//! Co-located child of `mvp_agent` (`use super::*`); tested by `tests/subagent_spawn_context_tests.rs`.
//! Shell runner adapter and spawn-context construction for [`MvpAgent`].
//! The shared coordinator actor lives in `xai-grok-tools`; this module plugs
//! its `!Send` local-session runner into `spawn_local`.
use super::*;
use crate::session::repo_changes::UploadMethod;
struct ShellChildRunner {
agent_ref: LocalRef<MvpAgent>,
}
impl xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunner
for ShellChildRunner
{
type Control = crate::agent::subagent::ShellChildRuntime;
type CompletionData = crate::agent::subagent::ShellCompletionData;
type RunFuture = xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture<
xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunOutput<
Self::CompletionData,
>,
>;
type ValidateFuture =
xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture<
xai_grok_tools::implementations::grok_build::task::types::SubagentValidateTypeOutcome,
>;
type DescribeFuture =
xai_grok_tools::implementations::grok_build::task::coordinator::LocalBoxFuture<
xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome,
>;
fn run(
&self,
run: xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunRequest<
Self::Control,
>,
) -> Self::RunFuture {
let agent_ref = self.agent_ref.clone();
Box::pin(async move {
let xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunRequest {
request,
cancellation,
reporter,
} = run;
let this = agent_ref.get();
let parent_sid = request.parent_session_id.clone();
let Some(mut ctx) = this.try_build_subagent_spawn_context(&parent_sid) else {
tracing::warn!(
parent_session_id = %parent_sid,
subagent_id = %request.id,
"Spawn for unknown or evicted parent session"
);
return xai_grok_tools::implementations::grok_build::task::coordinator::ChildRunOutput {
result: xai_grok_tools::implementations::grok_build::task::types::SubagentResult {
success: false,
error: Some(
"Parent session not found (evicted or torn down); cannot spawn subagent."
.to_owned(),
),
subagent_id: request.id.clone(),
child_session_id: request.id,
..Default::default()
},
completion_data: Default::default(),
snapshot_ref: None,
};
};
let parent_handle = {
let parent_sid = acp::SessionId::new(parent_sid);
this.sessions.borrow().get(&parent_sid).cloned()
};
if let Some(handle) = parent_handle {
ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await;
ctx.client_hooks = handle.snapshot_client_hooks().await;
let definitions = handle.snapshot_tool_definitions().await;
ctx.parent_tool_definitions = (!definitions.is_empty()).then_some(definitions);
}
crate::agent::subagent::run_shell_child(
request,
ctx,
cancellation,
reporter,
&this.gateway,
)
.await
})
}
fn validate_type(
&self,
subagent_type: String,
parent_session_id: String,
) -> Self::ValidateFuture {
let agent_ref = self.agent_ref.clone();
Box::pin(async move {
let this = agent_ref.get();
let ctx = this.build_subagent_validation_context(&parent_session_id);
crate::agent::subagent::validate_subagent_type(&subagent_type, &ctx)
})
}
fn describe_type(
&self,
subagent_type: String,
harness_agent_type: Option<String>,
parent_session_id: String,
) -> Self::DescribeFuture {
let agent_ref = self.agent_ref.clone();
Box::pin(async move {
let this = agent_ref.get();
match this.try_build_subagent_spawn_context(&parent_session_id) {
Some(ctx) => crate::agent::subagent::describe_subagent_type(
&subagent_type,
harness_agent_type.as_deref(),
&ctx,
),
None => {
tracing::warn!(
parent_session_id,
subagent_type,
"DescribeType for unknown/evicted parent session, replying Unavailable",
);
xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome::Unavailable
}
}
})
}
fn on_completed(
&self,
completion: xai_grok_tools::implementations::grok_build::task::coordinator::ChildCompletion<
Self::CompletionData,
>,
) {
let gateway = self.agent_ref.get().gateway.clone();
crate::agent::subagent::present_child_completion(completion, &gateway);
}
fn running_count_changed(&self, running: usize) {
self.agent_ref
.get()
.activity
.subagent_gauge()
.store(running, std::sync::atomic::Ordering::Relaxed);
}
fn persisted_output_ref(&self, completion_data: &Self::CompletionData) -> Option<String> {
completion_data
.persisted_output_dir()
.map(|path| path.to_string_lossy().into_owned())
}
fn load_persisted_output(&self, reference: &str) -> Option<std::sync::Arc<str>> {
crate::agent::subagent::read_subagent_output(std::path::Path::new(reference))
.map(std::sync::Arc::from)
}
}
impl MvpAgent {
/// Start the subagent coordinator drain task.
/// Start the shared subagent coordinator actor.
///
/// Takes the `subagent_event_rx` receiver (once) and spawns a `spawn_local` task
/// that receives `SubagentRequest`s and delegates each to
/// `handle_subagent_request()` on its own `spawn_local` task.
/// Takes `subagent_event_rx` once and `spawn_local`s one
/// [`SubagentCoordinator`](xai_grok_tools::implementations::grok_build::task::coordinator::SubagentCoordinator)
/// that drains `ChannelBackend` events (`Spawn` / await / cancel / inspect)
/// through [`ShellChildRunner`]. The actor owns pending/active/completed
/// state, waiters, deadlines, and completion disposition; the runner only
/// builds shell child sessions via `run_shell_child`.
///
/// Uses `LocalRef` to reference `self` from
/// `spawn_local` closures. Idempotent: subsequent calls are no-ops.
/// Uses `LocalRef` so the `!Send` runner can touch `self` from the
/// `LocalSet`. Idempotent: subsequent calls are no-ops.
pub(super) fn start_subagent_coordinator(&self) {
let Some(mut rx) = self.subagent_event_rx.borrow_mut().take() else {
let Some(rx) = self.subagent_event_rx.borrow_mut().take() else {
return;
};
let agent_ref = LocalRef::new(self);
use crate::agent::subagent::{BlockWaitSlot, is_running, resolve_snapshot};
use xai_grok_tools::implementations::grok_build::task::types::{
SubagentCancelOutcome, SubagentCancelTarget, SubagentEvent,
let runner = ShellChildRunner {
agent_ref: agent_ref.clone(),
};
let config =
xai_grok_tools::implementations::grok_build::task::coordinator::CoordinatorConfig {
foreground_budget:
xai_grok_tools::implementations::grok_build::task::backend::env_duration_or(
"GROK_SUBAGENT_AWAIT_BUDGET_MS",
std::time::Duration::from_secs(600),
),
buffer_completions: true,
buffered_completion_output_cap: None,
};
tokio::task::spawn_local(
xai_grok_tools::implementations::grok_build::task::coordinator::SubagentCoordinator::new(
rx,
runner,
config,
)
.run(),
);
let (trace_tx, mut trace_rx) = tokio::sync::mpsc::unbounded_channel::<
crate::upload::turn::SyntheticTurnTraceRequest,
>();
self.subagent_presentation.borrow_mut().synthetic_trace_tx = Some(trace_tx);
tokio::task::spawn_local({
let agent_ref = agent_ref.clone();
async move {
while let Some(event) = rx.recv().await {
match event {
SubagentEvent::Spawn(boxed) => {
let mut request = *boxed;
{
let this = agent_ref.get();
let parent_is_session = this.sessions.borrow().contains_key(
&acp::SessionId::new(request.parent_session_id.clone()),
);
if !parent_is_session {
let child_sess = request.parent_session_id.clone();
let reparent = {
let coord = this.subagent_coordinator.borrow();
coord.parent_of_child_session(&child_sess).map(|root| {
(root, coord.loop_task_id_of_child_session(&child_sess))
})
};
if let Some((root, inherited_loop)) = reparent {
tracing::info!(
child_session_id = %child_sess,
root_session_id = %root,
subagent_id = %request.id,
"Re-parenting child-session spawn to root session"
);
request.parent_session_id = root;
request.surface_completion = false;
if request.runtime_overrides.loop_task_id.is_none() {
request.runtime_overrides.loop_task_id = inherited_loop;
}
}
}
if let Some(task_id) =
request.runtime_overrides.loop_task_id.clone()
{
this.subagent_coordinator
.borrow_mut()
.record_loop_owner(&request.id, &task_id);
}
}
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let this = agent_ref.get();
let parent_sid = request.parent_session_id.clone();
let Some(mut ctx) =
this.try_build_subagent_spawn_context(&parent_sid)
else {
tracing::warn!(
parent_session_id = %parent_sid,
subagent_id = %request.id,
"Spawn for unknown/evicted parent session, failing request"
);
this.subagent_coordinator
.borrow_mut()
.remove_loop_owner(&request.id);
crate::agent::subagent::send_failure(
request,
"Parent session not found (evicted or torn down); cannot spawn subagent.",
);
return;
};
let parent_handle = {
let parent_sid_acp = acp::SessionId::new(parent_sid.clone());
this.sessions.borrow().get(&parent_sid_acp).cloned()
};
if let Some(handle) = parent_handle {
ctx.parent_mcp_pool = handle.snapshot_mcp_pool().await;
ctx.client_hooks = handle.snapshot_client_hooks().await;
let parent_tools = handle.snapshot_tool_definitions().await;
ctx.parent_tool_snapshot =
(!parent_tools.is_empty()).then_some(parent_tools);
}
crate::agent::subagent::handle_subagent_request(
request,
ctx,
&this.subagent_coordinator,
&this.gateway,
)
.await;
});
while let Some(request) = trace_rx.recv().await {
tokio::task::spawn_local({
let agent_ref = agent_ref.clone();
async move {
handle_synthetic_turn_trace(agent_ref, request).await;
}
SubagentEvent::Query(query) => {
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let subagent_id = query.subagent_id;
let block = query.block;
let timeout_ms = query.timeout_ms;
let slot: BlockWaitSlot = std::rc::Rc::new(
std::cell::RefCell::new(Some(query.respond_to)),
);
let send_via_slot =
|slot: &BlockWaitSlot, snap| match slot.borrow_mut().take() {
Some(tx) => tx.send(snap).is_ok(),
None => false,
};
let lookup = {
let this = agent_ref.get();
let result =
this.subagent_coordinator.borrow().lookup(&subagent_id);
if block && result.is_some() {
this.subagent_coordinator
.borrow_mut()
.register_block_wait(&subagent_id, slot.clone());
}
result
};
let snapshot = resolve_snapshot(lookup).await;
let should_block =
block && snapshot.as_ref().is_some_and(is_running);
if should_block {
let timeout_ms = timeout_ms.unwrap_or(30_000);
let deadline = tokio::time::Instant::now()
+ tokio::time::Duration::from_millis(timeout_ms);
loop {
tokio::time::sleep(tokio::time::Duration::from_millis(200))
.await;
let receiver_gone =
slot.borrow().as_ref().is_none_or(|tx| tx.is_closed());
if receiver_gone {
let this = agent_ref.get();
let mut coord = this.subagent_coordinator.borrow_mut();
coord.clear_block_waited(&subagent_id);
coord.unregister_block_wait(&subagent_id, &slot);
return;
}
let lookup = {
let this = agent_ref.get();
this.subagent_coordinator.borrow().lookup(&subagent_id)
};
let snap = resolve_snapshot(lookup).await;
let still_running = snap.as_ref().is_some_and(is_running);
if !still_running || tokio::time::Instant::now() >= deadline
{
{
let this = agent_ref.get();
let mut coord =
this.subagent_coordinator.borrow_mut();
if still_running {
coord.clear_block_waited(&subagent_id);
}
coord.unregister_block_wait(&subagent_id, &slot);
}
if !send_via_slot(&slot, snap) && !still_running {
let this = agent_ref.get();
this.subagent_coordinator
.borrow_mut()
.clear_block_waited(&subagent_id);
}
return;
}
}
} else {
let delivered = send_via_slot(&slot, snapshot);
if block {
let this = agent_ref.get();
let mut coord = this.subagent_coordinator.borrow_mut();
coord.unregister_block_wait(&subagent_id, &slot);
if !delivered {
coord.clear_block_waited(&subagent_id);
}
}
}
});
}
SubagentEvent::Cancel(request) => match request.target {
SubagentCancelTarget::WorkflowRunId(run_id) => {
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let notify = {
let this = agent_ref.get();
let mut coord = this.subagent_coordinator.borrow_mut();
coord.cancel_workflow_children(&run_id);
coord.completion_notify()
};
loop {
let notified = notify.notified();
let outstanding = {
let this = agent_ref.get();
this.subagent_coordinator
.borrow()
.outstanding_for_workflow(&run_id)
};
if outstanding == 0 {
let _ = request
.respond_to
.send(SubagentCancelOutcome::Cancelled);
break;
}
notified.await;
}
});
}
target => {
let this = agent_ref.get();
let outcome = {
let mut coord = this.subagent_coordinator.borrow_mut();
match target {
SubagentCancelTarget::SubagentId(ref subagent_id) => {
coord.mark_explicitly_killed(subagent_id);
coord.cancel_with_outcome(subagent_id)
}
SubagentCancelTarget::ParentPromptId(
ref parent_prompt_id,
) => {
coord.cancel_by_parent_prompt_id(parent_prompt_id);
SubagentCancelOutcome::Cancelled
}
SubagentCancelTarget::WorkflowRunId(_) => {
unreachable!("handled above")
}
}
};
let _ = request.respond_to.send(outcome);
}
},
SubagentEvent::ListActive(request) => {
let this = agent_ref.get();
let summaries = this
.subagent_coordinator
.borrow()
.active_summaries_for(&request.parent_session_id);
let _ = request.respond_to.send(summaries);
}
SubagentEvent::Completions(request) => {
let this = agent_ref.get();
let mut completions = this
.subagent_coordinator
.borrow_mut()
.drain_pending_completions_for(&request.session_id);
completions.retain(|c| !request.suppress_ids.contains(&c.subagent_id));
let _ = request.respond_to.send(completions);
}
SubagentEvent::Outstanding(request) => {
let this = agent_ref.get();
let reply = this
.subagent_coordinator
.borrow()
.outstanding_reply_for_prompt(&request.prompt_id);
let _ = request.respond_to.send(reply);
}
SubagentEvent::ClearUsageNotApplied(request) => {
let this = agent_ref.get();
this.subagent_coordinator
.borrow_mut()
.clear_subagent_usage_not_applied(&request.prompt_id);
}
SubagentEvent::MarkUsageNotApplied(request) => {
let this = agent_ref.get();
this.subagent_coordinator
.borrow_mut()
.mark_subagent_usage_not_applied(&request.prompt_id);
let _ = request.respond_to.send(());
}
SubagentEvent::ValidateType(request) => {
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
let this = agent_ref.get();
let ctx = this
.build_subagent_validation_context(&request.parent_session_id);
let outcome = crate::agent::subagent::validate_subagent_type(
&request.subagent_type,
&ctx,
);
let _ = request.respond_to.send(outcome);
});
}
SubagentEvent::DescribeType(request) => {
let agent_ref = agent_ref.clone();
tokio::task::spawn_local(async move {
use xai_grok_tools::implementations::grok_build::task::types::SubagentDescribeOutcome;
let this = agent_ref.get();
let outcome = match this
.try_build_subagent_spawn_context(&request.parent_session_id)
{
Some(ctx) => crate::agent::subagent::describe_subagent_type(
&request.subagent_type,
request.harness_agent_type.as_deref(),
&ctx,
),
None => {
tracing::warn!(
parent_session_id = %request.parent_session_id,
subagent_type = %request.subagent_type,
"DescribeType for unknown/evicted parent session, replying Unavailable",
);
SubagentDescribeOutcome::Unavailable
}
};
let _ = request.respond_to.send(outcome);
});
}
SubagentEvent::LoopUnitActive(request) => {
let this = agent_ref.get();
let active = this
.subagent_coordinator
.borrow()
.loop_unit_active(&request.task_id);
let _ = request.respond_to.send(active);
}
}
});
}
}
});
{
let (trace_tx, mut trace_rx) = tokio::sync::mpsc::unbounded_channel::<
crate::upload::turn::SyntheticTurnTraceRequest,
>();
self.subagent_coordinator.borrow_mut().synthetic_trace_tx = Some(trace_tx);
tokio::task::spawn_local({
let agent_ref = agent_ref.clone();
async move {
while let Some(request) = trace_rx.recv().await {
tokio::task::spawn_local({
let agent_ref = agent_ref.clone();
async move {
handle_synthetic_turn_trace(agent_ref, request).await;
}
});
}
}
});
}
}
/// Lightweight context for the `SubagentEvent::ValidateType` drain arm;
/// tolerates evicted parent sessions (returns built-in defaults + warns).
@ -509,7 +365,6 @@ impl MvpAgent {
};
let (gcs_upload_method, gcs_bucket_url) = match self.trace_upload_config_snapshot() {
Some(method) => {
use crate::session::repo_changes::UploadMethod;
let bucket = match &method {
UploadMethod::Direct { .. } => self
.cfg
@ -552,7 +407,6 @@ impl MvpAgent {
};
Some(crate::agent::subagent::SubagentSpawnContext {
lsp: parent_lsp,
gateway: self.gateway.clone(),
client_hooks: Default::default(),
sampling_config: self.sampling_config.borrow().clone(),
managed_mcp_proxy_base_url: parent_managed_mcp_proxy_base_url
@ -565,7 +419,6 @@ impl MvpAgent {
.cloned()
.unwrap_or_else(|| acp::AuthMethodId::new("default")),
model_id: parent_model_id,
storage_mode: self.storage_mode,
auth: self.current_or_buffered_auth(),
parent_cwd: parent_cwd.clone(),
parent_session_id: parent_session_id.to_string(),
@ -632,7 +485,6 @@ impl MvpAgent {
agent_config: Some(self.cfg.borrow().clone()),
gcs_upload_method,
hook_registry: parent_hook_registry,
hook_workspace_root: String::new(),
permission_handle: {
let sessions = self.sessions.borrow();
sessions
@ -664,7 +516,7 @@ impl MvpAgent {
},
managed_mcp_state: self.managed_mcp_cache.clone(),
parent_mcp_pool: None,
parent_tool_snapshot: None,
parent_tool_definitions: None,
parent_skills: None,
parent_skills_config: self.cfg.borrow().skills.clone(),
parent_compat: self.cfg.borrow().compat_resolved,
@ -700,15 +552,6 @@ impl MvpAgent {
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false))
})
},
parent_blocking_wait_depth: {
let sessions = self.sessions.borrow();
sessions
.get(&parent_sid)
.map(|h| h.tool_context.blocking_wait_depth.clone())
.unwrap_or_else(|| {
std::sync::Arc::new(crate::tools::tool_context::BlockingWaitState::new())
})
},
parent_terminal_backend: parent_terminal_backend.clone(),
parent_notification_handle: parent_notification_handle.clone(),
parent_scheduler_handle: parent_scheduler_handle.clone(),

View file

@ -1074,6 +1074,7 @@ async fn file_toolset_override_e2e_to_finalized_toolset() {
session_env: std::sync::Arc::new(std::collections::HashMap::new()),
notification_handle: ToolNotificationHandle::noop(),
owner_session_id: None,
subagent: None,
parent_scheduler_handle: None,
skills: vec![],
state_path: tmp.path().join("state.json"),
@ -2131,6 +2132,8 @@ fn find_model_by_id_prefers_key_then_falls_back_to_slug() {
api_backend: crate::sampling::ApiBackend::default(),
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
query_params: IndexMap::new(),
env_http_headers: IndexMap::new(),
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
@ -2610,6 +2613,31 @@ async fn prepare_video_gen_config_sends_client_identifier_header() {
applies the coding ZDR opt-out to Build traffic"
);
}
/// Regression: `x.ai/auth/info` must return profile fields even when the
/// access token is expired — profile data does not expire with the token,
/// and hiding it made the desktop render "Signed in" with no identity.
#[tokio::test]
async fn auth_info_returns_profile_when_token_expired() {
let agent = build_agent_with_auth(crate::auth::GrokAuth {
email: Some("user@example.com".into()),
first_name: Some("Test".into()),
refresh_token: Some("rt".into()),
expires_at: Some(chrono::Utc::now() - chrono::Duration::hours(1)),
..crate::auth::GrokAuth::test_default()
});
let resp = crate::extensions::auth::handle(
&agent,
&acp::ExtRequest::new(
"x.ai/auth/info",
std::sync::Arc::from(serde_json::value::to_raw_value(&serde_json::json!({})).unwrap()),
),
)
.await
.expect("auth/info must succeed with an expired token");
let info: serde_json::Value = serde_json::from_str(resp.0.get()).unwrap();
assert_eq!(info["email"], "user@example.com");
assert_eq!(info["firstName"], "Test");
}
#[tokio::test]
async fn data_collection_enabled_for_normal_user() {
let agent = build_agent_with_auth(crate::auth::GrokAuth::test_default());
@ -4775,6 +4803,11 @@ mod soft_default_settings_emit {
let cfg = AgentConfig {
remote_settings: Some(crate::util::config::RemoteSettings {
permission_mode: Some("always-approve".into()),
slash_command_tags: Some(
[("workflows".to_string(), "new".to_string())]
.into_iter()
.collect(),
),
..Default::default()
}),
..Default::default()
@ -4795,6 +4828,14 @@ mod soft_default_settings_emit {
Some("always-approve"),
"post-auth emit must carry remote permission_mode for first session"
);
assert_eq!(
params
.get("slash_command_tags")
.and_then(|v| v.get("workflows"))
.and_then(|v| v.as_str()),
Some("new"),
"post-auth emit must carry remote slash_command_tags"
);
let _ = args.response_tx.send(Ok(()));
})
.await;

View file

@ -1,578 +0,0 @@
#![cfg_attr(rustfmt, rustfmt::skip)]
#![allow(unused_imports)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use agent_client_protocol as acp;
use tokio::sync::{Notify, mpsc, oneshot};
use tokio_util::sync::CancellationToken;
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 crate::upload::trace::{
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
local_sandbox_telemetry, upload_metadata, upload_session_state,
upload_subagent_metadata, upload_turn_result,
};
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
use xai_grok_tools::implementations::grok_build::task::types::*;
use xai_grok_workspace::file_system::AsyncFileSystem;
use xai_hunk_tracker::HunkTrackerHandle;
use super::*;
impl SubagentCoordinator {
pub fn new() -> Self {
Self {
pending: HashMap::new(),
active: HashMap::new(),
completed: HashMap::new(),
completion_notify: Arc::new(Notify::new()),
pending_completions: Vec::new(),
is_turn_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
synthetic_trace_tx: None,
running_gauge: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
block_wait_slots: HashMap::new(),
subagent_usage_not_applied_prompts: std::collections::HashSet::new(),
loop_owned: HashMap::new(),
}
}
pub fn mark_subagent_usage_not_applied(&mut self, prompt_id: &str) {
self.subagent_usage_not_applied_prompts.insert(prompt_id.to_string());
}
pub fn subagent_usage_not_applied(&self, prompt_id: &str) -> bool {
self.subagent_usage_not_applied_prompts.contains(prompt_id)
}
pub fn clear_subagent_usage_not_applied(&mut self, prompt_id: &str) {
self.subagent_usage_not_applied_prompts.remove(prompt_id);
}
pub fn parent_prompt_id_for(&self, subagent_id: &str) -> Option<String> {
self.active
.get(subagent_id)
.and_then(|t| t.parent_prompt_id.clone())
.or_else(|| {
self.pending.get(subagent_id).and_then(|p| p.parent_prompt_id.clone())
})
}
/// Rebind the running-subagent gauge, copying the current count so a
/// late rebind cannot under-report.
pub fn set_running_gauge(&mut self, gauge: Arc<std::sync::atomic::AtomicUsize>) {
gauge
.store(
self.pending.len() + self.active.len(),
std::sync::atomic::Ordering::Relaxed,
);
self.running_gauge = gauge;
}
/// Recompute the gauge from `pending` + `active` after every mutation of
/// either map — recomputing (rather than incrementing) prevents drift.
fn sync_running_gauge(&self) {
self.running_gauge
.store(
self.pending.len() + self.active.len(),
std::sync::atomic::Ordering::Relaxed,
);
}
pub fn completion_notify(&self) -> Arc<Notify> {
Arc::clone(&self.completion_notify)
}
/// Returns a shared handle to the turn-active flag.
pub fn turn_active_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.is_turn_active)
}
/// Whether the model's turn is currently active.
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "used from tests only; remove expect when wired in production"
)
)]
pub fn is_turn_active(&self) -> bool {
self.is_turn_active.load(std::sync::atomic::Ordering::Relaxed)
}
/// Pending + active turn-blocking subagent IDs for `prompt_id`.
/// Background children are excluded: they outlive the turn by design, so
/// the freeze drain must not wait on them (their spend reaches the session
/// ledger when they finish; the prompt report flags them via
/// `background_live`).
pub fn outstanding_for_prompt(&self, prompt_id: &str) -> Vec<String> {
let mut ids: Vec<String> = self
.pending
.values()
.filter(|p| {
p.parent_prompt_id.as_deref() == Some(prompt_id) && !p.run_in_background
})
.map(|p| p.subagent_id.clone())
.chain(
self
.active
.values()
.filter(|t| {
t.parent_prompt_id.as_deref() == Some(prompt_id)
&& !t.run_in_background
})
.map(|t| t.subagent_id.clone()),
)
.collect();
ids.sort();
ids
}
/// True while any background child of `prompt_id` is pending or active.
/// Their spend is missing from the prompt report (it lands on the session
/// ledger at completion), so the report is incomplete — without waiting.
pub fn background_live_for_prompt(&self, prompt_id: &str) -> bool {
self
.pending
.values()
.any(|p| {
p.parent_prompt_id.as_deref() == Some(prompt_id) && p.run_in_background
})
|| self
.active
.values()
.any(|t| {
t.parent_prompt_id.as_deref() == Some(prompt_id)
&& t.run_in_background
})
}
/// Record that a foreground child was auto-backgrounded (await budget
/// expired): it no longer blocks the turn, so the freeze drain must stop
/// waiting on it.
pub fn mark_backgrounded(&mut self, subagent_id: &str) {
if let Some(t) = self.active.values_mut().find(|t| t.subagent_id == subagent_id)
{
t.run_in_background = true;
}
if let Some(p) = self.pending.values_mut().find(|p| p.subagent_id == subagent_id)
{
p.run_in_background = true;
}
}
pub fn outstanding_reply_for_prompt(
&self,
prompt_id: &str,
) -> xai_grok_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
xai_grok_tools::implementations::grok_build::task::types::SubagentOutstandingReply {
live_ids: self.outstanding_for_prompt(prompt_id),
background_live: self.background_live_for_prompt(prompt_id),
subagent_usage_not_applied: self.subagent_usage_not_applied(prompt_id),
}
}
pub fn drain_pending_completions_for(
&mut self,
session_id: &str,
) -> Vec<SubagentCompletionSummary> {
if session_id.is_empty() {
return std::mem::take(&mut self.pending_completions);
}
let (mine, others) = std::mem::take(&mut self.pending_completions)
.into_iter()
.partition(|c| {
c.owner_session_id.is_empty() || c.owner_session_id == session_id
});
self.pending_completions = others;
mine
}
pub fn discard_pending_completions_for(&mut self, session_id: &str) {
if session_id.is_empty() {
return;
}
self.pending_completions.retain(|c| c.owner_session_id != session_id);
}
fn enforce_pending_completions_cap(&mut self) {
const MAX_PENDING_COMPLETIONS: usize = 256;
if self.pending_completions.len() > MAX_PENDING_COMPLETIONS {
let excess = self.pending_completions.len() - MAX_PENDING_COMPLETIONS;
self.pending_completions.drain(..excess);
}
}
/// Collect references to subagents spawned for a specific parent prompt.
/// Returns only the children whose `parent_prompt_id` matches, so the
/// parent turn's `turn_result.json` accurately reflects what was spawned
/// during that turn — not the entire coordinator lifetime.
pub fn spawned_refs_for_prompt(&self, prompt_id: &str) -> Vec<SubagentSpawnedRef> {
let mut refs: Vec<_> = self
.active
.values()
.filter(|t| t.parent_prompt_id.as_deref() == Some(prompt_id))
.map(|t| SubagentSpawnedRef {
subagent_id: t.subagent_id.clone(),
child_session_id: t.child_session_id.0.to_string(),
subagent_type: t.subagent_type.clone(),
description: t.description.clone(),
persona: t.persona.clone(),
resumed_from: t.resumed_from.clone(),
})
.chain(
self
.completed
.values()
.filter(|c| c.parent_prompt_id.as_deref() == Some(prompt_id))
.map(|c| SubagentSpawnedRef {
subagent_id: c.subagent_id.clone(),
child_session_id: c.child_session_id.clone(),
subagent_type: c.subagent_type.clone(),
description: c.description.clone(),
persona: c.persona.clone(),
resumed_from: c.resumed_from.clone(),
}),
)
.collect();
refs.sort_by(|a, b| a.subagent_id.cmp(&b.subagent_id));
refs
}
/// Register a subagent as pending (initializing). Call this early,
/// before any blocking work like worktree creation, so that
/// `get_task_output` can report the subagent as initializing instead
/// of "not found".
pub fn insert_pending(&mut self, entry: PendingSubagent) {
self.pending.insert(entry.subagent_id.clone(), entry);
self.sync_running_gauge();
}
/// Remove a pending subagent without recording a failure.
/// Used by cancel flows where the subagent was intentionally stopped.
#[cfg(test)]
pub fn remove_pending(&mut self, id: &str) {
self.pending.remove(id);
self.sync_running_gauge();
}
/// Move a pending subagent directly to `completed` so it stays queryable via
/// `get_task_output`. `cancelled` stamps `"cancelled"` vs `"failed"`.
fn move_pending_to_terminal(&mut self, id: &str, error: &str, cancelled: bool) {
let Some(pending) = self.pending.remove(id) else {
return;
};
self.record_failure_completion(FailureCompletion {
subagent_id: pending.subagent_id,
subagent_type: pending.subagent_type,
description: pending.description,
parent_prompt_id: pending.parent_prompt_id,
parent_session_id: pending.parent_session_id,
owner: pending.owner,
persona: pending.persona,
started_at: pending.started_at,
error,
surface_completion: pending.surface_completion,
cancelled,
});
}
/// Move a pending subagent to `completed` as a failure so it stays queryable
/// via `get_task_output`.
pub fn move_pending_to_failed(&mut self, id: &str, error: &str) {
self.move_pending_to_terminal(id, error, false);
}
/// Like [`Self::move_pending_to_failed`] but stamps `"cancelled"` — a pending
/// subagent killed while initializing.
pub fn move_pending_to_cancelled(&mut self, id: &str, error: &str) {
self.move_pending_to_terminal(id, error, true);
}
/// Record a synthetic failure for a subagent that never reached `pending`.
pub fn record_pre_spawn_failure(
&mut self,
subagent_id: String,
subagent_type: String,
description: String,
parent_prompt_id: Option<String>,
parent_session_id: String,
owner: SubagentOwner,
error: &str,
surface_completion: bool,
) {
self.record_failure_completion(FailureCompletion {
subagent_id,
subagent_type,
description,
parent_prompt_id,
parent_session_id,
owner,
persona: None,
started_at: std::time::Instant::now(),
error,
surface_completion,
cancelled: false,
});
}
/// Insert a synthetic failed entry, push a completion summary, notify waiters.
/// Clears any stale pending entry for the same id.
fn record_failure_completion(&mut self, c: FailureCompletion<'_>) {
self.pending.remove(&c.subagent_id);
self.loop_owned.remove(&c.subagent_id);
self.sync_running_gauge();
let FailureCompletion {
subagent_id,
subagent_type,
description,
parent_prompt_id,
parent_session_id,
owner,
persona,
started_at,
error,
surface_completion,
cancelled,
} = c;
let result = SubagentResult {
success: false,
cancelled,
error: Some(error.to_string()),
subagent_id: subagent_id.clone(),
..Default::default()
};
let summary_output = result.output.clone();
let owner_session_id = parent_session_id.clone();
self.completed
.insert(
subagent_id.clone(),
CompletedSubagent {
subagent_id: subagent_id.clone(),
parent_session_id,
parent_prompt_id,
owner,
child_session_id: String::new(),
description: description.clone(),
subagent_type: subagent_type.clone(),
persona,
started_at,
completed_at: std::time::Instant::now(),
result,
resumed_from: None,
child_cwd: String::new(),
worktree_path: None,
snapshot_ref: None,
effective_model_id: String::new(),
block_waited: false,
explicitly_killed: false,
completion_output_cap: None,
persisted_output_dir: None,
},
);
self.enforce_completed_cap();
if surface_completion {
self.pending_completions
.push(SubagentCompletionSummary {
subagent_id,
owner_session_id,
subagent_type,
description,
success: false,
duration_ms: 0,
tool_calls: 0,
turns: 0,
output: summary_output,
});
self.enforce_pending_completions_cap();
}
self.completion_notify.notify_waiters();
}
pub fn insert(&mut self, tracker: SubagentTracker) {
self.pending.remove(&tracker.subagent_id);
self.active.insert(tracker.subagent_id.clone(), tracker);
self.sync_running_gauge();
}
/// Move a finished subagent from `active` to `completed`.
/// Returns the tracker if it was active.
pub fn move_to_completed(
&mut self,
id: &str,
description: String,
subagent_type: String,
result: SubagentResult,
persisted_output_dir: Option<PathBuf>,
) -> Option<SubagentTracker> {
let tracker = self.active.remove(id);
self.loop_owned.remove(id);
self.sync_running_gauge();
let started_at = tracker
.as_ref()
.map(|t| t.started_at)
.unwrap_or_else(std::time::Instant::now);
let parent_session_id = tracker
.as_ref()
.map(|t| t.parent_session_id.clone())
.unwrap_or_default();
let child_session_id = tracker
.as_ref()
.map(|t| t.child_session_id.0.to_string())
.unwrap_or_default();
let parent_prompt_id = tracker.as_ref().and_then(|t| t.parent_prompt_id.clone());
let owner = tracker.as_ref().map(|t| t.owner.clone()).unwrap_or_default();
let persona = tracker.as_ref().and_then(|t| t.persona.clone());
let child_cwd = tracker
.as_ref()
.map(|t| t.child_cwd.clone())
.unwrap_or_default();
let worktree_path = tracker.as_ref().and_then(|t| t.worktree_path.clone());
let resumed_from = tracker.as_ref().and_then(|t| t.resumed_from.clone());
let effective_model_id = tracker
.as_ref()
.map(|t| t.effective_model_id.clone())
.unwrap_or_default();
let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited);
let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed);
let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion);
let completion_output_cap = tracker
.as_ref()
.and_then(|t| t.completion_output_cap);
let mut completed = CompletedSubagent {
subagent_id: id.to_string(),
parent_session_id,
parent_prompt_id,
owner,
child_session_id,
description,
subagent_type,
persona,
started_at,
completed_at: std::time::Instant::now(),
result,
resumed_from,
child_cwd,
worktree_path,
snapshot_ref: None,
effective_model_id,
block_waited,
explicitly_killed,
completion_output_cap,
persisted_output_dir,
};
let success = completed.result.success && !completed.result.cancelled;
{
let preview = crate::util::truncate(&completed.result.output, 200);
let level_fn = if success {
xai_grok_telemetry::unified_log::info
} else {
xai_grok_telemetry::unified_log::error
};
level_fn(
if success { "subagent completed" } else { "subagent failed" },
None,
Some(
serde_json::json!({
"subagent_id": &completed.subagent_id,
"subagent_type": &completed.subagent_type,
"effective_model": &completed.effective_model_id,
"success": success,
"cancelled": completed.result.cancelled,
"duration_ms": completed.result.duration_ms,
"turns": completed.result.turns,
"tool_calls": completed.result.tool_calls,
"output_preview": preview,
"error": &completed.result.error,
}),
),
);
}
if surface_completion {
self.pending_completions
.push(SubagentCompletionSummary {
subagent_id: id.to_string(),
owner_session_id: completed.parent_session_id.clone(),
subagent_type: completed.subagent_type.clone(),
description: completed.description.clone(),
success,
duration_ms: completed.result.duration_ms,
tool_calls: completed.result.tool_calls,
turns: completed.result.turns,
output: super::cap_completion_output(
&completed.result.output,
completed.completion_output_cap,
),
});
self.enforce_pending_completions_cap();
}
if completed.persisted_output_dir.is_some() {
completed.result.output = Arc::from("");
}
self.completed.insert(id.to_string(), completed);
self.enforce_completed_cap();
self.completion_notify.notify_waiters();
tracker
}
/// Record the durable worktree snapshot ref on a completed subagent so
/// in-memory `resume_from` resolution can rehydrate the disposed worktree.
/// No-op if the entry was already evicted (the on-disk meta.json still has it).
pub fn set_completed_snapshot_ref(&mut self, id: &str, snapshot_ref: String) {
if let Some(completed) = self.completed.get_mut(id) {
completed.snapshot_ref = Some(snapshot_ref);
}
}
/// Cancel all active subagents that were launched by a specific parent turn,
/// including `run_in_background: true` subagents.
pub fn cancel_by_parent_prompt_id(&mut self, parent_prompt_id: &str) {
for tracker in self.active.values() {
if tracker.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
Self::cancel_tracker(tracker);
}
}
for pending in self.pending.values() {
if pending.parent_prompt_id.as_deref() == Some(parent_prompt_id) {
pending.cancel_token.cancel();
}
}
}
pub fn cancel_workflow_children(&mut self, run_id: &str) -> usize {
for tracker in self.active.values() {
if tracker.owner.workflow_run_id() == Some(run_id) {
Self::cancel_tracker(tracker);
}
}
for pending in self.pending.values() {
if pending.owner.workflow_run_id() == Some(run_id) {
pending.cancel_token.cancel();
}
}
self.outstanding_for_workflow(run_id)
}
pub fn outstanding_for_workflow(&self, run_id: &str) -> usize {
self
.pending
.values()
.filter(|entry| entry.owner.workflow_run_id() == Some(run_id))
.count()
+ self
.active
.values()
.filter(|entry| entry.owner.workflow_run_id() == Some(run_id))
.count()
}
/// Attempt to cancel a subagent. Returns a typed outcome covering all cases:
/// - Active → cancel it, return Cancelled
/// - Pending (initializing) → fire its spawn token, return Cancelled
/// - Already finished → return AlreadyFinished with terminal status
/// - Unknown ID → return NotFound
pub fn cancel_with_outcome(&mut self, subagent_id: &str) -> SubagentCancelOutcome {
if let Some(tracker) = self.active.get(subagent_id) {
Self::cancel_tracker(tracker);
return SubagentCancelOutcome::Cancelled;
}
if let Some(pending) = self.pending.get(subagent_id) {
pending.cancel_token.cancel();
return SubagentCancelOutcome::Cancelled;
}
if let Some(entry) = self.completed.get(subagent_id) {
return SubagentCancelOutcome::AlreadyFinished {
status: entry.result.status().to_string(),
};
}
SubagentCancelOutcome::NotFound
}
/// Internal: send Cancel + Shutdown to a tracked subagent.
fn cancel_tracker(tracker: &SubagentTracker) {
tracker.cancel_token.cancel();
let _ = tracker
.child_handle
.cmd_tx
.send(SessionCommand::Cancel {
cancel_subagents: true,
kill_background_tasks: true,
rewind_if_pristine: false,
trigger: None,
});
let _ = tracker.child_handle.cmd_tx.send(SessionCommand::Shutdown);
}
}

View file

@ -1,427 +0,0 @@
#![cfg_attr(rustfmt, rustfmt::skip)]
#![allow(unused_imports)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use agent_client_protocol as acp;
use tokio::sync::{Notify, mpsc, oneshot};
use tokio_util::sync::CancellationToken;
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 crate::upload::trace::{
GCS_SCHEMA_VERSION, PromptMetadata, SubagentSpawnedRef, TurnResultMetadata,
local_sandbox_telemetry, upload_metadata, upload_session_state,
upload_subagent_metadata, upload_turn_result,
};
use crate::upload::turn::{PromptTraceContext, complete_prompt_trace};
use xai_acp_lib::AcpAgentGatewaySender as GatewaySender;
use xai_grok_tools::implementations::grok_build::task::types::*;
use xai_grok_workspace::file_system::AsyncFileSystem;
use xai_hunk_tracker::HunkTrackerHandle;
use super::*;
impl SubagentCoordinator {
/// Synchronous lookup of a subagent by ID.
///
/// Returns a three-way result so the caller can drop the `RefCell` borrow
/// before awaiting the signals handle for running subagents.
///
/// - `Ready` — completed/failed/cancelled snapshot, no async work needed.
/// - `NeedsSignals` — subagent is running; caller must await
/// `resolve_snapshot()` after dropping the coordinator borrow.
/// - `None` — ID not found in active, completed, or pending maps.
pub(crate) fn lookup(&self, id: &str) -> Option<SnapshotLookup> {
if let Some(tracker) = self.active.get(id) {
if tracker.owner.is_workflow() {
return None;
}
return Some(
SnapshotLookup::NeedsSignals(RunningSnapshotSeed {
subagent_id: tracker.subagent_id.clone(),
description: tracker.description.clone(),
subagent_type: tracker.subagent_type.clone(),
started_at_epoch_ms: instant_to_epoch_ms(tracker.started_at),
duration_ms: tracker.started_at.elapsed().as_millis() as u64,
persona: tracker.persona.clone(),
signals_handle: tracker.child_handle.signals_handle.clone(),
}),
);
}
if let Some(completed) = self.completed.get(id) {
if completed.owner.is_workflow() {
return None;
}
let status = if completed.result.cancelled {
SubagentSnapshotStatus::Cancelled {
reason: completed.result.error.clone(),
}
} else if completed.result.success {
let output = match &completed.persisted_output_dir {
Some(dir) => {
read_subagent_output(dir)
.unwrap_or_else(|| {
OUTPUT_UNAVAILABLE_PLACEHOLDER.to_string()
})
}
None => completed.result.output.to_string(),
};
SubagentSnapshotStatus::Completed {
output,
tool_calls: completed.result.tool_calls,
turns: completed.result.turns,
worktree_path: completed.result.worktree_path.clone(),
}
} else {
SubagentSnapshotStatus::Failed {
error: completed
.result
.error
.clone()
.unwrap_or_else(|| "Unknown error".to_string()),
}
};
return Some(
SnapshotLookup::Ready(SubagentSnapshot {
subagent_id: completed.subagent_id.clone(),
description: completed.description.clone(),
subagent_type: completed.subagent_type.clone(),
status,
started_at_epoch_ms: instant_to_epoch_ms(completed.started_at),
duration_ms: completed.result.duration_ms,
persona: completed.persona.clone(),
}),
);
}
if let Some(pending) = self.pending.get(id) {
if pending.owner.is_workflow() {
return None;
}
return Some(
SnapshotLookup::Ready(SubagentSnapshot {
subagent_id: pending.subagent_id.clone(),
description: pending.description.clone(),
subagent_type: pending.subagent_type.clone(),
status: SubagentSnapshotStatus::Initializing,
started_at_epoch_ms: instant_to_epoch_ms(pending.started_at),
duration_ms: pending.started_at.elapsed().as_millis() as u64,
persona: pending.persona.clone(),
}),
);
}
None
}
/// Parent session of the running subagent whose child session is
/// `child_session_id`. Used to re-parent spawn requests that originate
/// inside a child session (e.g. a loop iteration spawning its own
/// subagent) to the root session that owns it.
pub(crate) fn parent_of_child_session(
&self,
child_session_id: &str,
) -> Option<String> {
self.active
.values()
.find(|t| t.child_session_id.0.as_ref() == child_session_id)
.map(|t| t.parent_session_id.clone())
}
/// Return `(parent_session_id, child_session_id)` for a given subagent.
///
/// Checks active first, then completed. Returns `None` if not found.
pub(crate) fn session_ids_for(&self, id: &str) -> Option<(String, String)> {
if let Some(t) = self.active.get(id) {
return Some((t.parent_session_id.clone(), t.child_session_id.0.to_string()));
}
if let Some(c) = self.completed.get(id) {
return Some((c.parent_session_id.clone(), c.child_session_id.clone()));
}
None
}
/// Mark a subagent as block-waited so auto-wake is suppressed on completion.
pub(crate) fn mark_block_waited(&mut self, id: &str) {
if let Some(t) = self.active.get_mut(id) {
t.block_waited = true;
} else if let Some(c) = self.completed.get_mut(id) {
c.block_waited = true;
}
}
/// Clear the block-waited flag after a block timed out without receiving
/// the completion, so auto-wake can still fire when the subagent finishes.
pub(crate) fn clear_block_waited(&mut self, id: &str) {
if let Some(t) = self.active.get_mut(id) {
t.block_waited = false;
} else if let Some(c) = self.completed.get_mut(id) {
c.block_waited = false;
}
}
/// Whether a block-waiter already consumed this subagent's result.
pub(crate) fn is_block_waited(&self, id: &str) -> bool {
self.active.get(id).is_some_and(|t| t.block_waited)
|| self.completed.get(id).is_some_and(|c| c.block_waited)
}
/// Register a live blocking-query reply slot and mark `block_waited`.
///
/// The slot lets `block_wait_delivered_or_live` verify at completion
/// time that the waiter can still receive the result — the flag alone
/// can be stale when the waiting turn was cancelled moments before the
/// subagent finished.
pub(crate) fn register_block_wait(&mut self, id: &str, slot: BlockWaitSlot) {
self.mark_block_waited(id);
self.block_wait_slots.entry(id.to_string()).or_default().push(slot);
}
/// Drop a previously registered reply slot (query poll loop exited).
pub(crate) fn unregister_block_wait(&mut self, id: &str, slot: &BlockWaitSlot) {
if let Some(slots) = self.block_wait_slots.get_mut(id) {
slots.retain(|s| !std::rc::Rc::ptr_eq(s, slot));
if slots.is_empty() {
self.block_wait_slots.remove(id);
}
}
}
/// Decision-time gate for the completion auto-wake: returns true when
/// the result was already delivered to a blocking waiter, or a live
/// waiter is still parked and will receive it. When every registered
/// waiter is gone (receivers dropped by a cancelled turn), clears
/// `block_waited` and returns false so the auto-wake fires.
///
/// This closes the race where the query poll loop clears the flag up to
/// one poll interval *after* the caller cancelled — the completion
/// handler could read the stale flag in that window and skip the wake.
/// Consumes the id's slot registrations (completion is terminal).
pub(crate) fn block_wait_delivered_or_live(&mut self, id: &str) -> bool {
let slots = self.block_wait_slots.remove(id).unwrap_or_default();
if !self.is_block_waited(id) {
return false;
}
let delivered_or_live = slots.is_empty()
|| slots
.iter()
.any(|s| s.borrow().as_ref().is_none_or(|tx| !tx.is_closed()));
if !delivered_or_live {
self.clear_block_waited(id);
}
delivered_or_live
}
/// Mark a subagent as explicitly killed so auto-wake is suppressed on completion.
pub(crate) fn mark_explicitly_killed(&mut self, id: &str) {
if let Some(t) = self.active.get_mut(id) {
t.explicitly_killed = true;
} else if let Some(c) = self.completed.get_mut(id) {
c.explicitly_killed = true;
}
}
/// Whether the model explicitly killed this subagent via the kill tool.
pub(crate) fn is_explicitly_killed(&self, id: &str) -> bool {
self.active.get(id).is_some_and(|t| t.explicitly_killed)
|| self.completed.get(id).is_some_and(|c| c.explicitly_killed)
}
/// Return fork provenance for a given subagent.
pub(crate) fn provenance_for(&self, id: &str) -> SubagentProvenance {
if let Some(t) = self.active.get(id) {
return SubagentProvenance {
fork_parent_prompt_id: t.parent_prompt_id.clone(),
resumed_from: t.resumed_from.clone(),
};
}
if let Some(c) = self.completed.get(id) {
return SubagentProvenance {
fork_parent_prompt_id: c.parent_prompt_id.clone(),
resumed_from: c.resumed_from.clone(),
};
}
SubagentProvenance::default()
}
/// Resolve a completed subagent scoped to the requesting parent session.
///
/// Returns `None` if the subagent is not found, still active, or belongs
/// to a different parent session (prevents cross-session context bleed).
///
/// Fast path: checks the in-memory `completed` map first. When that
/// misses (e.g. after cap eviction), falls back to on-disk metadata
/// in `{parent_session_dir}/subagents/{id}/meta.json`.
pub(crate) fn resumable_source_for(
&self,
id: &str,
parent_session_id: &str,
parent_cwd: &Path,
) -> Option<ResumeSourceData> {
if let Some(completed) = self.completed.get(id) {
if completed.parent_session_id != parent_session_id {
return None;
}
return Some(ResumeSourceData {
subagent_id: completed.subagent_id.clone(),
child_session_id: completed.child_session_id.clone(),
child_cwd: completed.child_cwd.clone(),
worktree_path: completed.worktree_path.clone(),
snapshot_ref: completed.snapshot_ref.clone(),
subagent_type: completed.subagent_type.clone(),
persona: completed.persona.clone(),
model_id: Some(completed.effective_model_id.clone()),
});
}
let parent_info = SessionInfo {
id: acp::SessionId::new(parent_session_id),
cwd: parent_cwd.to_string_lossy().to_string(),
};
let meta_path = session::persistence::session_dir(&parent_info)
.join("subagents")
.join(id)
.join("meta.json");
let data = std::fs::read_to_string(&meta_path).ok()?;
let meta: SubagentMeta = serde_json::from_str(&data).ok()?;
if meta.parent_session_id != parent_session_id {
return None;
}
match meta.status.as_str() {
"completed" | "failed" | "cancelled" => {}
_ => return None,
}
Some(ResumeSourceData {
subagent_id: meta.subagent_id,
child_session_id: meta.child_session_id,
child_cwd: meta.child_cwd.unwrap_or_default(),
worktree_path: meta.worktree_path.map(PathBuf::from),
snapshot_ref: meta.snapshot_ref,
subagent_type: meta.subagent_type,
persona: meta.persona,
model_id: meta.effective_model_id,
})
}
/// Check whether an ID refers to a currently-active (running) subagent.
pub(crate) fn is_active(&self, id: &str) -> bool {
self.active.contains_key(id)
}
/// Whether the coordinator still has this id in flight (spawning or running).
/// Orphan reconcile skips these — there is nothing stuck to heal.
pub(crate) fn is_active_or_pending(&self, id: &str) -> bool {
self.active.contains_key(id) || self.pending.contains_key(id)
}
pub(crate) fn record_loop_owner(&mut self, subagent_id: &str, task_id: &str) {
self.loop_owned.insert(subagent_id.to_string(), task_id.to_string());
}
pub(crate) fn remove_loop_owner(&mut self, subagent_id: &str) {
self.loop_owned.remove(subagent_id);
}
pub(crate) fn loop_task_id_of_child_session(
&self,
child_session_id: &str,
) -> Option<String> {
let subagent_id = self
.active
.values()
.find(|t| t.child_session_id.0.as_ref() == child_session_id)?
.subagent_id
.clone();
self.loop_owned.get(&subagent_id).cloned()
}
pub(crate) fn loop_unit_active(&self, task_id: &str) -> bool {
self.loop_owned.values().any(|t| t == task_id)
}
/// The terminal `SubagentFinished` for an id the coordinator already holds in
/// `completed`, else `None`. Lets orphan reconcile re-emit a subagent's real
/// outcome when only its terminal meta write was lost (reconnect race: entry
/// in `completed` but the on-disk meta is still `running`) instead of
/// force-cancelling it and discarding the result.
pub(crate) fn completed_finish(&self, id: &str) -> Option<SessionUpdate> {
let c = self.completed.get(id)?;
let duration_ms = c
.completed_at
.saturating_duration_since(c.started_at)
.as_millis() as u64;
Some(SessionUpdate::SubagentFinished {
subagent_id: c.subagent_id.clone(),
child_session_id: c.child_session_id.clone(),
status: c.result.status().to_string(),
error: c.result.error.clone(),
tool_calls: c.result.tool_calls,
turns: c.result.turns,
duration_ms,
tokens_used: 0,
output: None,
will_wake: false,
})
}
/// Lifecycle-map entry counts as `(pending, active, completed)`.
pub(crate) fn registry_snapshot(&self) -> (usize, usize, usize) {
(self.pending.len(), self.active.len(), self.completed.len())
}
/// Oldest completions are evicted first; their `output.json` stays on disk.
pub fn enforce_completed_cap(&mut self) {
if self.completed.len() <= MAX_COMPLETED_ENTRIES {
return;
}
let excess = self.completed.len() - MAX_COMPLETED_ENTRIES;
let mut by_age: Vec<(std::time::Instant, String)> = self
.completed
.iter()
.map(|(id, e)| (e.completed_at, id.clone()))
.collect();
by_age.sort_unstable_by_key(|(completed_at, _)| *completed_at);
for (_, id) in by_age.into_iter().take(excess) {
self.completed.remove(&id);
}
}
/// Snapshot all currently-running subagents for compaction state context.
///
/// Completed/failed/cancelled subagents are NOT included — they live in
/// the `completed` map and are irrelevant for post-compaction reminders
/// (the model already saw their tool results before compaction).
///
/// The `elapsed_ms` field is computed from `started_at.elapsed()` at call
/// time, so the values are a snapshot of "right now" — appropriate for
/// compaction since it happens once and the reminder is static.
#[cfg(test)]
pub fn active_summaries(&self) -> Vec<ActiveSubagentSummary> {
self.active
.values()
.filter(|t| !t.owner.is_workflow())
.map(tracker_to_summary)
.collect()
}
pub fn active_summaries_for(
&self,
parent_session_id: &str,
) -> Vec<ActiveSubagentSummary> {
self.active
.values()
.filter(|t| {
t.parent_session_id == parent_session_id && !t.owner.is_workflow()
})
.map(tracker_to_summary)
.collect()
}
///
/// Each seed carries copied identity metadata plus a cloned
/// `SessionSignalsHandle` so the caller can resolve live progress
/// asynchronously after dropping the coordinator borrow.
///
/// Returns an empty `Vec` if no active subagents match the given
/// parent session ID. Callers (e.g. the `x.ai/subagent/list_running`
/// ACP handler) should treat an empty result as a normal "no running
/// subagents" response, not an error.
pub(crate) fn list_running_for_parent(
&self,
parent_session_id: &str,
) -> Vec<RunningSubagentListSeed> {
self.active
.values()
.filter(|t| {
t.parent_session_id == parent_session_id && !t.owner.is_workflow()
})
.map(|t| RunningSubagentListSeed {
subagent_id: t.subagent_id.clone(),
parent_session_id: t.parent_session_id.clone(),
child_session_id: t.child_session_id.0.to_string(),
subagent_type: t.subagent_type.clone(),
description: t.description.clone(),
started_at_epoch_ms: instant_to_epoch_ms(t.started_at),
duration_ms: t.started_at.elapsed().as_millis() as u64,
signals_handle: t.child_handle.signals_handle.clone(),
})
.collect()
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff