Synced from monorepo

Synced from monorepo

Changes:
- Refresh tool search when the managed MCP catalog is re-fetched
- Prevent duplicate leader process spawn and startup hang from stale leaders
- Document marketplaces, plugins, and organization controls
- Stamp session ID on image generation direct-to-API requests
- Fix auto mode blocked documentation
- Auto mode considers recent user intent
- Expose deploy archive, taken-down, limit, and in-progress reasons on the chat API
- Fail-closed auth refresh contract for shell clients
- Emit a chat-supplied per-session turn index in turn hooks
- Show bash mode chrome in minimal mode
- Add metrics for true-noop and stationarity stops
- Include voice interim text on prompt submit
- Silently end turn on true-noop thrash
- Quiet copy toast when clipboard delivery is confirmed
- Fix session fork truncating at the wrong prompt in rewound sessions
- Make the idle "still running" watcher cue clickable to open the tasks pane
- Default web search model to grok-4.5
- Let plugin subagents inherit parent MCP servers
- Gate no-op end-turn reminder on system reminders
- Add gateway bridge lifecycle telemetry
- Allow editing finalized text while voice is open
- Relocate token carrier to turn-commit events and plumb per-turn origin context
- Raise workflow scratch quotas and make failed runs resumable
- Workflows overlay: auto-progress phases, live agent status, and drop budget meter

Source-Revision: 9b8d35b46d959c042ea9aa31cbbebbd1f0c5c527
This commit is contained in:
grokkybara[bot] 2026-07-24 16:59:42 +00:00
commit 6e38642082
103 changed files with 4964 additions and 1261 deletions

View file

