M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
//! Code-navigation eligibility gating and codebase-index management for [`MvpAgent`].
|
||||
//! Co-located child of `mvp_agent` (`use super::*`).
|
||||
|
||||
use super::*;
|
||||
|
||||
impl MvpAgent {
|
||||
/// Parse the `x.ai/codeNavigation.enabled` capability from an initialize
|
||||
/// request. Returns `false` if the field is absent or not `true`.
|
||||
pub(crate) fn parse_code_nav_capability(init: &acp::InitializeRequest) -> bool {
|
||||
init.client_capabilities
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("x.ai/codeNavigation"))
|
||||
.and_then(|v| v.get("enabled"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Start (or reuse) the codebase index for an eligible code-nav request.
|
||||
///
|
||||
/// Returns `Some((handle, was_newly_started))` on success or `None` when
|
||||
/// config/git-root checks prevent starting. The bool is the authoritative
|
||||
/// "first spawn vs reuse" signal threaded up from `CodebaseIndexManager`.
|
||||
///
|
||||
/// This is the narrow `pub(crate)` entry point for lazy index startup
|
||||
/// from `extensions/code_nav.rs`. Callers must verify eligibility with
|
||||
/// [`code_nav_eligibility_for_request`] before calling this.
|
||||
pub(crate) fn start_codebase_index_for_code_nav(
|
||||
&self,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<(std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool)> {
|
||||
let (handle, was_newly_started) = self.resolve_codebase_index(cwd)?;
|
||||
// Pin the index to the requesting session so the Weak in
|
||||
// CodebaseIndexManager doesn't orphan it immediately.
|
||||
if let Some(sid) = session_id {
|
||||
self.session_index_claims
|
||||
.borrow_mut()
|
||||
.insert(sid.clone(), std::sync::Arc::clone(&handle));
|
||||
}
|
||||
Some((handle, was_newly_started))
|
||||
}
|
||||
|
||||
/// Core eligibility check — pure function that accepts explicit client
|
||||
/// context rather than reading global agent state.
|
||||
///
|
||||
/// This is the single place that applies all four gates. Call it via
|
||||
/// [`code_nav_eligibility_for_request`] (leader-mode safe) or
|
||||
/// [`code_nav_eligibility`] (global state, non-leader use only).
|
||||
pub(super) fn code_nav_eligibility_inner(
|
||||
&self,
|
||||
cwd: &std::path::Path,
|
||||
client_type: ClientType,
|
||||
code_nav_enabled: bool,
|
||||
) -> Result<(), CodeNavEligibility> {
|
||||
use crate::agent::config::CodebaseIndexingSetting;
|
||||
|
||||
// Gate 1: client type
|
||||
if !matches!(client_type, ClientType::GrokWeb) {
|
||||
tracing::info!(
|
||||
client_type = ?client_type,
|
||||
gate = "client_type",
|
||||
skip_reason = "client_not_web",
|
||||
"code-nav eligibility check: skipping (client type not eligible)"
|
||||
);
|
||||
return Err(CodeNavEligibility::ClientNotWeb);
|
||||
}
|
||||
|
||||
// Gate 2: capability advertised
|
||||
if !code_nav_enabled {
|
||||
tracing::info!(
|
||||
gate = "capability",
|
||||
skip_reason = "capability_not_advertised",
|
||||
"code-nav eligibility check: skipping (x.ai/codeNavigation.enabled not advertised)"
|
||||
);
|
||||
return Err(CodeNavEligibility::CapabilityNotAdvertised);
|
||||
}
|
||||
|
||||
// Gate 3: config
|
||||
let setting = self.cfg.borrow().features.codebase_indexing.clone();
|
||||
if let CodebaseIndexingSetting::Enabled(false) = &setting {
|
||||
tracing::info!(
|
||||
gate = "config",
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav eligibility check: skipping (codebase_indexing disabled in config)"
|
||||
);
|
||||
return Err(CodeNavEligibility::DisabledByConfig);
|
||||
}
|
||||
|
||||
// Gate 4: git root / config globs
|
||||
let git_root = kigi_workspace::session::git::find_git_root_from_path(cwd).ok();
|
||||
match &setting {
|
||||
CodebaseIndexingSetting::Enabled(true) => {
|
||||
if git_root.is_none() {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
gate = "git_root",
|
||||
skip_reason = "not_git_repo",
|
||||
"code-nav eligibility check: skipping (not inside a git repo)"
|
||||
);
|
||||
return Err(CodeNavEligibility::NotGitRepo);
|
||||
}
|
||||
}
|
||||
CodebaseIndexingSetting::Patterns(_) => {
|
||||
let check_path = git_root.as_deref().unwrap_or(cwd);
|
||||
if !setting.should_index(check_path) {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
gate = "config_globs",
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav eligibility check: skipping (not matched by config globs)"
|
||||
);
|
||||
return Err(CodeNavEligibility::DisabledByConfig);
|
||||
}
|
||||
}
|
||||
CodebaseIndexingSetting::Enabled(false) => {} // handled above
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check eligibility using per-session context (leader-mode safe).
|
||||
///
|
||||
/// When `session_id` is provided, reads the session's own client type
|
||||
/// and code-nav capability — the values that were in effect when that
|
||||
/// specific client created the session. This is correct in leader mode
|
||||
/// where multiple clients share one agent process and `initialize()` is
|
||||
/// called once per connection; the global fields on `MvpAgent` reflect
|
||||
/// only the **last** client to call `initialize()`.
|
||||
///
|
||||
/// Falls back to global agent state when no session_id is given.
|
||||
pub fn code_nav_eligibility_for_request(
|
||||
&self,
|
||||
session_id: Option<&acp::SessionId>,
|
||||
cwd: &std::path::Path,
|
||||
) -> Result<(), CodeNavEligibility> {
|
||||
let session_id = match session_id {
|
||||
Some(sid) => sid,
|
||||
// No session_id: per-client capability cannot be determined without a
|
||||
// session. Reject with SessionRequired rather than fall back to shared
|
||||
// global state. Callers must provide sessionId for x.ai/code/* requests.
|
||||
None => return Err(CodeNavEligibility::SessionRequired),
|
||||
};
|
||||
|
||||
let sessions = self.sessions.borrow();
|
||||
let (client_type, code_nav_enabled) = if let Some(handle) = sessions.get(session_id) {
|
||||
let ct = crate::http::client_type_from_origin(handle.origin_client.as_ref());
|
||||
(ct, handle.code_nav_enabled)
|
||||
} else {
|
||||
// Session not found (evicted/unknown): reject rather than silently
|
||||
// falling back to shared global state — that would reintroduce the
|
||||
// last-client-wins bug for stale session IDs in leader mode.
|
||||
return Err(CodeNavEligibility::SessionRequired);
|
||||
};
|
||||
drop(sessions);
|
||||
self.code_nav_eligibility_inner(cwd, client_type, code_nav_enabled)
|
||||
}
|
||||
|
||||
/// Check eligibility using the stored initialize_request context.
|
||||
///
|
||||
/// **Not safe in leader mode** — reads the last `initialize()` call's
|
||||
/// client_type and capability. Prefer [`code_nav_eligibility_for_request`]
|
||||
/// when a session_id is available.
|
||||
pub fn code_nav_eligibility(&self, cwd: &std::path::Path) -> Result<(), CodeNavEligibility> {
|
||||
let client_type = *self.client_type.borrow();
|
||||
let code_nav_enabled = self.code_nav_enabled.get();
|
||||
self.code_nav_eligibility_inner(cwd, client_type, code_nav_enabled)
|
||||
}
|
||||
|
||||
/// Resolve and get-or-create the codebase index for `cwd`, applying config
|
||||
/// and git-root eligibility checks.
|
||||
///
|
||||
/// Returns `Some((handle, was_newly_started))` when an index is available,
|
||||
/// `None` when config or git-root checks rule it out. The bool is the
|
||||
/// authoritative "was this a first spawn?" signal from the manager.
|
||||
pub(super) fn resolve_codebase_index(
|
||||
&self,
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<(std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool)> {
|
||||
use crate::agent::config::CodebaseIndexingSetting;
|
||||
|
||||
let setting = self.cfg.borrow().features.codebase_indexing.clone();
|
||||
let git_root = kigi_workspace::session::git::find_git_root_from_path(cwd).ok();
|
||||
|
||||
match (&setting, &git_root) {
|
||||
(CodebaseIndexingSetting::Enabled(false), _) => {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav: skipping index creation (disabled in config)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
(CodebaseIndexingSetting::Enabled(true), None) => {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
skip_reason = "not_git_repo",
|
||||
"code-nav: skipping index creation (not inside a git repo)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
(CodebaseIndexingSetting::Patterns(_), _) => {
|
||||
let check_path = git_root.as_deref().unwrap_or(cwd);
|
||||
if !setting.should_index(check_path) {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
skip_reason = "disabled_by_config",
|
||||
"code-nav: skipping index creation (not matched by config globs)"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
(CodebaseIndexingSetting::Enabled(true), Some(_)) => {}
|
||||
}
|
||||
|
||||
let target = git_root.unwrap_or_else(|| cwd.to_path_buf());
|
||||
// get_or_create returns the authoritative (handle, was_newly_started) pair.
|
||||
// Log only on actual first spawn so reuse requests are not misleadingly
|
||||
// labelled as "starting".
|
||||
let (handle, was_newly_started) = self.get_or_create_codebase_index(target.clone());
|
||||
if was_newly_started {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
index_target = %target.display(),
|
||||
event = "index_first_spawn",
|
||||
"code-nav: first lazy spawn of codebase index"
|
||||
);
|
||||
}
|
||||
Some((handle, was_newly_started))
|
||||
}
|
||||
|
||||
pub(super) fn indexed_roots_for(&self, cwd: &std::path::Path) -> Vec<String> {
|
||||
if self.get_codebase_index(cwd).is_some() {
|
||||
return vec![cwd.to_string_lossy().into_owned()];
|
||||
}
|
||||
if let Ok(git_root) = kigi_workspace::session::git::find_git_root_from_path(cwd)
|
||||
&& self.get_codebase_index(&git_root).is_some()
|
||||
{
|
||||
return vec![git_root.to_string_lossy().into_owned()];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Returns `(handle, was_newly_started)` — the bool is the authoritative
|
||||
/// "did this call spawn a new actor?" bit from `CodebaseIndexManager::get_or_create`.
|
||||
pub(super) fn get_or_create_codebase_index(
|
||||
&self,
|
||||
cwd: PathBuf,
|
||||
) -> (std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>, bool) {
|
||||
self.codebase_indexes.lock().get_or_create(cwd)
|
||||
}
|
||||
|
||||
/// Get an existing codebase index for the given cwd.
|
||||
/// Returns None if no index exists for this cwd.
|
||||
pub fn get_codebase_index(
|
||||
&self,
|
||||
cwd: &std::path::Path,
|
||||
) -> Option<std::sync::Arc<kigi_codebase_graph::IndexManagerHandle>> {
|
||||
self.codebase_indexes.lock().get(cwd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Interactive folder-trust prompt: a dormant agent→GUI-client ACP round-trip
|
||||
//! (`x.ai/folder_trust/request`) that asks a GUI client (grok-desktop) to decide
|
||||
//! trust for an untrusted-with-configs workspace, then grants + reloads the
|
||||
//! now-trusted project servers without a restart.
|
||||
//!
|
||||
//! DORMANT in production: it only fires when the connected client advertised
|
||||
//! `x.ai/folderTrust.interactive` AND the folder-trust feature flag is on AND the
|
||||
//! verdict is [`kigi_workspace::folder_trust::TrustOutcome::Prompt`]. No
|
||||
//! client advertises the capability until the desktop UI ships — so this is
|
||||
//! inert by default even with the feature flag on. The TUI/headless clients never
|
||||
//! advertise it (they self-gate trust client-side), so they are never
|
||||
//! double-prompted. Co-located child of `mvp_agent` (`use super::*`).
|
||||
//!
|
||||
//! Post-grant reload scope: MCP, plugins, and each session's own project hooks
|
||||
//! are hot-reloaded in place — for EVERY session sharing the granted workspace
|
||||
//! (same `workspace_key`), each reloaded against its OWN cwd. Project LSP is NOT
|
||||
//! hot-reloaded — the LSP backend is baked into the agent's tool bridge at build
|
||||
//! time (one-shot startup coordinator, no in-place reconfigure API), so repo-local
|
||||
//! `.kigi/lsp.json` servers start on the NEXT session open (the durable grant
|
||||
//! makes the re-spawn trusted). `lsp` is still REPORTED in the prompt's
|
||||
//! `configKinds` (it is a real reason the folder is gated) — only the post-grant
|
||||
//! hot-reload skips it.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Max wait for a GUI client's trust decision before giving up (fail-closed).
|
||||
/// Generous because it is a human decision, but bounds the detached task so a
|
||||
/// connected-but-silent client (modal left open / client bug) can't leak it for
|
||||
/// the whole connection lifetime.
|
||||
const TRUST_PROMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
|
||||
|
||||
/// ACP `x.ai/folder_trust/request` payload (agent → GUI client). Serialized as
|
||||
/// `camelCase` for the ACP JSON-RPC wire format.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FolderTrustRequest {
|
||||
/// The session this prompt belongs to. REQUIRED for leader Tier-2 routing:
|
||||
/// non-interaction reverse-requests are delivered to the driver keyed on
|
||||
/// `params.sessionId`; omitting it makes the leader silently drop the message,
|
||||
/// so the prompt would never reach the client.
|
||||
pub session_id: String,
|
||||
/// The session cwd whose workspace is being gated.
|
||||
pub cwd: String,
|
||||
/// Display path of the canonical workspace key (the trust grant's scope).
|
||||
pub workspace: String,
|
||||
/// Detected repo-local config kinds (e.g. `mcp`, `hooks`, `lsp`) — the
|
||||
/// reasons the folder is gated — for the prompt UI. Display-only, NOT the
|
||||
/// trust gate; derived from the same scan as the gate. `lsp` may appear: it
|
||||
/// is a real reason to prompt, but project LSP applies on the NEXT session
|
||||
/// open rather than hot-reloading on grant (see module docs).
|
||||
pub config_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
/// Outcome of the trust prompt (GUI client → agent). Fail-closed: any value
|
||||
/// other than `"trust"` (including unknown strings, via `#[serde(other)]`)
|
||||
/// decodes to [`FolderTrustOutcome::Reject`], so only an explicit grant unblocks.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum FolderTrustOutcome {
|
||||
Trust,
|
||||
#[serde(other)]
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// ACP `x.ai/folder_trust/request` response (GUI client → agent).
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub(crate) struct FolderTrustResponse {
|
||||
pub outcome: FolderTrustOutcome,
|
||||
}
|
||||
|
||||
impl MvpAgent {
|
||||
/// Parse the `x.ai/folderTrust.interactive` capability from an initialize
|
||||
/// request. Returns `false` if absent or not `true`. Mirrors
|
||||
/// [`Self::parse_code_nav_capability`].
|
||||
pub(crate) fn parse_interactive_trust_capability(init: &acp::InitializeRequest) -> bool {
|
||||
init.client_capabilities
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("x.ai/folderTrust"))
|
||||
.and_then(|v| v.get("interactive"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Ask a GUI client to decide trust for `session_id`'s workspace, then grant
|
||||
/// + reload on accept. DORMANT no-op unless the client advertised
|
||||
/// `x.ai/folderTrust.interactive` AND [`folder_trust::prompt_warranted`]
|
||||
/// (feature on + untrusted + repo configs present).
|
||||
///
|
||||
/// Non-blocking: the session was already created with project servers GATED
|
||||
/// (the untrusted resolve in `new_session`/`load_session`), so nothing
|
||||
/// repo-local spawns while the prompt is open. The round-trip + reload run in
|
||||
/// a detached `spawn_local` task, so the `new_session` response is not
|
||||
/// delayed by the (potentially long) user decision. At most one outstanding
|
||||
/// request per workspace per process (dedup), and the await is bounded by
|
||||
/// [`TRUST_PROMPT_TIMEOUT`].
|
||||
pub(crate) fn maybe_spawn_interactive_trust_prompt(
|
||||
&self,
|
||||
session_id: &acp::SessionId,
|
||||
cwd: &std::path::Path,
|
||||
remote: Option<&crate::util::config::RemoteSettings>,
|
||||
) {
|
||||
if !self.interactive_trust_client.get() {
|
||||
return;
|
||||
}
|
||||
if !folder_trust::prompt_warranted(cwd, remote) {
|
||||
return;
|
||||
}
|
||||
let key = kigi_workspace::trust::workspace_key(cwd);
|
||||
// Dedup: skip if this workspace was already prompted/decided (reconnect)
|
||||
// or has a prompt in flight (concurrent same-workspace session). `insert`
|
||||
// returns false when already present. Agent-owned set (no process
|
||||
// global), captured into the task for release on failure/timeout.
|
||||
let prompted = self.interactive_trust_prompted.clone();
|
||||
if !prompted.borrow_mut().insert(key.clone()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture EVERY session sharing the GRANTED WORKSPACE (same
|
||||
// `workspace_key` — the grant's actual scope, aligned with the dedup key),
|
||||
// each with its OWN cwd, so a grant reloads every sibling against its own
|
||||
// project config — exactly like the per-cwd `handle_reload_project_mcp_servers`
|
||||
// / `broadcast_plugin_registry_to_sessions`. `&self` can't be borrowed
|
||||
// across the `spawn_local` boundary, so capture owned clones now.
|
||||
//
|
||||
// INTENTIONAL fail-safe limitation: this is a one-time snapshot taken at
|
||||
// prompt-spawn. A same-workspace session created WHILE the modal is open is
|
||||
// deduped (no second prompt) and is not in this set, so the grant won't
|
||||
// reload it — it stays GATED until its own next session (secure, never
|
||||
// over-exposed). Re-querying at grant time would need the `sessions` map
|
||||
// (a non-`Rc` `RefCell` field) shared into the detached task, which isn't
|
||||
// available here; the fail-safe stale-session window is accepted instead.
|
||||
let targets: Vec<ReloadTarget> = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.values()
|
||||
.filter(|h| {
|
||||
kigi_workspace::trust::workspace_key(std::path::Path::new(&h.info.cwd)) == key
|
||||
})
|
||||
.map(|h| ReloadTarget {
|
||||
cmd_tx: h.cmd_tx.clone(),
|
||||
initial_client_mcp_servers: h.initial_client_mcp_servers.clone(),
|
||||
cwd: PathBuf::from(&h.info.cwd),
|
||||
})
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
|
||||
let gateway = self.gateway.clone();
|
||||
let plugin_handle = self.plugin_registry_handle.clone();
|
||||
let managed_mcp_cache = self.managed_mcp_cache.clone();
|
||||
let auth_manager = self.auth_manager.clone();
|
||||
let can_fetch_managed = self.can_fetch_managed_mcps();
|
||||
let proxy_url = self.cfg.borrow().endpoints.proxy_url();
|
||||
let compat = self.cfg.borrow().compat_resolved;
|
||||
let remote = remote.cloned();
|
||||
let cwd = cwd.to_path_buf();
|
||||
let workspace = key.display().to_string();
|
||||
let config_kinds = folder_trust::detected_config_kinds(&cwd);
|
||||
let session_id = session_id.0.to_string();
|
||||
// Regression guard: every reverse-request must carry a
|
||||
// non-empty sessionId or leader Tier-2 routing silently drops it.
|
||||
debug_assert!(
|
||||
!session_id.is_empty(),
|
||||
"folder_trust reverse-request must carry a non-empty sessionId (design §5.4)"
|
||||
);
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
let request = FolderTrustRequest {
|
||||
session_id,
|
||||
cwd: cwd.to_string_lossy().into_owned(),
|
||||
workspace,
|
||||
config_kinds,
|
||||
};
|
||||
// Non-panicking: a struct of String/Vec<String> can't fail to
|
||||
// serialize, but avoid `expect` in prod — bail (and release the dedup
|
||||
// key) on the impossible error rather than aborting the task thread.
|
||||
let raw_params = match serde_json::value::to_raw_value(&request) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "folder trust: request serialization failed");
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let ext_request = acp::ExtRequest::new("x.ai/folder_trust/request", raw_params.into());
|
||||
|
||||
use agent_client_protocol::Client as _;
|
||||
let outcome = match tokio::time::timeout(
|
||||
TRUST_PROMPT_TIMEOUT,
|
||||
gateway.ext_method(ext_request),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// A decodable response carries the user's decision. An
|
||||
// undecodable success payload is a client/protocol error, not a
|
||||
// decision: stay gated (fail-closed) but release the dedup key so
|
||||
// a later session can re-prompt — same as transport/timeout below.
|
||||
Ok(Ok(raw)) => match serde_json::from_str::<FolderTrustResponse>(raw.0.get()) {
|
||||
Ok(r) => r.outcome,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "folder trust: undecodable trust response; staying gated, releasing dedup key");
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
},
|
||||
Ok(Err(e)) => {
|
||||
// Client disconnected / transport error: not a decision —
|
||||
// release the key so a later session can re-prompt.
|
||||
tracing::debug!(error = %e, "folder trust: client trust request failed");
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// Connected but silent past the deadline: stay gated, release
|
||||
// the key so a future session may re-prompt.
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: no client decision before timeout; staying gated"
|
||||
);
|
||||
prompted.borrow_mut().remove(&key);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if outcome != FolderTrustOutcome::Trust {
|
||||
// Decided "reject": keep the dedup key so the user is not
|
||||
// re-prompted for this workspace on every reconnect.
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: GUI client declined; workspace stays gated"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check the dedup key before granting. `HooksAction::Untrust`
|
||||
// removes this workspace's key (and revokes asynchronously) when the
|
||||
// user untrusts. If that fired while the modal was open, the key is
|
||||
// gone — honor the untrust and drop this now-stale "trust" rather
|
||||
// than re-persisting a grant the user just revoked. The single-
|
||||
// threaded LocalSet makes this check + grant atomic w.r.t. the
|
||||
// untrust task (no await in between).
|
||||
if !prompted.borrow().contains(&key) {
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: workspace untrusted while prompt was open; ignoring stale grant"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the grant, then flip the cached untrusted verdict to trusted
|
||||
// (the `Some(false)` arm of `resolve_and_record` re-reads the store).
|
||||
folder_trust::grant_folder_trust(&cwd);
|
||||
folder_trust::resolve_and_record(&cwd, remote.as_ref(), false);
|
||||
|
||||
reload_project_servers_after_grant(ReloadAfterGrant {
|
||||
gateway: &gateway,
|
||||
targets,
|
||||
plugin_handle: &plugin_handle,
|
||||
managed_mcp_cache: &managed_mcp_cache,
|
||||
auth_manager: &auth_manager,
|
||||
can_fetch_managed,
|
||||
proxy_url: &proxy_url,
|
||||
compat: &compat,
|
||||
prompt_cwd: &cwd,
|
||||
})
|
||||
.await;
|
||||
|
||||
tracing::info!(
|
||||
cwd = %cwd.display(),
|
||||
"folder trust: granted via GUI client; reloaded project servers"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One session to reload after a grant, with ITS OWN cwd (so the MCP merge +
|
||||
/// plugin build use the session's own project config — matching the per-cwd
|
||||
/// canonical reloaders, not the prompt's cwd).
|
||||
struct ReloadTarget {
|
||||
cmd_tx: tokio::sync::mpsc::UnboundedSender<crate::session::SessionCommand>,
|
||||
initial_client_mcp_servers: Vec<acp::McpServer>,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
/// Inputs for [`reload_project_servers_after_grant`], bundled to keep the
|
||||
/// orchestrator free of a long positional arg list.
|
||||
struct ReloadAfterGrant<'a> {
|
||||
gateway: &'a GatewaySender,
|
||||
/// Every session sharing the granted workspace, each with its own cwd.
|
||||
targets: Vec<ReloadTarget>,
|
||||
plugin_handle: &'a kigi_agent::plugins::SharedPluginRegistryHandle,
|
||||
managed_mcp_cache: &'a crate::session::managed_mcp::ManagedMcpStateHandle,
|
||||
auth_manager: &'a std::sync::Arc<AuthManager>,
|
||||
can_fetch_managed: bool,
|
||||
proxy_url: &'a str,
|
||||
compat: &'a kigi_tools::types::CompatConfig,
|
||||
/// The prompting session's cwd — used only for the client catalog push.
|
||||
prompt_cwd: &'a std::path::Path,
|
||||
}
|
||||
|
||||
/// Reload each granted-workspace session's now-trusted project servers in place
|
||||
/// (no restart), driving the canonical primitives the normal spawn/reload paths
|
||||
/// use — PER SESSION CWD, like `handle_reload_project_mcp_servers` /
|
||||
/// `broadcast_plugin_registry_to_sessions`: `fetch_managed_mcp_configs` +
|
||||
/// `merge_managed_mcp_servers` (`SessionCommand::UpdateMcpServers`), `build_for_cwd`
|
||||
/// (`SessionCommand::ReloadPlugins`), and `reload_hooks_impl`
|
||||
/// (`SessionCommand::ReloadHooks`), then push the refreshed MCP catalog. LSP is
|
||||
/// spawn-baked and applies on the next session open (see module docs). Caller
|
||||
/// must have granted + recorded trust first.
|
||||
async fn reload_project_servers_after_grant(ctx: ReloadAfterGrant<'_>) {
|
||||
// Managed (gateway/Toolbox) servers must survive the re-merge; fetch them once
|
||||
// (cwd-independent) via the shared helper (single-sources the auth-key dance
|
||||
// with `MvpAgent::get_managed_mcp_configs`). The plugin MCP snapshot is also
|
||||
// global, so it is fine to reuse across cwds for the merge.
|
||||
let managed = if ctx.can_fetch_managed {
|
||||
crate::session::managed_mcp::fetch_managed_mcp_configs(
|
||||
ctx.managed_mcp_cache,
|
||||
ctx.proxy_url,
|
||||
ctx.auth_manager,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let plugin_snapshot = ctx.plugin_handle.snapshot();
|
||||
|
||||
for target in ctx.targets {
|
||||
// Per-session cwd: a sibling session in a subdir of the granted workspace
|
||||
// must get ITS OWN project config, not the prompt's.
|
||||
let session_cwd = target.cwd.as_path();
|
||||
// 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,
|
||||
session_cwd,
|
||||
&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.
|
||||
let disk_cfg =
|
||||
crate::config::resolve_effective_plugins_config(session_cwd).to_discovery_config();
|
||||
let project_trusted = folder_trust::project_scope_allowed(session_cwd);
|
||||
// Session `_meta.pluginDirs` are re-merged by the receiving actor
|
||||
// (`preserve_session_plugin_dirs` on `ReloadPlugins`).
|
||||
let registry =
|
||||
ctx.plugin_handle
|
||||
.build_for_cwd(session_cwd, &disk_cfg, &[], project_trusted);
|
||||
let _ = target
|
||||
.cmd_tx
|
||||
.send(crate::session::SessionCommand::ReloadPlugins { registry });
|
||||
// The session's OWN project hooks (`.kigi/hooks`, `.cursor/hooks.json`),
|
||||
// which `ReloadPlugins` does NOT touch — re-discovered against the actor's
|
||||
// own `session_info.cwd` on the now-trusted verdict by `reload_hooks_impl`.
|
||||
let _ = target
|
||||
.cmd_tx
|
||||
.send(crate::session::SessionCommand::ReloadHooks);
|
||||
}
|
||||
|
||||
// Push the refreshed MCP catalog (for the prompting session's cwd) so the
|
||||
// client UI reflects the now-trusted repo-local servers.
|
||||
let local = folder_trust::filter_untrusted_project_mcp(
|
||||
ctx.prompt_cwd,
|
||||
crate::util::config::load_mcp_servers(ctx.prompt_cwd, ctx.compat),
|
||||
);
|
||||
crate::extensions::mcp::notify_servers_updated(ctx.gateway, &managed, &local).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn init_with_meta(meta: Option<serde_json::Value>) -> acp::InitializeRequest {
|
||||
// Production reads `client_capabilities.meta`, not top-level request meta.
|
||||
let mut caps = acp::ClientCapabilities::new()
|
||||
.fs(acp::FileSystemCapabilities::new())
|
||||
.terminal(false);
|
||||
if let Some(m) = meta
|
||||
&& let Some(map) = m.as_object().cloned()
|
||||
{
|
||||
caps = caps.meta(map);
|
||||
}
|
||||
acp::InitializeRequest::new(acp::ProtocolVersion::V1).client_capabilities(caps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_interactive_trust_capability_present_and_true() {
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert(
|
||||
"x.ai/folderTrust".to_string(),
|
||||
serde_json::json!({ "interactive": true }),
|
||||
);
|
||||
let init = init_with_meta(Some(serde_json::Value::Object(meta)));
|
||||
assert!(MvpAgent::parse_interactive_trust_capability(&init));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_interactive_trust_capability_absent_returns_false() {
|
||||
let init = init_with_meta(None);
|
||||
assert!(!MvpAgent::parse_interactive_trust_capability(&init));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_interactive_trust_capability_false_returns_false() {
|
||||
let mut meta = serde_json::Map::new();
|
||||
meta.insert(
|
||||
"x.ai/folderTrust".to_string(),
|
||||
serde_json::json!({ "interactive": false }),
|
||||
);
|
||||
let init = init_with_meta(Some(serde_json::Value::Object(meta)));
|
||||
assert!(!MvpAgent::parse_interactive_trust_capability(&init));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_camel_case_with_session_id() {
|
||||
let req = FolderTrustRequest {
|
||||
session_id: "sess-1".into(),
|
||||
cwd: "/repo".into(),
|
||||
workspace: "/repo".into(),
|
||||
config_kinds: vec!["mcp".into()],
|
||||
};
|
||||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert!(json.get("configKinds").is_some());
|
||||
assert!(json.get("config_kinds").is_none());
|
||||
// Leader Tier-2 routing reads `params.sessionId`; it must be present and
|
||||
// non-empty (regression guard for the silently-dropped-in-leader bug).
|
||||
assert_eq!(json["sessionId"], "sess-1");
|
||||
assert!(!json["sessionId"].as_str().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_decodes_trust_reject_and_unknown_fail_closed() {
|
||||
let trust: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"trust"}"#).unwrap();
|
||||
assert_eq!(trust.outcome, FolderTrustOutcome::Trust);
|
||||
let reject: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"reject"}"#).unwrap();
|
||||
assert_eq!(reject.outcome, FolderTrustOutcome::Reject);
|
||||
// Unknown outcome must fail closed to Reject (never silently "trust").
|
||||
let unknown: FolderTrustResponse = serde_json::from_str(r#"{"outcome":"banana"}"#).unwrap();
|
||||
assert_eq!(unknown.outcome, FolderTrustOutcome::Reject);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
use super::{PromptResponseMetaArgs, build_prompt_response_meta};
|
||||
use kigi_sampling_types::TokenUsage;
|
||||
|
||||
/// Baseline args with no usage, cancellation, or structured output.
|
||||
fn args<'a>(
|
||||
session_id: &'a str,
|
||||
prompt_id: &'a str,
|
||||
total_tokens: u64,
|
||||
model_id: &'a str,
|
||||
) -> PromptResponseMetaArgs<'a> {
|
||||
PromptResponseMetaArgs {
|
||||
session_id,
|
||||
prompt_id,
|
||||
total_tokens,
|
||||
model_id,
|
||||
last_turn_usage: None,
|
||||
prompt_usage: None,
|
||||
cancellation_category: None,
|
||||
cancel_trigger: None,
|
||||
structured_output: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_baseline_keys_without_usage() {
|
||||
let meta = build_prompt_response_meta(args("sess-1", "prompt-1", 42_000, "grok-4.5"));
|
||||
assert_eq!(meta["sessionId"], "sess-1");
|
||||
assert_eq!(meta["requestId"], "prompt-1");
|
||||
assert_eq!(meta["promptId"], "prompt-1");
|
||||
assert_eq!(meta["totalTokens"], 42_000);
|
||||
assert_eq!(meta["modelId"], "grok-4.5");
|
||||
// No per-turn keys when usage is absent.
|
||||
assert!(meta.get("inputTokens").is_none());
|
||||
assert!(meta.get("outputTokens").is_none());
|
||||
assert!(meta.get("cachedReadTokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enriches_meta_with_camelcase_token_keys() {
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: 1500,
|
||||
completion_tokens: 200,
|
||||
total_tokens: 1700,
|
||||
reasoning_tokens: 75,
|
||||
cached_prompt_tokens: 1000,
|
||||
};
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
last_turn_usage: Some(&usage),
|
||||
..args("sess-1", "prompt-1", 1_700, "grok-4.5")
|
||||
});
|
||||
// Bot's _META_TOKEN_KEY_MAP expects exactly these camelCase keys.
|
||||
assert_eq!(meta["inputTokens"], 1500);
|
||||
assert_eq!(meta["outputTokens"], 200);
|
||||
assert_eq!(meta["cachedReadTokens"], 1000);
|
||||
// Reasoning tokens carried through for diagnostic visibility.
|
||||
assert_eq!(meta["reasoningTokens"], 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_zero_token_values() {
|
||||
// Responses API hits with no cache return cached_prompt_tokens=0.
|
||||
// The key is still emitted as 0 so the bot can distinguish "no cache
|
||||
// hit" from "no usage data". (The bot's _merge_meta_usage requires
|
||||
// the key to be present and integer-typed.)
|
||||
let usage = TokenUsage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 110,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 0,
|
||||
};
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
last_turn_usage: Some(&usage),
|
||||
..args("s", "p", 110, "m")
|
||||
});
|
||||
assert_eq!(meta["cachedReadTokens"], 0);
|
||||
assert_eq!(meta["reasoningTokens"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_object_lands_on_meta() {
|
||||
let mut ledger = kigi_chat_state::UsageLedger::default();
|
||||
ledger.record_main_loop_call(
|
||||
"m",
|
||||
&TokenUsage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 999_999,
|
||||
reasoning_tokens: 0,
|
||||
cached_prompt_tokens: 0,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
prompt_usage: Some(crate::extensions::notification::PromptUsage::from(&ledger)),
|
||||
..args("s", "p", 110, "m")
|
||||
});
|
||||
assert_eq!(meta["usage"]["totalTokens"], 110);
|
||||
assert_eq!(meta["usage"]["modelUsage"]["m"]["inputTokens"], 100);
|
||||
assert!(
|
||||
build_prompt_response_meta(args("s", "p", 0, "m"))
|
||||
.get("usage")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_trigger_lands_as_camelcase_meta_key() {
|
||||
// A send-now cancelled turn's PromptResponse `_meta` carries `cancelTrigger: "send_now"`.
|
||||
let meta = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
cancel_trigger: Some("send_now".to_string()),
|
||||
..args("s", "p", 0, "m")
|
||||
});
|
||||
assert_eq!(meta["cancelTrigger"], "send_now");
|
||||
|
||||
// Absent for non-cancel completions — the key must not appear.
|
||||
let none = build_prompt_response_meta(args("s", "p", 0, "m"));
|
||||
assert!(none.get("cancelTrigger").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_maps_to_camelcase_meta_keys() {
|
||||
// Success carries the validated value under `structuredOutput`; no error key.
|
||||
let ok = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
structured_output: Some(Ok(serde_json::json!({"name": "ada"}))),
|
||||
..args("s", "p", 0, "m")
|
||||
});
|
||||
assert_eq!(ok["structuredOutput"]["name"], "ada");
|
||||
assert!(ok.get("structuredOutputError").is_none());
|
||||
|
||||
// Failure carries the message under `structuredOutputError`; no value key.
|
||||
let err = build_prompt_response_meta(PromptResponseMetaArgs {
|
||||
structured_output: Some(Err("output does not match the required schema".to_string())),
|
||||
..args("s", "p", 0, "m")
|
||||
});
|
||||
assert_eq!(
|
||||
err["structuredOutputError"],
|
||||
"output does not match the required schema"
|
||||
);
|
||||
assert!(err.get("structuredOutput").is_none());
|
||||
|
||||
// No schema requested → neither key present.
|
||||
let none = build_prompt_response_meta(args("s", "p", 0, "m"));
|
||||
assert!(none.get("structuredOutput").is_none());
|
||||
assert!(none.get("structuredOutputError").is_none());
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! Session lifecycle, roster deltas, and the idle-session supervisor for [`MvpAgent`].
|
||||
//! Co-located `#[path]`-style child of `mvp_agent` (`use super::*`) so the `impl`
|
||||
//! block keeps access to `MvpAgent`'s private fields.
|
||||
use super::*;
|
||||
impl MvpAgent {
|
||||
/// Ask a live session actor to shut down.
|
||||
pub(crate) fn request_session_shutdown(&self, id: &acp::SessionId) {
|
||||
if let Some(handle) = self.sessions.borrow().get(id) {
|
||||
let _ = handle.cmd_tx.send(SessionCommand::Shutdown);
|
||||
}
|
||||
}
|
||||
/// Finalize the cloud session replica (fire-and-forget, "Hook 4").
|
||||
///
|
||||
/// Marks the session **done** upstream, so this MUST only run on a genuine
|
||||
/// session end — a terminal/explicit close (`x.ai/session/close`). It must
|
||||
/// NOT run on a mere client disconnect or a dead-actor reap: those leave the
|
||||
/// conversation resumable on disk, and finalizing would wrongly mark a still
|
||||
/// running/resumable session "done".
|
||||
pub(super) fn finalize_session_replica(&self, id: &acp::SessionId) {
|
||||
#[cfg(test)]
|
||||
self.finalize_spy.borrow_mut().push(id.0.to_string());
|
||||
if let Some(client) = self.session_registry_client() {
|
||||
let sid = id.0.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = client.finalize(&sid).await {
|
||||
tracing::warn!(
|
||||
error = % e, "session registry finalize failed (non-fatal)"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Remove a session and its thread handle **without** finalizing the cloud
|
||||
/// replica.
|
||||
///
|
||||
/// Used for dead-actor reaping and idle-unload: the conversation stays
|
||||
/// resumable on disk, so it must NOT be marked "done" upstream. Genuine
|
||||
/// terminal closes go through [`MvpAgent::close_session_explicit`]. Also
|
||||
/// drops the `session_live_state` entry so that map stays bounded.
|
||||
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
|
||||
self.sessions.borrow_mut().remove(id);
|
||||
self.prompt_intake_locks.borrow_mut().remove(id);
|
||||
self.session_threads.borrow_mut().remove(id);
|
||||
self.session_index_claims.borrow_mut().remove(id);
|
||||
self.require_gateway_sessions.borrow_mut().remove(id);
|
||||
self.session_live_state.borrow_mut().remove(id);
|
||||
}
|
||||
/// Get-or-create the per-session prompt-intake lock (see
|
||||
/// [`Self::prompt_intake_locks`]). Cheap clone of the shared `Rc`.
|
||||
pub(super) fn prompt_intake_lock(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
) -> std::rc::Rc<tokio::sync::Mutex<()>> {
|
||||
self.prompt_intake_locks
|
||||
.borrow_mut()
|
||||
.entry(id.clone())
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
/// Close a session in response to an **explicit** terminal close
|
||||
/// (`x.ai/session/close`). Finalizes the cloud replica (genuine session
|
||||
/// end), then removes the session terminally as `Completed`.
|
||||
pub(crate) fn close_session_explicit(&self, id: &acp::SessionId) {
|
||||
self.finalize_session_replica(id);
|
||||
self.remove_session_terminal(id, SessionLiveState::Completed);
|
||||
}
|
||||
/// Record the coarse lifecycle state for a session.
|
||||
pub(super) fn set_session_live_state(&self, id: &acp::SessionId, state: SessionLiveState) {
|
||||
self.session_live_state
|
||||
.borrow_mut()
|
||||
.insert(id.clone(), state);
|
||||
}
|
||||
/// Read the recorded lifecycle state for a session (test observability).
|
||||
#[cfg(test)]
|
||||
pub(super) fn session_live_state_for(&self, id: &acp::SessionId) -> Option<SessionLiveState> {
|
||||
self.session_live_state.borrow().get(id).copied()
|
||||
}
|
||||
/// Roster-delta hook for a terminally removed session. Broadcasts an
|
||||
/// `x.ai/sessions/changed` notification with the session in `removed` so
|
||||
/// every attached dashboard drops the row promptly. Also
|
||||
/// records the call site (and the terminal state) for test observability,
|
||||
/// since the `session_live_state` entry is dropped on removal.
|
||||
pub(super) fn record_roster_delta(&self, id: &acp::SessionId, final_state: SessionLiveState) {
|
||||
#[cfg(test)]
|
||||
self.roster_delta_spy
|
||||
.borrow_mut()
|
||||
.push((id.0.to_string(), final_state));
|
||||
tracing::debug!(
|
||||
session_id = % id.0, ? final_state, "roster delta: session removed"
|
||||
);
|
||||
self.emit_roster_changed(Vec::new(), vec![id.0.to_string()]);
|
||||
}
|
||||
/// Roster-delta hook for a newly-resident / changed session. Broadcasts an
|
||||
/// `x.ai/sessions/changed` notification with the current entry in
|
||||
/// `upserted` so dashboards add/refresh the row.
|
||||
pub(crate) fn push_roster_delta_upserted(&self, id: &acp::SessionId) {
|
||||
if let Some(entry) = self.resident_roster_entry(id) {
|
||||
self.emit_roster_changed(vec![entry], Vec::new());
|
||||
}
|
||||
}
|
||||
/// Emit an `x.ai/sessions/changed` upsert for a resident session with an
|
||||
/// explicit `activity`, so every attached dashboard reflects a
|
||||
/// turn-boundary transition (Working / Idle / NeedsInput) *immediately*
|
||||
/// rather than waiting for the ≤1s roster poll (deltas are emitted
|
||||
/// at turn-start/turn-end). Without this, a viewer client that holds no
|
||||
/// local `AgentView` for the session only learns its activity from the
|
||||
/// poll, so a turn driven by another client shows as `Idle` for up to a
|
||||
/// poll interval — and not at all while that viewer's poll is dormant.
|
||||
///
|
||||
/// The `activity` is supplied by the caller rather than read from
|
||||
/// `resident_activity` because at turn-start the actor may not have
|
||||
/// published `current_prompt_id` yet (it is set asynchronously once the
|
||||
/// actor dequeues the `SessionCommand::Prompt`), so a natural read would
|
||||
/// still observe `Idle`. The authoritative entry (cwd / worktree / model /
|
||||
/// yolo) is built by `resident_roster_entry`, so it never diverges from
|
||||
/// the polled entry; only the `activity` field is overridden.
|
||||
pub(super) fn push_roster_activity_delta(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
activity: crate::agent::roster::RosterActivity,
|
||||
) {
|
||||
if let Some(mut entry) = self.resident_roster_entry(id) {
|
||||
entry.activity = activity;
|
||||
self.emit_roster_changed(vec![entry], Vec::new());
|
||||
}
|
||||
}
|
||||
/// Fan an `x.ai/sessions/changed` delta out to every attached client.
|
||||
///
|
||||
/// This is a roster-wide notification (no `sessionId`), so the leader IPC
|
||||
/// server broadcasts it to all clients rather than routing by session (see
|
||||
/// the `x.ai/sessions/changed` special-case in `leader/server.rs`).
|
||||
pub(super) fn emit_roster_changed(
|
||||
&self,
|
||||
upserted: Vec<crate::agent::roster::RosterEntry>,
|
||||
removed: Vec<String>,
|
||||
) {
|
||||
if upserted.is_empty() && removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let payload = crate::agent::roster::RosterChanged { upserted, removed };
|
||||
if let Ok(params) = serde_json::value::to_raw_value(&payload) {
|
||||
self.gateway
|
||||
.forward_fire_and_forget(acp::ExtNotification::new(
|
||||
crate::agent::roster::SESSIONS_CHANGED_METHOD,
|
||||
params.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
/// Coarse activity of a resident session for the dashboard status column.
|
||||
///
|
||||
/// Precedence: a non-empty pending-interaction map →
|
||||
/// `NeedsInput` (wins even over a running turn — a session awaiting a
|
||||
/// permission *mid-turn* is "needs input"); else a running turn →
|
||||
/// `Working`; else map the coarse `SessionLiveState`.
|
||||
pub(super) fn resident_activity(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
) -> crate::agent::roster::RosterActivity {
|
||||
use crate::agent::roster::RosterActivity;
|
||||
let (needs_input, turn_running) = self
|
||||
.sessions
|
||||
.borrow()
|
||||
.get(id)
|
||||
.map(|h| {
|
||||
let needs_input = h
|
||||
.pending_interactions
|
||||
.lock()
|
||||
.map(|g| !g.is_empty())
|
||||
.unwrap_or(false);
|
||||
let turn_running = h
|
||||
.current_prompt_id
|
||||
.lock()
|
||||
.map(|g| g.is_some())
|
||||
.unwrap_or(false);
|
||||
(needs_input, turn_running)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
if needs_input {
|
||||
return RosterActivity::NeedsInput;
|
||||
}
|
||||
if turn_running {
|
||||
return RosterActivity::Working;
|
||||
}
|
||||
match self.session_live_state.borrow().get(id).copied() {
|
||||
Some(SessionLiveState::Completed) => RosterActivity::Completed,
|
||||
Some(SessionLiveState::DeadFailed) => RosterActivity::Dead,
|
||||
Some(SessionLiveState::Dormant) => RosterActivity::Dormant,
|
||||
_ => RosterActivity::Idle,
|
||||
}
|
||||
}
|
||||
/// Build a single roster entry for a resident session, or `None` if it is
|
||||
/// not currently resident.
|
||||
pub(super) fn resident_roster_entry(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
) -> Option<crate::agent::roster::RosterEntry> {
|
||||
let session_id = id.0.to_string();
|
||||
let (cwd, is_worktree, model_id, reasoning_effort, yolo) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
let h = sessions.get(id)?;
|
||||
(
|
||||
h.display_cwd.clone().unwrap_or_else(|| h.info.cwd.clone()),
|
||||
h.display_cwd.is_some(),
|
||||
Some(h.model_id.0.to_string()),
|
||||
h.reasoning_effort,
|
||||
h.yolo_mode,
|
||||
)
|
||||
};
|
||||
Some(crate::agent::roster::RosterEntry {
|
||||
title: self
|
||||
.resident_roster_titles
|
||||
.borrow()
|
||||
.get(&session_id)
|
||||
.cloned(),
|
||||
session_id,
|
||||
cwd,
|
||||
is_worktree,
|
||||
model_id,
|
||||
reasoning_effort,
|
||||
yolo,
|
||||
activity: self.resident_activity(id),
|
||||
resident: true,
|
||||
last_change_unix_ms: chrono::Utc::now().timestamp_millis(),
|
||||
origin: crate::agent::roster::RosterOrigin::Local,
|
||||
})
|
||||
}
|
||||
/// Snapshot all resident sessions as roster entries (synchronous; no disk).
|
||||
pub(super) fn resident_roster_entries(&self) -> Vec<crate::agent::roster::RosterEntry> {
|
||||
let ids: Vec<acp::SessionId> = self.sessions.borrow().keys().cloned().collect();
|
||||
ids.iter()
|
||||
.filter_map(|id| self.resident_roster_entry(id))
|
||||
.collect()
|
||||
}
|
||||
/// Build the full roster: resident actors plus recently-touched on-disk
|
||||
/// (`Dormant`) sessions. Resident wins on an id collision; hidden sessions
|
||||
/// are excluded.
|
||||
pub(crate) async fn build_roster(&self) -> Vec<crate::agent::roster::RosterEntry> {
|
||||
let resident = self.resident_roster_entries();
|
||||
let summaries = crate::session::persistence::list_recent_summaries(200)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let entries = crate::agent::roster::merge_roster(resident, summaries);
|
||||
self.cache_resident_titles(&entries);
|
||||
entries
|
||||
}
|
||||
/// Refresh `resident_roster_titles` from the freshly-built roster.
|
||||
pub(super) fn cache_resident_titles(&self, entries: &[crate::agent::roster::RosterEntry]) {
|
||||
*self.resident_roster_titles.borrow_mut() = entries
|
||||
.iter()
|
||||
.filter(|e| e.resident)
|
||||
.filter_map(|e| Some((e.session_id.clone(), e.title.clone()?)))
|
||||
.collect();
|
||||
}
|
||||
/// Terminally remove a session: emit the roster delta with its final state,
|
||||
/// then drop it from all maps (no finalize — callers that need finalize do
|
||||
/// it first, see `close_session_explicit`).
|
||||
pub(super) fn remove_session_terminal(
|
||||
&self,
|
||||
id: &acp::SessionId,
|
||||
final_state: SessionLiveState,
|
||||
) {
|
||||
self.record_roster_delta(id, final_state);
|
||||
self.remove_session(id);
|
||||
}
|
||||
/// Reap a session whose **resident** actor thread exited unexpectedly
|
||||
/// (panic / load failure). Demotes it to `DeadFailed`, emits the roster
|
||||
/// delta, and removes it WITHOUT finalize — the conversation persists on
|
||||
/// disk and stays resumable (reaping a dead actor is harmless;
|
||||
/// it demotes to Dormant).
|
||||
pub(super) fn reap_dead_session(&self, id: &acp::SessionId) {
|
||||
self.remove_session_terminal(id, SessionLiveState::DeadFailed);
|
||||
}
|
||||
/// Sweep `session_threads` for finished threads and clean them up.
|
||||
///
|
||||
/// A finished thread has two distinct meanings, and conflating them
|
||||
/// corrupts the `SessionLiveState` roster source:
|
||||
///
|
||||
/// - **Still resident in `sessions`** → the actor exited unexpectedly while
|
||||
/// the session was hosted (panic / load failure). Reap as `DeadFailed`.
|
||||
/// - **Not resident** (already idle-unloaded → `Dormant`, or explicitly
|
||||
/// closed) → this is the *expected* clean exit. The `SessionThread` was
|
||||
/// kept only so `drain_old_session_thread` could wait on it; now that it
|
||||
/// has finished there is nothing left to drain, so just drop the leftover
|
||||
/// `SessionThread`/state entries. Do **not** demote to `DeadFailed` and do
|
||||
/// **not** emit a second roster delta.
|
||||
///
|
||||
/// `JoinHandle::is_finished()` is non-blocking and cannot distinguish a
|
||||
/// clean exit from a panic on its own, which is exactly why the residency
|
||||
/// check is required. Runs both opportunistically and from the join-handle
|
||||
/// supervisor (`ensure_session_supervisor`).
|
||||
pub(super) fn sweep_dead_sessions(&self) {
|
||||
let dead: Vec<acp::SessionId> = self
|
||||
.session_threads
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(_, t)| t.is_finished())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in dead {
|
||||
if self.sessions.borrow().contains_key(&id) {
|
||||
tracing::warn!(
|
||||
session_id = % id.0,
|
||||
"Resident session actor exited unexpectedly; reaping as DeadFailed"
|
||||
);
|
||||
self.reap_dead_session(&id);
|
||||
} else {
|
||||
self.session_threads.borrow_mut().remove(&id);
|
||||
self.session_live_state.borrow_mut().remove(&id);
|
||||
tracing::debug!(
|
||||
session_id = % id.0,
|
||||
"Reaped finished thread for non-resident session (clean exit)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Start the join-handle supervisor. **Idempotent.**
|
||||
///
|
||||
/// A single `spawn_local` task periodically reaps actor threads that have
|
||||
/// exited (panicked or finished) so a dead actor never lingers as a roster
|
||||
/// zombie. `std::thread::JoinHandle` is not awaitable, so we poll
|
||||
/// `is_finished()` on a tick — the same mechanism `drain_old_session_thread`
|
||||
/// and `sweep_dead_sessions` already use. A panicked actor is therefore
|
||||
/// reaped within one [`SESSION_SUPERVISOR_TICK`].
|
||||
///
|
||||
/// The sweep body is wrapped in `catch_unwind` so a single panicking sweep
|
||||
/// can never terminate the loop (which would silently disable reaping for
|
||||
/// the rest of the process). The task holds a `LocalRef` (raw pointer) to
|
||||
/// `self` for the lifetime of the `LocalSet`; this is sound because the
|
||||
/// agent owns the `LocalSet` and outlives it (same contract as
|
||||
/// `start_subagent_coordinator`), and `LocalRef` is `!Send`.
|
||||
pub(super) fn ensure_session_supervisor(&self) {
|
||||
if self.supervisor_started.replace(true) {
|
||||
return;
|
||||
}
|
||||
#[cfg(test)]
|
||||
self.supervisor_spawn_count
|
||||
.set(self.supervisor_spawn_count.get() + 1);
|
||||
let agent_ref = LocalRef::new(self);
|
||||
tokio::task::spawn_local(async move {
|
||||
loop {
|
||||
tokio::time::sleep(SESSION_SUPERVISOR_TICK).await;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
agent_ref.get().sweep_dead_sessions();
|
||||
}));
|
||||
if result.is_err() {
|
||||
tracing::error!("session supervisor sweep panicked; continuing supervision");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Coarse "any work pending" check for the idle-unload stub.
|
||||
/// Returns `true` while the session has work in flight.
|
||||
///
|
||||
/// Three layers:
|
||||
/// 1. **Fast path (sync):** the shared `current_prompt_id` slot, which the
|
||||
/// actor sets while a turn is running (`maybe_start_running_task`) and
|
||||
/// clears via its RAII guard. A poisoned lock is treated as busy → never
|
||||
/// unload.
|
||||
/// 1b. **Parked plan-approval (sync):** the shared `pending_interactions`
|
||||
/// slot. The parked plan-approval resume re-park is the one outstanding work with no
|
||||
/// running turn, so it needs its own sync check (the same shared-`Arc`
|
||||
/// idiom as `current_prompt_id`) rather than the async round-trip below.
|
||||
/// 2. **Queue check (async):** when no turn is running, the actor is between
|
||||
/// turns and responsive, so we ask it whether `pending_inputs` is
|
||||
/// non-empty (a prompt queued at the turn boundary). This closes the
|
||||
/// sub-tick window where `current_prompt_id` is momentarily `None` but a
|
||||
/// queued input is about to be drained. On timeout we keep the session
|
||||
/// resident (conservative).
|
||||
///
|
||||
/// TODO(PR-4): once the aggregate `SessionActivity` signal exists, also
|
||||
/// consult the autonomous background sources so a detached session is never
|
||||
/// idle-unloaded (→ `Shutdown` → `KillOnDrop`) while they are live:
|
||||
/// `monitor_event_buffer`, pending scheduler fires,
|
||||
/// `ToolContext.background_tasks`, and background subagent sessions. Until
|
||||
/// then those background-only sessions rely on the keep-resident default and
|
||||
/// the `current_prompt_id` auto-wake turn being active.
|
||||
///
|
||||
/// TODO(PR-4): this is also inherently a *check-then-act* across the
|
||||
/// actor-thread boundary — work can arrive (a new `Prompt`/auto-wake) in the
|
||||
/// gap between this `IsBusy` answer and the caller's subsequent `Shutdown`,
|
||||
/// so an idle-unload can still race a just-arrived turn. The actor processes
|
||||
/// its mailbox in order, so the lost work is bounded and recoverable on
|
||||
/// reload; PR-4 closes the gap properly by gating the unload inside the
|
||||
/// actor (a single `Unload`-if-idle command) rather than check-then-send.
|
||||
pub(super) async fn session_has_live_work(&self, id: &acp::SessionId) -> bool {
|
||||
let Some(handle) = self.sessions.borrow().get(id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let turn_running = handle
|
||||
.current_prompt_id
|
||||
.lock()
|
||||
.map(|g| g.is_some())
|
||||
.unwrap_or(true);
|
||||
if turn_running {
|
||||
return true;
|
||||
}
|
||||
if crate::session::pending_interaction::has_parked_plan_approval(
|
||||
&handle.pending_interactions,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
tokio::time::timeout(IDLE_QUERY_TIMEOUT, handle.is_busy())
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//! 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`.
|
||||
use super::*;
|
||||
impl MvpAgent {
|
||||
/// Start the subagent coordinator drain task.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Uses `LocalRef` to reference `self` from
|
||||
/// `spawn_local` closures. 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 {
|
||||
return;
|
||||
};
|
||||
let agent_ref = LocalRef::new(self);
|
||||
use crate::agent::subagent::{BlockWaitSlot, is_running, resolve_snapshot};
|
||||
use kigi_tools::implementations::grok_build::task::types::{
|
||||
SubagentCancelOutcome, SubagentCancelTarget, SubagentEvent,
|
||||
};
|
||||
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 request = *boxed;
|
||||
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 mut ctx = this.build_subagent_spawn_context(&parent_sid);
|
||||
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;
|
||||
});
|
||||
}
|
||||
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());
|
||||
}
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.evict_stale_completed();
|
||||
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) => {
|
||||
let this = agent_ref.get();
|
||||
let outcome = {
|
||||
let mut coord = this.subagent_coordinator.borrow_mut();
|
||||
match request.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
|
||||
}
|
||||
}
|
||||
};
|
||||
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();
|
||||
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 kigi_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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Lightweight context for the `SubagentEvent::ValidateType` drain arm;
|
||||
/// tolerates evicted parent sessions (returns built-in defaults + warns).
|
||||
pub(super) fn build_subagent_validation_context(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> crate::agent::subagent::SubagentValidationContext {
|
||||
let parent_sid = acp::SessionId::new(parent_session_id);
|
||||
let (parent_cwd, allowed_subagent_types) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
let ps = sessions.get(&parent_sid);
|
||||
warn_on_missing_parent_session_for_validate_type(parent_session_id, ps.is_some());
|
||||
(
|
||||
ps.map(|h| std::path::PathBuf::from(&h.info.cwd))
|
||||
.unwrap_or_default(),
|
||||
ps.and_then(|h| h.allowed_subagent_types.clone()),
|
||||
)
|
||||
};
|
||||
let cli_agent_names: Vec<String> = {
|
||||
let cfg = self.cfg.borrow();
|
||||
cfg.cli_agents.iter().map(|d| d.name.clone()).collect()
|
||||
};
|
||||
crate::agent::subagent::SubagentValidationContext {
|
||||
parent_cwd,
|
||||
plugin_registry: self.plugin_registry_handle.snapshot(),
|
||||
subagent_toggle: self.subagent_toggle.clone(),
|
||||
allowed_subagent_types,
|
||||
cli_agent_names,
|
||||
}
|
||||
}
|
||||
/// Build a `SubagentSpawnContext` from the current agent state and the
|
||||
/// parent session's shared resources.
|
||||
///
|
||||
/// This is the ONLY subagent-related method on MvpAgent besides the
|
||||
/// coordinator startup.
|
||||
/// Build a spawn context for a real subagent spawn. The parent session is
|
||||
/// guaranteed present here because the parent just issued the spawn request,
|
||||
/// so a missing parent is a real invariant violation and panics. Read-only
|
||||
/// callers that can race a parent teardown (e.g. `DescribeType`) must use
|
||||
/// [`Self::try_build_subagent_spawn_context`] instead.
|
||||
pub(super) fn build_subagent_spawn_context(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> crate::agent::subagent::SubagentSpawnContext {
|
||||
self.try_build_subagent_spawn_context(parent_session_id)
|
||||
.expect("parent session must exist when spawning subagents")
|
||||
}
|
||||
/// Fallible variant of [`Self::build_subagent_spawn_context`]: returns
|
||||
/// `None` when the parent `SessionHandle` is absent (evicted / torn down)
|
||||
/// instead of panicking, so read-only paths that can race a teardown can
|
||||
/// fail open.
|
||||
pub(super) fn try_build_subagent_spawn_context(
|
||||
&self,
|
||||
parent_session_id: &str,
|
||||
) -> Option<crate::agent::subagent::SubagentSpawnContext> {
|
||||
let parent_sid = acp::SessionId::new(parent_session_id);
|
||||
let (
|
||||
parent_model_id,
|
||||
parent_chat_state,
|
||||
parent_cmd_tx,
|
||||
parent_cwd,
|
||||
yolo_mode,
|
||||
parent_depth,
|
||||
hunk_tracker_handle,
|
||||
hunk_tracking_enabled,
|
||||
fs,
|
||||
terminal,
|
||||
session_env,
|
||||
parent_attribution_callback,
|
||||
parent_agent_name,
|
||||
parent_managed_mcp_proxy_base_url,
|
||||
) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
let ps = sessions.get(&parent_sid);
|
||||
(
|
||||
ps.map(|h| h.model_id.clone())
|
||||
.unwrap_or_else(|| self.models_manager.current_model_id()),
|
||||
ps.map(|h| h.chat_state_handle.clone()),
|
||||
ps.map(|h| h.cmd_tx.clone()),
|
||||
ps.map(|h| std::path::PathBuf::from(&h.info.cwd))
|
||||
.unwrap_or_default(),
|
||||
ps.map(|h| h.yolo_mode).unwrap_or(self.default_yolo_mode),
|
||||
ps.map(|h| h.tool_context.subagent_depth).unwrap_or(0),
|
||||
ps.map(|h| h.tool_context.hunk_tracker_handle.clone())
|
||||
.unwrap_or_else(kigi_hunk_tracker::HunkTrackerHandle::noop),
|
||||
ps.map(|h| h.tool_context.hunk_tracking_enabled)
|
||||
.unwrap_or(false),
|
||||
ps.map(|h| h.tool_context.fs.inner().clone())
|
||||
.unwrap_or_else(|| {
|
||||
let cwd = ps
|
||||
.map(|h| std::path::PathBuf::from(&h.info.cwd))
|
||||
.unwrap_or_default();
|
||||
std::sync::Arc::new(kigi_workspace::file_system::LocalFs::new(cwd))
|
||||
}),
|
||||
ps.map(|h| h.tool_context.terminal.clone())
|
||||
.unwrap_or_else(|| {
|
||||
std::sync::Arc::new(crate::terminal::TerminalRunner::new(
|
||||
std::sync::Arc::new(self.gateway.clone()),
|
||||
parent_sid.clone(),
|
||||
))
|
||||
}),
|
||||
ps.map(|h| h.tool_context.session_env.clone())
|
||||
.unwrap_or_else(|| std::sync::Arc::new(std::collections::HashMap::new())),
|
||||
ps.and_then(|h| h.attribution_callback.clone()),
|
||||
ps.map(|h| h.agent_name.clone()),
|
||||
ps.map(|h| h.managed_mcp_proxy_base_url.clone()),
|
||||
)
|
||||
};
|
||||
let (
|
||||
parent_workspace_ops,
|
||||
parent_terminal_backend,
|
||||
parent_notification_handle,
|
||||
parent_scheduler_handle,
|
||||
) = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions.get(&parent_sid).map(|ps| {
|
||||
(
|
||||
ps.workspace_ops.clone(),
|
||||
ps.terminal_backend.clone(),
|
||||
ps.tools_notification_handle.clone(),
|
||||
ps.scheduler_handle.clone(),
|
||||
)
|
||||
})
|
||||
}?;
|
||||
let available_models = self.models_manager.models();
|
||||
let parent_lsp = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.tool_context.lsp.clone())
|
||||
};
|
||||
let am = self.auth_manager.clone();
|
||||
let inference_idle_timeout_secs = {
|
||||
let per_model = config::find_model_by_id(&available_models, parent_model_id.0.as_ref())
|
||||
.and_then(|e| e.info.inference_idle_timeout_secs);
|
||||
let cfg = self.cfg.borrow();
|
||||
let remote = cfg
|
||||
.remote_settings
|
||||
.as_ref()
|
||||
.and_then(|s| s.inference_idle_timeout_secs);
|
||||
per_model.or(remote).unwrap_or(600).max(10)
|
||||
};
|
||||
let parent_hook_registry = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.hook_registry.clone())
|
||||
};
|
||||
let parent_max_turns = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions.get(&parent_sid).and_then(|h| h.max_turns)
|
||||
};
|
||||
let parent_model_agent_type =
|
||||
config::find_model_by_id(&available_models, parent_model_id.0.as_ref())
|
||||
.map(|e| e.info.agent_type.clone());
|
||||
let ask_user_question_enabled = {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.ask_user_question_enabled)
|
||||
.unwrap_or_else(|| self.cfg.borrow().resolve_ask_user_question().value)
|
||||
};
|
||||
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
|
||||
.unwrap_or_else(|| self.cli_chat_proxy_base_url()),
|
||||
alpha_test_key: self.alpha_test_key(),
|
||||
auth_method_id: self
|
||||
.auth_method_id
|
||||
.load()
|
||||
.as_deref()
|
||||
.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(),
|
||||
yolo_mode,
|
||||
subagent_event_tx: self.subagent_event_tx.clone(),
|
||||
parent_depth,
|
||||
inference_idle_timeout_secs,
|
||||
auto_compact_threshold_tiers:
|
||||
crate::agent::subagent::AutoCompactThresholdTiers::capture(&self.cfg.borrow()),
|
||||
hunk_tracker_handle,
|
||||
hunk_tracking_enabled,
|
||||
fs,
|
||||
terminal,
|
||||
session_env,
|
||||
memory_config: self.memory_config.clone(),
|
||||
web_search_sampling_config: self.prepare_web_search_sampling_config(),
|
||||
web_fetch_config: self.prepare_web_fetch_config(),
|
||||
image_gen_config: self.prepare_image_gen_config(),
|
||||
video_gen_config: self.prepare_video_gen_config(),
|
||||
app_builder_deployer_config: self.prepare_app_builder_deployer_config(),
|
||||
write_file_enabled: self.cfg.borrow().resolve_write_file().value,
|
||||
goal_enabled: self.cfg.borrow().resolve_goal().value,
|
||||
ask_user_question_enabled,
|
||||
parent_cmd_tx: parent_cmd_tx.clone(),
|
||||
parent_session_info: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| crate::session::info::Info {
|
||||
id: parent_sid.clone(),
|
||||
cwd: h.info.cwd.clone(),
|
||||
})
|
||||
},
|
||||
parent_chat_state,
|
||||
parent_max_turns,
|
||||
available_models,
|
||||
subagent_model_overrides: self.subagent_model_overrides.clone(),
|
||||
subagent_toggle: self.subagent_toggle.clone(),
|
||||
subagent_roles: self.subagent_roles.clone(),
|
||||
subagent_personas: self.subagent_personas.clone(),
|
||||
persona_io_summaries: self.persona_io_summaries.clone(),
|
||||
disable_web_search: self.cfg.borrow().disable_web_search,
|
||||
todo_gate: self.cfg.borrow().todo_gate,
|
||||
remote_settings: self.cfg.borrow().remote_settings.clone(),
|
||||
laziness_debug_log: self.cfg.borrow().laziness_debug_log.clone(),
|
||||
backend_tools_enabled: self.cfg.borrow().resolve_backend_tools().value,
|
||||
respect_gitignore: self.cfg.borrow().respect_gitignore,
|
||||
path_not_found_hints: self.cfg.borrow().path_not_found_hints,
|
||||
plugin_registry: self.plugin_registry_handle.snapshot(),
|
||||
models_manager: self.models_manager.clone(),
|
||||
file_tool_overrides: {
|
||||
let cfg = self.cfg.borrow();
|
||||
let effective = cfg
|
||||
.toolset
|
||||
.resolve_file_toolset(cfg.remote_settings.as_ref());
|
||||
if effective != crate::tools::FileToolset::Standard {
|
||||
effective.tool_configs(&cfg.toolset.hashline).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
agent_config: Some(self.cfg.borrow().clone()),
|
||||
hook_registry: parent_hook_registry,
|
||||
hook_workspace_root: String::new(),
|
||||
permission_handle: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.permission_handle.clone())
|
||||
},
|
||||
worktree_type: self.worktree_type,
|
||||
api_key_provider: Some(Arc::new(crate::auth::manager::SharedAuthKeyProvider(
|
||||
am.clone(),
|
||||
))),
|
||||
image_description_model: self.resolve_image_description_model(),
|
||||
workspace_ops: parent_workspace_ops.clone(),
|
||||
auth_manager: am.clone(),
|
||||
attribution_callback: parent_attribution_callback,
|
||||
parent_agent_name,
|
||||
parent_model_agent_type,
|
||||
allowed_subagent_types: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.allowed_subagent_types.clone())
|
||||
},
|
||||
parent_mcp_configs: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.mcp_servers.clone())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
managed_mcp_state: self.managed_mcp_cache.clone(),
|
||||
parent_mcp_pool: None,
|
||||
parent_tool_snapshot: None,
|
||||
parent_skills: None,
|
||||
parent_skills_config: self.cfg.borrow().skills.clone(),
|
||||
parent_compat: self.cfg.borrow().compat_resolved,
|
||||
auto_wake_delivered: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.and_then(|h| h.tool_context.auto_wake_delivered.clone())
|
||||
},
|
||||
task_output_tool_name: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.tool_context.task_output_tool_name.clone())
|
||||
.unwrap_or_else(|| {
|
||||
kigi_tools::reminders::task_completion::DEFAULT_TASK_OUTPUT_TOOL
|
||||
.to_string()
|
||||
})
|
||||
},
|
||||
auto_wake_enabled: self.cfg.borrow().auto_wake_enabled,
|
||||
goal_loop_active: {
|
||||
let sessions = self.sessions.borrow();
|
||||
sessions
|
||||
.get(&parent_sid)
|
||||
.map(|h| h.tool_context.goal_loop_active_gate.clone())
|
||||
.unwrap_or_else(|| {
|
||||
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(std::sync::atomic::AtomicUsize::new(0)))
|
||||
},
|
||||
parent_terminal_backend: parent_terminal_backend.clone(),
|
||||
parent_notification_handle: parent_notification_handle.clone(),
|
||||
parent_scheduler_handle: parent_scheduler_handle.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
//! Subagent spawn-context inheritance: a child session must inherit the parent's
|
||||
//! permission handle and goal-loop gate so policy and run-state can't be bypassed
|
||||
//! by delegating to a subagent.
|
||||
|
||||
use super::{build_minimal_agent_for_tests, make_test_handle};
|
||||
use agent_client_protocol as acp;
|
||||
use kigi_acp_lib::AcpAgentGatewaySender as GatewaySender;
|
||||
|
||||
/// Subagents inherit the parent permission handle, so a managed `Read(**/.env)`
|
||||
/// deny still blocks the child — direct read and the `cat .env` shell equivalent.
|
||||
#[tokio::test]
|
||||
async fn subagent_spawn_context_inherits_parent_permission_handle() {
|
||||
use kigi_workspace::permission::types::{
|
||||
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
|
||||
};
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async {
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
let sid = acp::SessionId::new("parent-permission");
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let gateway = GatewaySender::new(tx);
|
||||
let cwd = kigi_paths::AbsPathBuf::new(std::path::PathBuf::from("/tmp"))
|
||||
.expect("absolute cwd");
|
||||
let (permission_handle, _events_rx) =
|
||||
kigi_workspace::permission::spawn_permission_manager(
|
||||
sid.clone(),
|
||||
gateway,
|
||||
cwd,
|
||||
kigi_workspace::permission::types::ClientType::Generic,
|
||||
Some(PermissionConfig::new(vec![PermissionRule {
|
||||
action: RuleAction::Deny,
|
||||
tool: ToolFilter::Read,
|
||||
pattern: Some("**/.env".to_owned()),
|
||||
pattern_mode: PatternMode::Glob,
|
||||
}])),
|
||||
Vec::new(), // deny_read_globs
|
||||
Vec::new(),
|
||||
false,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut handle = make_test_handle("test-model", false, None);
|
||||
handle.permission_handle = permission_handle;
|
||||
agent.sessions.borrow_mut().insert(sid.clone(), handle);
|
||||
|
||||
let ctx = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
let inherited = ctx
|
||||
.permission_handle
|
||||
.expect("subagent context must inherit parent permission handle");
|
||||
|
||||
// Direct file read and the shell equivalent both hit the parent deny.
|
||||
for access in [
|
||||
kigi_workspace::permission::AccessKind::Read(Some(".env".into())),
|
||||
kigi_workspace::permission::AccessKind::Bash("cat .env".into()),
|
||||
] {
|
||||
let decision = inherited
|
||||
.request(
|
||||
access.clone(),
|
||||
acp::ToolCallUpdate::new(acp::ToolCallId::new("tc"), Default::default()),
|
||||
Some("child-session".to_owned()),
|
||||
Some("general-purpose".to_owned()),
|
||||
Some("permission inheritance regression".to_owned()),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
decision,
|
||||
kigi_workspace::permission::Decision::PolicyDeny(_)
|
||||
),
|
||||
"subagent-inherited handle must enforce parent deny for {access:?}, got {decision:?}"
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// A subagent shares the parent's `goal_loop_active_gate` Arc, so flipping the
|
||||
/// parent gate is observed through the child context (same allocation).
|
||||
#[tokio::test]
|
||||
async fn subagent_spawn_context_shares_parent_goal_loop_gate() {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
let sid = acp::SessionId::new("parent-goal");
|
||||
let handle = make_test_handle("test-model", false, None);
|
||||
// Clone the parent's live gate before the handle moves into `sessions`.
|
||||
let parent_gate = handle.tool_context.goal_loop_active_gate.clone();
|
||||
agent.sessions.borrow_mut().insert(sid.clone(), handle);
|
||||
|
||||
let ctx = agent.build_subagent_spawn_context(sid.0.as_ref());
|
||||
|
||||
// Flipping the parent gate must surface through the child flag (shared Arc).
|
||||
assert!(!ctx.goal_loop_active.load(Relaxed));
|
||||
parent_gate.store(true, Relaxed);
|
||||
assert!(
|
||||
ctx.goal_loop_active.load(Relaxed),
|
||||
"subagent context must observe the parent's goal-loop gate (same Arc)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A subagent inherits the parent session's `ask_user_question` gate, so
|
||||
/// `--no-ask-user` strips the tool from subagents too, while the default keeps it.
|
||||
#[tokio::test]
|
||||
async fn subagent_spawn_context_inherits_parent_ask_user_question_gate() {
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
|
||||
// Parent with the tool disabled (the `--no-ask-user` case) → child off.
|
||||
let sid_off = acp::SessionId::new("parent-no-ask");
|
||||
let mut handle_off = make_test_handle("test-model", false, None);
|
||||
handle_off.ask_user_question_enabled = false;
|
||||
agent
|
||||
.sessions
|
||||
.borrow_mut()
|
||||
.insert(sid_off.clone(), handle_off);
|
||||
let ctx_off = agent.build_subagent_spawn_context(sid_off.0.as_ref());
|
||||
assert!(
|
||||
!ctx_off.ask_user_question_enabled,
|
||||
"subagent must inherit the parent's disabled ask_user_question gate (--no-ask-user)"
|
||||
);
|
||||
|
||||
// Parent with the tool enabled (the default) → child on.
|
||||
let sid_on = acp::SessionId::new("parent-ask");
|
||||
let handle_on = make_test_handle("test-model", false, None);
|
||||
agent
|
||||
.sessions
|
||||
.borrow_mut()
|
||||
.insert(sid_on.clone(), handle_on);
|
||||
let ctx_on = agent.build_subagent_spawn_context(sid_on.0.as_ref());
|
||||
assert!(
|
||||
ctx_on.ask_user_question_enabled,
|
||||
"subagent must inherit the parent's enabled ask_user_question gate"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user