@ -71,6 +71,12 @@ const AUTO_UPDATE_FLUSH_GRACE: Duration = Duration::from_secs(10);
/// the bounded-grace semantics of the `RelaunchForUpdate` drain.
const MAX_AUTO_UPDATE_BUSY_DEFERRALS: u32 = 24;
/// Bounded wait for the leader flock when it is held but no socket is bound yet
/// (a spawner mid-handoff, an old-flow client holding the flock across its ~10s
/// spawn window, or a same-version sibling briefly holding it). Exceeds that
/// old-flow window so a legitimately-spawning peer wins the race.
const LEADER_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(15);
/// Run the auto-update checker loop.
///
/// Periodically calls `check_fn` to check for, download, and install updates.
@ -906,17 +912,16 @@ fn spawn_leader_relay(
/// serve clients over IPC only. See [`spawn_leader_relay`] for when the relay
/// connection is opened (eager by default, demand-gated with `relay_on_demand`).
///
/// Startup sequence:
/// 1. Lock acquisition check — bail if another leader is already running.
/// Startup sequence (lock-then-socket):
/// 1. Acquire the leader flock FIRST — bail if another process holds it.
/// 2. Socket cleanup, channel + readiness-watch creation.
/// 3. IPC server started (`tokio::spawn`) — socket bound HERE, before auth.
/// 4. Wait for socket to appear (fast: < 100 ms).
/// 5. Lock handoff with spawner (if launched via connect_or_spawn).
/// 6. Auth + model prefetch (slow path, but socket already available to clients).
/// 5. Auth + model prefetch (slow path, but socket already available to clients).
/// - Auth resolves non-interactively; `None` (BYOK / no session) is not an
/// error — the relay is gated off and login is deferred to ACP.
/// 7. `ready_tx.send(true)` — unblocks ACP forwarding in the IPC server.
/// 8. LocalSet: agent, IPC↔agent bridges, WS↔agent bridges, relay, config watcher.
/// 6. `ready_tx.send(true)` — unblocks ACP forwarding in the IPC server.
/// 7. LocalSet: agent, IPC↔agent bridges, WS↔agent bridges, relay, config watcher.
///
/// # Arguments
///
@ -934,7 +939,7 @@ pub async fn run_leader(
) -> anyhow::Result<()> {
use crate::agent::relay::RelayConfig;
use crate::leader::{
LeaderLock, LeaderServerControlState, LeaderServerMetadata, ShutdownReason,
LeaderLock, LeaderServerControlState, LeaderServerMetadata, LockError, ShutdownReason,
compute_ws_url_suffix, run_leader_server,
};
use tokio::sync::watch;
@ -964,41 +969,62 @@ pub async fn run_leader(
let mut lock = LeaderLock::new(ws_url);
let socket_path = lock.socket_path().clone();
// Early bail-out: lock held + socket exists → another leader is running.
// ── Phase 1: Acquire the leader flock FIRST (lock-then-socket) ────────────
//
// Three cases:
// - Lock free → we ARE the leader; hold lock through setup.
// - Lock held + socket → another leader running → bail out immediately.
// - Lock held + no socket → spawner (connect_or_spawn) holds lock and is
// waiting for our socket → proceed normally.
let lock_already_held = match lock.try_acquire() {
// SINGLE-LEADER INVARIANT: only the flock holder may create/remove the socket
// and it holds the flock for its whole lifetime, so a racing leader can never
// clobber a live socket.
match lock.try_acquire() {
Ok(true) => {
lock.write_pid()?;
debug!("Lock acquired immediately, proceeding as leader");
true
debug!("Acquired leader lock, proceeding as leader");
}
Ok(false) => {
// Fast path: a fully-running leader (flock held AND socket bound) →
// exit so the client adopts it.
if crate::leader::listener_is_ready(&socket_path) {
info!(
"Another leader is already running (lock held, socket exists at {}). Exiting.",
"Another process holds the leader lock with a bound socket ({}). \
Exiting so the client adopts it.",
socket_path.display()
);
return Err(anyhow::anyhow!(
"Another leader is already running at {}",
"Another leader already holds the lock at {}",
socket_path.display()
));
}
debug!("Lock held by spawner (no socket yet), proceeding with socket-then-lock flow");
false
}
Err(e) => return Err(anyhow::anyhow!("Failed to check leader lock: {}", e)),
};
// ── Phase 1: Clean up stale socket ────────────────────────────────────────
// Held but no socket yet: a spawner is mid-handoff, or an old-flow
// client holds the flock across its spawn window. Wait (re-opening the
// path each poll to tolerate the old client's Drop unlinking the inode)
// before conceding.
match lock.acquire_reopen_timeout(LEADER_ACQUIRE_TIMEOUT).await {
Ok(()) => {
lock.write_pid()?;
debug!("Acquired leader lock after bounded wait, proceeding as leader");
}
Err(LockError::Timeout(_)) => {
info!(
"Timed out waiting for the leader lock ({}). Exiting so the \
client adopts whoever won it.",
socket_path.display()
);
return Err(anyhow::anyhow!(
"Timed out acquiring leader lock at {}",
socket_path.display()
));
}
Err(e) => return Err(anyhow::anyhow!("Failed to acquire leader lock: {}", e)),
}
}
Err(e) => return Err(anyhow::anyhow!("Failed to acquire leader lock: {}", e)),
}
// ── Phase 2: Clean up stale socket (we hold the flock, so this is safe) ────
lock.cleanup_socket()?;
info!("Leader server starting");
// ── Phase 2: Create all channels + readiness watch ────────────────────────
// ── Phase 3: Create all channels + readiness watch ────────────────────────
//
// All channels are created here so the IPC server can start receiving
// client connections immediately, before auth/prefetch begin.
@ -1058,7 +1084,7 @@ pub async fn run_leader(
// Cloned before control_state moves into the IPC server; auth wired below.
let workspace_control = control_state.workspace.clone();
// ── Phase 3: Bind socket and start IPC server (BEFORE auth/prefetch) ──────
// ── Phase 4: Bind socket and start IPC server (BEFORE auth/prefetch) ──────
//
// Starting the server here means connect_or_spawn sees the socket in < 100 ms
// regardless of how long auth + model prefetch take. The `ready_rx` gate inside
@ -1092,7 +1118,7 @@ pub async fn run_leader(
}
});
// ── Phase 4: Wait for socket to appear (fast: < 100 ms now) ──────────────
// ── Phase 5: Wait for socket to appear (fast: < 100 ms now) ──────────────
let socket_ready_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
while !crate::leader::listener_is_ready(&socket_path) {
if tokio::time::Instant::now() >= socket_ready_deadline {
@ -1105,42 +1131,8 @@ pub async fn run_leader(
}
debug!("IPC socket created");
// ── Phase 5: Lock handoff ─────────────────────────────────────────────────
//
// (a) lock_already_held=true: We acquired the lock at startup. Keep it.
// (b) lock_already_held=false: spawner holds lock, waiting for our socket.
// Now that socket is up, the spawner will see it, connect, and release
// the lock. We acquire it here (30 s timeout).
let _lock = if lock_already_held {
info!("Leader lock already held from startup, PID already written");
lock
} else {
const LEADER_LOCK_TIMEOUT: Duration = Duration::from_secs(30);
// spawn_blocking so we don't stall the async runtime while waiting.
let lock_result = tokio::task::spawn_blocking(move || {
lock.try_acquire_timeout(LEADER_LOCK_TIMEOUT)?;
lock.write_pid()?;
Ok::<_, anyhow::Error>(lock)
})
.await;
match lock_result {
Ok(Ok(lock)) => {
info!("Leader lock acquired, PID written");
lock
}
Ok(Err(e)) => {
warn!(error = ?e, "Failed to acquire leader lock");
cancel.cancel();
return Err(anyhow::anyhow!("Failed to acquire leader lock: {}", e));
}
Err(e) => {
warn!(error = ?e, "Lock task panicked");
cancel.cancel();
return Err(anyhow::anyhow!("Lock task failed: {}", e));
}
}
};
// Keep `lock` alive so its `Drop` removes the lock + socket on exit.
let _lock = lock;
// ── Phase 6: Auth + model prefetch ───────────────────────────────────────
//

View file

@ -3073,6 +3073,9 @@ impl acp::Agent for MvpAgent {
crate::session::commands::PromptCompletionKind::MaxTurnsReached {
..
} => Some("max_turns_reached".to_string()),
crate::session::commands::PromptCompletionKind::StationarityEnded => {
Some("action_stationarity".to_string())
}
_ => None,
};
Ok(

View file

@ -272,6 +272,81 @@ impl MvpAgent {
}
});
}
/// Push a fresh legacy managed-MCP catalog into live sessions' per-session
/// `McpServers` (called after `mcp/list` with `cache=false`).
///
/// The per-session `merge_managed_mcp_servers` re-reads disk, so the whole
/// broadcast is deferred off the `mcp/list` response-latency path via
/// `spawn_local`. This ONLY re-merges/pushes connectors; rebuilding the
/// agent-level gateway catalog's `search_tool` index is a separate,
/// independently-gated broadcast (see `refresh_mcp_search_index_in_sessions`),
/// because the two run in mutually-exclusive modes (legacy fetch only when
/// gateway tools are OFF, gateway fetch only when ON).
/// Caller must confirm the managed fetch succeeded (cache `Ready`) first: a
/// failed fetch returns an empty vec and syncing it tears down live servers.
pub(crate) fn sync_fresh_managed_mcp_to_sessions(
&self,
managed: &[crate::session::managed_mcp::ManagedMcpConfig],
) {
let sessions: Vec<_> = self
.sessions
.borrow()
.values()
.map(|handle| (
handle.cmd_tx.clone(),
handle.info.cwd.clone(),
handle.initial_client_mcp_servers.clone(),
))
.collect();
if sessions.is_empty() {
return;
}
let compat = self.cfg.borrow().compat_resolved;
let plugin_snapshot = self.plugin_registry_handle.snapshot();
let managed = managed.to_vec();
tokio::task::spawn_local(async move {
let mut updated = 0u32;
for (cmd_tx, cwd, initial_client_mcp_servers) in sessions {
let cwd = std::path::PathBuf::from(cwd);
if crate::session::managed_mcp::merge_and_send_managed_mcp_update(
&cmd_tx,
&cwd,
initial_client_mcp_servers,
&managed,
plugin_snapshot.as_deref(),
&compat,
) {
updated += 1;
}
}
if updated > 0 {
tracing::info!(
updated,
managed_count = managed.len(),
"synced fresh managed MCP catalog into live sessions"
);
}
});
}
/// Rebuild `search_tool` in every live session after a fresh gateway tool
/// catalog committed.
///
/// Gateway tools live in the agent-level catalog (not per-session
/// `McpServers`), so a fresh gateway catalog needs a session-side
/// `search_tool` rebuild even though the legacy managed cache stays
/// `NotFetched` in gateway mode. Callers gate on a successful refetch and
/// skip on failure to keep the last-good index.
pub(crate) fn refresh_mcp_search_index_in_sessions(&self) {
let session_txs: Vec<_> = self
.sessions
.borrow()
.values()
.map(|handle| handle.cmd_tx.clone())
.collect();
for tx in session_txs {
let _ = tx.send(SessionCommand::RefreshMcpSearchIndex);
}
}
/// Resolve the launch dir's project-scope trust verdict ONCE and return it
/// with its path.
///

View file

@ -333,20 +333,14 @@ async fn reload_project_servers_after_grant(ctx: ReloadAfterGrant<'_>) {
// MCP: `merge_managed_mcp_servers` re-reads disk + runs
// `filter_untrusted_project_mcp`, which now KEEPS project servers because
// the cached verdict was flipped to trusted (same workspace key).
let merged = crate::session::managed_mcp::merge_managed_mcp_servers(
target.initial_client_mcp_servers,
let _ = crate::session::managed_mcp::merge_and_send_managed_mcp_update(
&target.cmd_tx,
session_cwd,
target.initial_client_mcp_servers,
&managed,
plugin_snapshot.as_deref(),
ctx.compat,
);
let (tx, _rx) = tokio::sync::oneshot::channel();
let _ = target
.cmd_tx
.send(crate::session::SessionCommand::UpdateMcpServers {
mcp_servers: merged,
respond_to: tx,
});
// Plugins (+ plugin-contributed hooks) built for this session's own cwd
// on the folder-trust verdict (mirrors `broadcast_plugin_registry_to_sessions`);
// the grant + resolve_and_record above flipped the cached verdict to trusted.

View file

@ -1606,6 +1606,77 @@ async fn ext_method_routes_auth_cleared_and_refreshes_resident_sessions() {
})
.await;
}
/// Fresh managed catalog sync must push UpdateMcpServers with the injected
/// managed connector. The `search_tool` rebuild is a SEPARATE broadcast
/// (`refresh_mcp_search_index_in_sessions`), so it is not asserted here.
#[tokio::test(flavor = "current_thread")]
async fn sync_fresh_managed_mcp_pushes_update() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let agent = build_agent_with_auth(crate::auth::GrokAuth {
key: "eligible".into(),
auth_mode: crate::auth::AuthMode::WebLogin,
..crate::auth::GrokAuth::test_default()
});
let sid = acp::SessionId::new("sess-managed-sync");
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid, handle);
let managed = vec![crate::session::managed_mcp::ManagedMcpConfig {
name: "Linear".into(),
endpoint: "https://mcp.example.com/linear".into(),
headers: std::collections::HashMap::from([(
"Authorization".into(),
"Bearer tok".into(),
)]),
token_expires_at: None,
scope: None,
scope_id: None,
scope_name: None,
}];
agent.sync_fresh_managed_mcp_to_sessions(&managed);
let first = tokio::time::timeout(std::time::Duration::from_secs(1), cmd_rx.recv())
.await
.expect("UpdateMcpServers should be sent")
.expect("channel should stay open");
let SessionCommand::UpdateMcpServers { mcp_servers, .. } = first else {
panic!("expected UpdateMcpServers as the first synced command");
};
let managed_name = crate::session::managed_mcp::to_managed_name("Linear");
let linear = mcp_servers
.iter()
.find_map(|s| match s {
acp::McpServer::Http(http) if http.name == managed_name => Some(http),
_ => None,
})
.unwrap_or_else(|| {
panic!("merged catalog must contain managed HTTP server {managed_name}")
});
assert!(
linear
.headers
.iter()
.any(|h| h.name == "Authorization" && h.value == "Bearer tok"),
"managed server must carry the injected Authorization header"
);
})
.await;
}
/// The gateway-catalog refresh broadcast pushes `RefreshMcpSearchIndex` to every
/// live session (independent of the legacy managed-connector sync).
#[tokio::test(flavor = "current_thread")]
async fn refresh_mcp_search_index_broadcasts_to_sessions() {
let agent = build_minimal_agent_for_tests();
let sid = acp::SessionId::new("sess-search-index");
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
agent.sessions.borrow_mut().insert(sid, handle);
agent.refresh_mcp_search_index_in_sessions();
let cmd = tokio::time::timeout(std::time::Duration::from_secs(1), cmd_rx.recv())
.await
.expect("RefreshMcpSearchIndex should be sent")
.expect("channel should stay open");
assert!(matches!(cmd, SessionCommand::RefreshMcpSearchIndex));
}
/// Build a minimal MvpAgent suitable for testing extension methods.
fn build_minimal_agent_for_tests() -> MvpAgent {
use crate::agent::config::Config as AgentConfig;

View file

@ -861,7 +861,7 @@ pub(crate) async fn run_shell_child(
}
}
}
let agent_mcp_servers: Vec<_> = if is_plugin_agent {
let agent_mcp_servers: Vec<_> = if !agent_owned_mcp_servers_allowed(is_plugin_agent) {
if !definition.mcp_servers.is_empty() {
tracing::warn!(
agent = %definition.name,
@ -921,19 +921,8 @@ pub(crate) async fn run_shell_child(
})
.collect()
};
let parent_mcp_pool = if is_plugin_agent {
if ctx.parent_mcp_pool.is_some() {
tracing::debug!(
agent = %definition.name,
"skipping MCP pool inheritance for plugin agent"
);
}
None
} else {
ctx.parent_mcp_pool
.take()
.and_then(|pool| filter_pool_by_inheritance(pool, &definition.mcp_inheritance))
};
let parent_mcp_pool =
resolve_inherited_mcp_pool(ctx.parent_mcp_pool.take(), &definition.mcp_inheritance);
let mcp_inherited_count = parent_mcp_pool
.as_ref()
.map(|p| p.len() as u32)

View file

@ -1338,6 +1338,21 @@ fn durable_resume_source_for(
model_id: meta.effective_model_id,
})
}
/// Resolve the MCP pool a child subagent should import from its parent.
///
/// Inheritance applies to **every** agent source (built-in, user, project,
/// and plugin). Plugin agents are not excluded: the parent already connected
/// these servers for the session. Agent-owned `mcpServers` (spawned by the
/// child itself) are handled separately and remain blocked for plugins.
///
/// Returns `None` when there is no parent pool or `inheritance` is
/// [`McpInheritance::None`] (avoids an empty import call downstream).
fn resolve_inherited_mcp_pool(
parent_pool: Option<crate::session::mcp_servers::SharedMcpPool>,
inheritance: &xai_grok_agent::config::McpInheritance,
) -> Option<crate::session::mcp_servers::SharedMcpPool> {
parent_pool.and_then(|pool| filter_pool_by_inheritance(pool, inheritance))
}
/// Apply `McpInheritance` filtering to a parent MCP pool snapshot.
///
/// Returns `None` for `McpInheritance::None` (no pool at all — avoids
@ -1374,6 +1389,14 @@ fn filter_pool_by_inheritance(
}
}
}
/// Whether a subagent may declare its own agent-owned `mcpServers`.
///
/// Plugin agents cannot: untrusted packages must not spawn MCP processes or
/// open network MCP endpoints. Parent-pool inheritance is independent and
/// always available subject to [`McpInheritance`].
fn agent_owned_mcp_servers_allowed(is_plugin_agent: bool) -> bool {
!is_plugin_agent
}
/// 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).

View file

@ -2552,6 +2552,76 @@ fn filter_inheritance_except_all_servers_gives_empty() {
let result = result.expect("Except should return Some");
assert_eq!(result.server_names().count(), 0);
}
#[test]
fn resolve_inherited_pool_all_passes_parent_pool() {
let pool = make_pool(&["github", "atlassian"]);
let result = super::resolve_inherited_mcp_pool(
Some(pool),
&xai_grok_agent::config::McpInheritance::All,
)
.expect("All should return Some");
assert_eq!(pool_names(&result), vec!["atlassian", "github"]);
}
#[test]
fn resolve_inherited_pool_none_returns_none() {
let pool = make_pool(&["github", "atlassian"]);
let result = super::resolve_inherited_mcp_pool(
Some(pool),
&xai_grok_agent::config::McpInheritance::None,
);
assert!(result.is_none());
}
#[test]
fn resolve_inherited_pool_named_filters() {
let pool = make_pool(&["github", "atlassian", "slack"]);
let result = super::resolve_inherited_mcp_pool(
Some(pool),
&xai_grok_agent::config::McpInheritance::Named(vec!["atlassian".into()]),
)
.expect("Named should return Some");
assert_eq!(pool_names(&result), vec!["atlassian"]);
}
#[test]
fn resolve_inherited_pool_missing_parent_returns_none() {
let result = super::resolve_inherited_mcp_pool(
None,
&xai_grok_agent::config::McpInheritance::All,
);
assert!(result.is_none());
}
/// Plugin agents must still inherit the parent pool under default
/// `mcpInheritance: all`. The product rule is: plugins cannot *declare*
/// mcpServers, but they do inherit already-connected parent servers.
#[test]
fn plugin_agents_inherit_parent_mcp_pool_by_default() {
assert!(
!super::agent_owned_mcp_servers_allowed(true),
"plugin agents must not declare agent-owned mcpServers"
);
assert!(
super::agent_owned_mcp_servers_allowed(false),
"non-plugin agents may declare agent-owned mcpServers"
);
let pool = make_pool(&["atlassian", "github"]);
let inherited = super::resolve_inherited_mcp_pool(
Some(pool),
&xai_grok_agent::config::McpInheritance::All,
)
.expect("plugin children inherit parent pool with mcpInheritance=all");
assert_eq!(pool_names(&inherited), vec!["atlassian", "github"]);
}
#[test]
fn plugin_agents_can_opt_out_via_mcp_inheritance_none() {
let pool = make_pool(&["atlassian"]);
let inherited = super::resolve_inherited_mcp_pool(
Some(pool),
&xai_grok_agent::config::McpInheritance::None,
);
assert!(
inherited.is_none(),
"mcpInheritance: none must drop the parent pool for every source"
);
}
fn make_test_skill(
name: &str,
plugin: Option<&str>,