Synced from monorepo
Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
This commit is contained in:
parent
8adf9013a0
commit
98c3b2438a
225 changed files with 18836 additions and 7156 deletions
|
|
@ -3917,10 +3917,9 @@ impl ModelEntry {
|
|||
api_base_url: entry.api_base_url.clone(),
|
||||
}
|
||||
}
|
||||
/// The model's own (BYOK) credential: a non-empty `api_key`, else the first
|
||||
/// set, non-empty `env_key` value. `None` means the model has no usable own
|
||||
/// credential and resolution should fall through to the session / global key.
|
||||
fn own_credential(&self) -> Option<String> {
|
||||
/// Non-empty `api_key`, else first non-empty resolved `env_key`.
|
||||
/// `None` → fall through to session / global key.
|
||||
pub(crate) fn own_credential(&self) -> Option<String> {
|
||||
first_own_credential(self.api_key.as_deref(), self.env_key.as_ref())
|
||||
}
|
||||
/// `true` when the model has a non-empty `api_key` or an `env_key` that
|
||||
|
|
@ -4360,7 +4359,7 @@ pub fn enforce_disable_api_key_auth(
|
|||
) {
|
||||
if disable_api_key_auth
|
||||
&& creds.auth_type == xai_chat_state::AuthType::ApiKey
|
||||
&& crate::util::is_first_party_xai_url(&creds.base_url)
|
||||
&& crate::util::is_xai_api_url(&creds.base_url)
|
||||
{
|
||||
creds.auth_type = xai_chat_state::AuthType::SessionToken;
|
||||
creds.api_key = session_key.map(str::to_owned);
|
||||
|
|
@ -9216,7 +9215,6 @@ agent_type = "cursor"
|
|||
url = "https://mcp.test.com"
|
||||
[toolset.bash]
|
||||
timeout_secs = 120
|
||||
persistent_shell = true
|
||||
[shortcuts]
|
||||
ctrl_k = "search"
|
||||
[grok_com_config]
|
||||
|
|
|
|||
|
|
@ -223,11 +223,12 @@ pub(crate) async fn apply(
|
|||
current_agent_type: None,
|
||||
});
|
||||
if agent.cfg.borrow().mode != config::AgentMode::Leader {
|
||||
agent.models_manager.set_current_model_id(model_id);
|
||||
agent.models_manager.set_current_model_id(model_id.clone());
|
||||
agent
|
||||
.models_manager
|
||||
.set_current_reasoning_effort(applied_effort);
|
||||
}
|
||||
agent.sync_process_static_api_key(Some(model_id.0.as_ref()));
|
||||
Ok(acp::SetSessionModelResponse::new().meta(
|
||||
serde_json::json!({ "model" : updated_model, })
|
||||
.as_object()
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ impl acp::Agent for MvpAgent {
|
|||
);
|
||||
self.set_auth_method(default_id);
|
||||
}
|
||||
self.sync_process_static_api_key(None);
|
||||
let current_working_directory = self.launch_cwd.clone();
|
||||
let hostname = gethostname::gethostname();
|
||||
let mcp_servers: Vec<crate::extensions::mcp::McpServerEntry> = Vec::new();
|
||||
|
|
@ -513,6 +514,7 @@ impl acp::Agent for MvpAgent {
|
|||
}
|
||||
}
|
||||
self.set_auth_method(arguments.method_id.clone());
|
||||
self.sync_process_static_api_key(None);
|
||||
self.ensure_telemetry_client();
|
||||
if crate::agent::chat_modes::process_chat_mode_enabled() {
|
||||
self.chat_modes.warm_in_background();
|
||||
|
|
@ -718,44 +720,52 @@ impl acp::Agent for MvpAgent {
|
|||
),
|
||||
);
|
||||
let login_override = auth_meta.login_override();
|
||||
let (auth, _did_auth) = if !auth_meta.headless {
|
||||
let mut cancelled = false;
|
||||
let client_seq = auth_meta.request_seq;
|
||||
let auth_result = if !auth_meta.headless {
|
||||
let (url_tx, url_rx) = tokio::sync::oneshot::channel();
|
||||
let (code_tx, code_rx) = tokio::sync::mpsc::channel(1);
|
||||
*self.auth_code_tx.borrow_mut() = Some(code_tx);
|
||||
*self.auth_url_rx.borrow_mut() = Some(url_rx);
|
||||
let result = crate::auth::run_auth_flow_with_stderr_bridge(
|
||||
&self.auth_manager,
|
||||
grok_ctx,
|
||||
crate::auth::AuthChannels {
|
||||
url_tx: Some(url_tx),
|
||||
code_rx,
|
||||
},
|
||||
auth_meta.reauth,
|
||||
auth_meta.force_interactive,
|
||||
login_override,
|
||||
)
|
||||
.await;
|
||||
*self.auth_code_tx.borrow_mut() = None;
|
||||
*self.auth_url_rx.borrow_mut() = None;
|
||||
result
|
||||
let (cancel, _guard) = self
|
||||
.interactive_auth
|
||||
.begin(
|
||||
Some(
|
||||
crate::auth::single_flight::AttemptChannels::new(
|
||||
code_tx,
|
||||
url_rx,
|
||||
),
|
||||
),
|
||||
client_seq,
|
||||
);
|
||||
tokio::select! {
|
||||
biased; _ = cancel.cancelled() => { cancelled = true;
|
||||
Err(anyhow::anyhow!("Authentication cancelled")) } r = crate
|
||||
::auth::run_auth_flow_with_stderr_bridge(& self.auth_manager,
|
||||
grok_ctx, crate ::auth::AuthChannels { url_tx : Some(url_tx),
|
||||
code_rx, }, auth_meta.reauth, auth_meta.force_interactive,
|
||||
login_override,) => r,
|
||||
}
|
||||
} else {
|
||||
crate::auth::run_auth_flow(
|
||||
&self.auth_manager,
|
||||
grok_ctx,
|
||||
auth_meta.reauth,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
login_override,
|
||||
)
|
||||
.await
|
||||
}
|
||||
let (cancel, _guard) = self.interactive_auth.begin(None, client_seq);
|
||||
tokio::select! {
|
||||
biased; _ = cancel.cancelled() => { cancelled = true;
|
||||
Err(anyhow::anyhow!("Authentication cancelled")) } r = crate
|
||||
::auth::run_auth_flow(& self.auth_manager, grok_ctx, auth_meta
|
||||
.reauth, None, None, None, login_override,) => r,
|
||||
}
|
||||
};
|
||||
let (auth, _did_auth) = auth_result
|
||||
.map_err(|e| {
|
||||
emit_login_span(
|
||||
false,
|
||||
arguments.method_id.0.as_ref(),
|
||||
None,
|
||||
Some("login_flow_failed"),
|
||||
Some(
|
||||
if cancelled {
|
||||
"login_cancelled"
|
||||
} else {
|
||||
"login_flow_failed"
|
||||
},
|
||||
),
|
||||
);
|
||||
let mut err = acp::Error::auth_required();
|
||||
err.message = e.to_string();
|
||||
|
|
@ -2081,8 +2091,8 @@ impl acp::Agent for MvpAgent {
|
|||
return Ok(acp::PromptResponse::new(acp::StopReason::EndTurn));
|
||||
}
|
||||
}
|
||||
let intake_lock = self.prompt_intake_lock(&arguments.session_id);
|
||||
let intake_guard = intake_lock.lock().await;
|
||||
let dispatch_lock = self.dispatch_lock(&arguments.session_id);
|
||||
let dispatch_guard = dispatch_lock.lock().await;
|
||||
let meta_prompt_mode = arguments
|
||||
.meta
|
||||
.as_ref()
|
||||
|
|
@ -2312,7 +2322,7 @@ impl acp::Agent for MvpAgent {
|
|||
acp::Error::internal_error()
|
||||
.data(format!("failed to dispatch prompt to session: {e}"))
|
||||
})?;
|
||||
drop(intake_guard);
|
||||
drop(dispatch_guard);
|
||||
self.push_roster_activity_delta(
|
||||
&arguments.session_id,
|
||||
crate::agent::roster::RosterActivity::Working,
|
||||
|
|
@ -3081,6 +3091,8 @@ impl acp::Agent for MvpAgent {
|
|||
.and_then(|m| m.get("rewindIfPristine"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let dispatch_lock = self.dispatch_lock(&args.session_id);
|
||||
let _dispatch_guard = dispatch_lock.lock().await;
|
||||
let _ = handle
|
||||
.cmd_tx
|
||||
.send(SessionCommand::Cancel {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@
|
|||
//! Inherent [`MvpAgent`] helpers (MCP/clients/gateway, settings/models, session ops, spawn).
|
||||
//! Co-located child of `mvp_agent` (`use super::*`).
|
||||
use super::*;
|
||||
/// `preferred` model, else catalog `current`, else first with own credentials.
|
||||
fn byok_from_models(
|
||||
models: &indexmap::IndexMap<String, ModelEntry>,
|
||||
preferred: Option<&str>,
|
||||
current: &str,
|
||||
) -> Option<String> {
|
||||
preferred
|
||||
.and_then(|id| models.get(id))
|
||||
.and_then(|m| m.own_credential())
|
||||
.or_else(|| models.get(current).and_then(|m| m.own_credential()))
|
||||
.or_else(|| models.values().find_map(|m| m.own_credential()))
|
||||
}
|
||||
impl MvpAgent {
|
||||
pub(super) fn resolve_image_description_model(&self) -> String {
|
||||
self.cfg
|
||||
|
|
@ -78,6 +90,20 @@ impl MvpAgent {
|
|||
pub(super) fn set_auth_method(&self, id: acp::AuthMethodId) {
|
||||
self.auth_method_id.store(Some(std::sync::Arc::new(id)));
|
||||
}
|
||||
/// Publish model-owned credentials for voice/tools static fallthrough.
|
||||
/// Only [`ModelEntry::own_credential`] — not `sampling_config.api_key` (may be a session JWT).
|
||||
pub(crate) fn sync_process_static_api_key(&self, preferred_model_id: Option<&str>) {
|
||||
if self.cfg.borrow().grok_com_config.api_key_auth_disabled() {
|
||||
self.auth_manager.set_process_static_api_key(None);
|
||||
return;
|
||||
}
|
||||
let models = self.models_manager.models();
|
||||
let current = self.models_manager.current_model_id();
|
||||
self.auth_manager
|
||||
.set_process_static_api_key(
|
||||
byok_from_models(&models, preferred_model_id, current.0.as_ref()),
|
||||
);
|
||||
}
|
||||
/// Return auth for sync config construction.
|
||||
pub(super) fn current_or_buffered_auth(&self) -> Option<crate::auth::GrokAuth> {
|
||||
self.auth_manager
|
||||
|
|
@ -1414,6 +1440,14 @@ impl MvpAgent {
|
|||
) -> Self {
|
||||
models_manager.set_gateway(gateway.clone());
|
||||
let sampling_config = models_manager.sampling_config();
|
||||
if !cfg.grok_com_config.api_key_auth_disabled() {
|
||||
let models = models_manager.models();
|
||||
let current = models_manager.current_model_id();
|
||||
auth_manager
|
||||
.set_process_static_api_key(
|
||||
byok_from_models(&models, None, current.0.as_ref()),
|
||||
);
|
||||
}
|
||||
crate::upload::trace::spawn_purge_stale_upload_scratch();
|
||||
let storage_mode = cfg.storage_mode;
|
||||
let default_yolo_mode = cfg.default_yolo_mode;
|
||||
|
|
@ -1468,7 +1502,7 @@ impl MvpAgent {
|
|||
sessions: RefCell::new(HashMap::new()),
|
||||
activity,
|
||||
loading_sessions: RefCell::new(HashMap::new()),
|
||||
prompt_intake_locks: RefCell::new(HashMap::new()),
|
||||
dispatch_locks: RefCell::new(HashMap::new()),
|
||||
session_threads: RefCell::new(HashMap::new()),
|
||||
resident_roster_titles: RefCell::new(HashMap::new()),
|
||||
initialize_request: OnceLock::new(),
|
||||
|
|
@ -1504,8 +1538,7 @@ impl MvpAgent {
|
|||
auth_method_id: crate::agent::auth_method::new_shared_auth_method_id(None),
|
||||
sampling_config: RefCell::new(sampling_config),
|
||||
auth_manager,
|
||||
auth_code_tx: RefCell::new(None),
|
||||
auth_url_rx: RefCell::new(None),
|
||||
interactive_auth: Default::default(),
|
||||
client_type: RefCell::new(ClientType::default()),
|
||||
code_nav_enabled: std::cell::Cell::new(false),
|
||||
interactive_trust_client: std::cell::Cell::new(false),
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ use xai_grok_sampling_types::{
|
|||
supports_reasoning_effort_meta,
|
||||
};
|
||||
use crate::agent::update_chunk_merge;
|
||||
use crate::auth::{AuthManager, AuthUrlInfo};
|
||||
use crate::auth::AuthManager;
|
||||
use crate::config::StorageMode;
|
||||
use crate::extensions::notification::{SessionNotification, SessionUpdate};
|
||||
use xai_grok_telemetry::id::{agent_id, agent_instance_id};
|
||||
|
|
@ -614,19 +614,14 @@ pub struct MvpAgent {
|
|||
loading_sessions: RefCell<
|
||||
HashMap<acp::SessionId, tokio::sync::watch::Receiver<bool>>,
|
||||
>,
|
||||
/// Per-session prompt-intake serialization lock. LEADER-SAFE(per-session):
|
||||
/// keyed by SessionId, mirrors `sessions` lifecycle.
|
||||
///
|
||||
/// Each incoming `session/prompt` RPC is dispatched as its own task by the
|
||||
/// ACP message loop, and [`Self::prompt`] runs an async preamble (prompt-mode
|
||||
/// query, trace context, model lookup) BEFORE it enqueues
|
||||
/// `SessionCommand::Prompt` onto the actor's FIFO mailbox. Without
|
||||
/// serialization those preambles interleave across tasks, so the mailbox —
|
||||
/// and therefore the authoritative prompt queue — receives prompts out of
|
||||
/// submission order. `prompt()` holds this lock across the preamble and
|
||||
/// releases it immediately after the enqueue (the turn itself runs unlocked),
|
||||
/// which makes intake order match arrival order.
|
||||
prompt_intake_locks: RefCell<
|
||||
/// Per-session lock ordering dispatch onto the actor's mailbox:
|
||||
/// [`Self::prompt`] holds it across its intake preamble and
|
||||
/// [`Self::cancel`] around its `Cancel` send, so prompts land in
|
||||
/// submission order and a cancel cannot overtake the prompt it targets
|
||||
/// (see `cancel_never_overtakes_in_flight_prompt_intake`). Cancels wait
|
||||
/// out preambles held ahead of them — keep preambles lean; bridge cancels
|
||||
/// are unordered. LEADER-SAFE(per-session): mirrors `sessions` lifecycle.
|
||||
dispatch_locks: RefCell<
|
||||
HashMap<acp::SessionId, std::rc::Rc<tokio::sync::Mutex<()>>>,
|
||||
>,
|
||||
/// LEADER-SAFE(per-session): keyed by SessionId. Mirrors `sessions` lifecycle.
|
||||
|
|
@ -654,10 +649,11 @@ pub struct MvpAgent {
|
|||
/// grok.com chat-product catalog (`/rest/modes`) for chat sessions; distinct
|
||||
/// from `models_manager` (the build `/v1/models` catalog).
|
||||
pub(crate) chat_modes: crate::agent::chat_modes::ChatModesManager,
|
||||
/// Forwards pasted codes from `handle_auth_submit_code` to the auth flow.
|
||||
pub(crate) auth_code_tx: RefCell<Option<tokio::sync::mpsc::Sender<String>>>,
|
||||
/// Receives the auth URL from the auth flow; read by `handle_auth_get_url`.
|
||||
pub(crate) auth_url_rx: RefCell<Option<tokio::sync::oneshot::Receiver<AuthUrlInfo>>>,
|
||||
/// Single-flight guard for interactive login (device poll / loopback
|
||||
/// wait). Owns the active attempt's cancel token and its code/url
|
||||
/// channels; a new `authenticate` or `x.ai/auth/cancel` cancels the
|
||||
/// prior attempt.
|
||||
pub(crate) interactive_auth: crate::auth::single_flight::AuthSingleFlight,
|
||||
/// Client type. LEADER-SAFE(init-once): set once during `initialize` from
|
||||
/// `_meta.clientIdentifier` (injected by the IPC server in leader mode).
|
||||
///
|
||||
|
|
@ -1127,6 +1123,10 @@ struct AuthRequestMeta {
|
|||
/// user abandons the browser flow, the current session continues.
|
||||
#[serde(default)]
|
||||
force_interactive: bool,
|
||||
/// Pager auth `request_seq` for this attempt. Scopes `x.ai/auth/cancel`
|
||||
/// so a delayed cancel cannot tear down a successor login.
|
||||
#[serde(default)]
|
||||
request_seq: Option<u64>,
|
||||
}
|
||||
impl AuthRequestMeta {
|
||||
/// `--oauth` → force loopback; otherwise default (loopback).
|
||||
|
|
|
|||
|
|
@ -30,14 +30,10 @@ impl MvpAgent {
|
|||
});
|
||||
}
|
||||
}
|
||||
/// Remove a session and its thread handle without finalizing the cloud
|
||||
/// replica; the conversation stays resumable on disk. Reached by
|
||||
/// dead-actor reaping and the terminal close and delete paths. Idle
|
||||
/// unload does not route here: `handle_evict_sessions` removes its
|
||||
/// handle inline and keeps the thread for reconnect.
|
||||
/// Remove a session without finalizing; it stays resumable on disk.
|
||||
pub(crate) fn remove_session(&self, id: &acp::SessionId) {
|
||||
self.sessions.borrow_mut().remove(id);
|
||||
self.prompt_intake_locks.borrow_mut().remove(id);
|
||||
self.dispatch_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);
|
||||
|
|
@ -51,13 +47,10 @@ impl MvpAgent {
|
|||
ops.end_local_session(id.0.as_ref());
|
||||
}
|
||||
}
|
||||
/// 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
|
||||
/// Get-or-create the per-session dispatch lock (see
|
||||
/// [`Self::dispatch_locks`]). Cheap clone of the shared `Rc`.
|
||||
pub(super) fn dispatch_lock(&self, id: &acp::SessionId) -> std::rc::Rc<tokio::sync::Mutex<()>> {
|
||||
self.dispatch_locks
|
||||
.borrow_mut()
|
||||
.entry(id.clone())
|
||||
.or_default()
|
||||
|
|
|
|||
|
|
@ -74,9 +74,6 @@ impl MvpAgent {
|
|||
.borrow_mut()
|
||||
.register_block_wait(&subagent_id, slot.clone());
|
||||
}
|
||||
this.subagent_coordinator
|
||||
.borrow_mut()
|
||||
.evict_stale_completed();
|
||||
result
|
||||
};
|
||||
let snapshot = resolve_snapshot(lookup).await;
|
||||
|
|
|
|||
|
|
@ -3000,6 +3000,58 @@ fn cancel_does_not_forward_to_bridge_in_local_mode() {
|
|||
);
|
||||
});
|
||||
}
|
||||
/// Regression (post-cancel slot hang, first bad release 0.2.101; see
|
||||
/// `dispatch_locks`). SDK e2e shape:
|
||||
/// `test_cancel_ends_in_flight_turn_and_frees_slot` (grok-agent-sdk).
|
||||
#[test]
|
||||
fn cancel_never_overtakes_in_flight_prompt_intake() {
|
||||
use crate::session::SessionCommand;
|
||||
use acp::Agent as _;
|
||||
run_local_for_bridge_test(|| async {
|
||||
let agent = build_minimal_agent_for_tests();
|
||||
let sid = acp::SessionId::new("sess-cancel-intake-race");
|
||||
let (handle, _tx, mut cmd_rx) = make_live_session_handle(&sid, None);
|
||||
agent.sessions.borrow_mut().insert(sid.clone(), handle);
|
||||
let order: std::rc::Rc<std::cell::RefCell<Vec<&'static str>>> =
|
||||
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let (intake_parked_tx, intake_parked_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let driver_order = order.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
let mut intake_parked_tx = Some(intake_parked_tx);
|
||||
while let Some(cmd) = cmd_rx.recv().await {
|
||||
match cmd {
|
||||
SessionCommand::GetCurrentPromptMode { .. } => {
|
||||
if let Some(tx) = intake_parked_tx.take() {
|
||||
let _ = tx.send(());
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
SessionCommand::Prompt { .. } => driver_order.borrow_mut().push("prompt"),
|
||||
SessionCommand::Cancel { .. } => driver_order.borrow_mut().push("cancel"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
let prompt_fut = agent.prompt(acp::PromptRequest::new(
|
||||
sid.clone(),
|
||||
vec![acp::ContentBlock::from("hi")],
|
||||
));
|
||||
let cancel_fut = async {
|
||||
intake_parked_rx
|
||||
.await
|
||||
.expect("prompt intake reaches the fake actor");
|
||||
let _ = agent
|
||||
.cancel(acp::CancelNotification::new(sid.clone()))
|
||||
.await;
|
||||
};
|
||||
let _ = futures::join!(prompt_fut, cancel_fut);
|
||||
assert_eq!(
|
||||
order.borrow().as_slice(),
|
||||
["prompt", "cancel"],
|
||||
"cancel must land on the actor mailbox after the prompt it targets"
|
||||
);
|
||||
});
|
||||
}
|
||||
use crate::session::SessionCommand as TestSessionCommand;
|
||||
/// Build a session handle wired to a *live* command channel. Returns the
|
||||
/// handle (move into `sessions`) plus a probe `cmd_tx`/`cmd_rx` so a test
|
||||
|
|
|
|||
|
|
@ -323,8 +323,10 @@ impl SubagentCoordinator {
|
|||
effective_model_id: String::new(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
persisted_output_dir: None,
|
||||
},
|
||||
);
|
||||
self.enforce_completed_cap();
|
||||
if surface_completion {
|
||||
self.pending_completions
|
||||
.push(SubagentCompletionSummary {
|
||||
|
|
@ -353,6 +355,7 @@ impl SubagentCoordinator {
|
|||
description: String,
|
||||
subagent_type: String,
|
||||
result: SubagentResult,
|
||||
persisted_output_dir: Option<PathBuf>,
|
||||
) -> Option<SubagentTracker> {
|
||||
let tracker = self.active.remove(id);
|
||||
self.sync_running_gauge();
|
||||
|
|
@ -383,30 +386,26 @@ impl SubagentCoordinator {
|
|||
let block_waited = tracker.as_ref().is_some_and(|t| t.block_waited);
|
||||
let explicitly_killed = tracker.as_ref().is_some_and(|t| t.explicitly_killed);
|
||||
let surface_completion = tracker.as_ref().is_none_or(|t| t.surface_completion);
|
||||
self.completed
|
||||
.insert(
|
||||
id.to_string(),
|
||||
CompletedSubagent {
|
||||
subagent_id: id.to_string(),
|
||||
parent_session_id,
|
||||
parent_prompt_id,
|
||||
child_session_id,
|
||||
description,
|
||||
subagent_type,
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from,
|
||||
child_cwd,
|
||||
worktree_path,
|
||||
snapshot_ref: None,
|
||||
effective_model_id,
|
||||
block_waited,
|
||||
explicitly_killed,
|
||||
},
|
||||
);
|
||||
let completed = self.completed.get(id).expect("just inserted");
|
||||
let mut completed = CompletedSubagent {
|
||||
subagent_id: id.to_string(),
|
||||
parent_session_id,
|
||||
parent_prompt_id,
|
||||
child_session_id,
|
||||
description,
|
||||
subagent_type,
|
||||
persona,
|
||||
started_at,
|
||||
completed_at: std::time::Instant::now(),
|
||||
result,
|
||||
resumed_from,
|
||||
child_cwd,
|
||||
worktree_path,
|
||||
snapshot_ref: None,
|
||||
effective_model_id,
|
||||
block_waited,
|
||||
explicitly_killed,
|
||||
persisted_output_dir,
|
||||
};
|
||||
let success = completed.result.success && !completed.result.cancelled;
|
||||
{
|
||||
let preview = crate::util::truncate(&completed.result.output, 200);
|
||||
|
|
@ -444,6 +443,11 @@ impl SubagentCoordinator {
|
|||
output: completed.result.output.clone(),
|
||||
});
|
||||
}
|
||||
if completed.persisted_output_dir.is_some() {
|
||||
completed.result.output = Arc::from("");
|
||||
}
|
||||
self.completed.insert(id.to_string(), completed);
|
||||
self.enforce_completed_cap();
|
||||
self.completion_notify.notify_waiters();
|
||||
tracker
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,8 +55,17 @@ impl SubagentCoordinator {
|
|||
reason: completed.result.error.clone(),
|
||||
}
|
||||
} else if completed.result.success {
|
||||
let output = match &completed.persisted_output_dir {
|
||||
Some(dir) => {
|
||||
read_subagent_output(dir)
|
||||
.unwrap_or_else(|| {
|
||||
OUTPUT_UNAVAILABLE_PLACEHOLDER.to_string()
|
||||
})
|
||||
}
|
||||
None => completed.result.output.to_string(),
|
||||
};
|
||||
SubagentSnapshotStatus::Completed {
|
||||
output: completed.result.output.to_string(),
|
||||
output,
|
||||
tool_calls: completed.result.tool_calls,
|
||||
turns: completed.result.turns,
|
||||
worktree_path: completed.result.worktree_path.clone(),
|
||||
|
|
@ -209,7 +218,7 @@ impl SubagentCoordinator {
|
|||
/// to a different parent session (prevents cross-session context bleed).
|
||||
///
|
||||
/// Fast path: checks the in-memory `completed` map first. When that
|
||||
/// misses (e.g. after TTL eviction), falls back to on-disk metadata
|
||||
/// misses (e.g. after cap eviction), falls back to on-disk metadata
|
||||
/// in `{parent_session_dir}/subagents/{id}/meta.json`.
|
||||
pub(crate) fn resumable_source_for(
|
||||
&self,
|
||||
|
|
@ -293,10 +302,21 @@ impl SubagentCoordinator {
|
|||
will_wake: false,
|
||||
})
|
||||
}
|
||||
/// TTL cleanup: remove completed entries older than 30 minutes.
|
||||
pub fn evict_stale_completed(&mut self) {
|
||||
let cutoff = std::time::Duration::from_secs(30 * 60);
|
||||
self.completed.retain(|_, entry| entry.completed_at.elapsed() < cutoff);
|
||||
/// Oldest completions are evicted first; their `output.json` stays on disk.
|
||||
pub fn enforce_completed_cap(&mut self) {
|
||||
if self.completed.len() <= MAX_COMPLETED_ENTRIES {
|
||||
return;
|
||||
}
|
||||
let excess = self.completed.len() - MAX_COMPLETED_ENTRIES;
|
||||
let mut by_age: Vec<(std::time::Instant, String)> = self
|
||||
.completed
|
||||
.iter()
|
||||
.map(|(id, e)| (e.completed_at, id.clone()))
|
||||
.collect();
|
||||
by_age.sort_unstable_by_key(|(completed_at, _)| *completed_at);
|
||||
for (_, id) in by_age.into_iter().take(excess) {
|
||||
self.completed.remove(&id);
|
||||
}
|
||||
}
|
||||
/// Snapshot all currently-running subagents for compaction state context.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1765,7 +1765,8 @@ pub(crate) async fn handle_subagent_request(
|
|||
}
|
||||
}
|
||||
}
|
||||
update_subagent_meta_completed(&subagent_meta_dir, &result, &gcs_upload_ctx);
|
||||
let persisted_output_dir = persist_subagent_output(&subagent_meta_dir, &result);
|
||||
persist_subagent_completion(&subagent_meta_dir, &result, &gcs_upload_ctx);
|
||||
let final_status = result.status().to_string();
|
||||
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
|
||||
let telemetry_tokens = if result.tool_calls > 0 || result.success {
|
||||
|
|
@ -1994,6 +1995,7 @@ pub(crate) async fn handle_subagent_request(
|
|||
request.description.clone(),
|
||||
request.subagent_type.clone(),
|
||||
result.clone(),
|
||||
persisted_output_dir,
|
||||
);
|
||||
if let Some(snapshot_ref) = disposed_snapshot_ref {
|
||||
coordinator.borrow_mut().set_completed_snapshot_ref(&request.id, snapshot_ref);
|
||||
|
|
|
|||
|
|
@ -474,7 +474,7 @@ pub(crate) struct CompletedSubagent {
|
|||
pub subagent_type: String,
|
||||
pub persona: Option<String>,
|
||||
pub started_at: std::time::Instant,
|
||||
/// When the subagent moved to the completed map. Used for TTL eviction.
|
||||
/// When the subagent moved to the completed map. Orders cap eviction.
|
||||
pub completed_at: std::time::Instant,
|
||||
pub result: SubagentResult,
|
||||
/// ID of the source subagent this session was resumed from.
|
||||
|
|
@ -492,6 +492,12 @@ pub(crate) struct CompletedSubagent {
|
|||
pub block_waited: bool,
|
||||
/// Set when the model explicitly killed this subagent via the kill tool.
|
||||
pub explicitly_killed: bool,
|
||||
/// Directory whose `output.json` holds the output text; when set, the
|
||||
/// stored `result.output` is cleared and `lookup` reads from disk.
|
||||
/// `None` (failures, empty outputs, failed writes) serves from memory.
|
||||
/// Process-scoped and local-only: resume survives a restart via
|
||||
/// `meta.json`, and trace upload carries the text to GCS.
|
||||
pub persisted_output_dir: Option<PathBuf>,
|
||||
}
|
||||
/// Lightweight entry for subagents that have been requested but are still
|
||||
/// initializing (creating worktree, resolving config, spawning session).
|
||||
|
|
@ -583,6 +589,11 @@ pub(crate) struct SubagentCoordinator {
|
|||
/// Cleared on freeze/cancel. See AGENTS.md rule 3 for the completeness model.
|
||||
subagent_usage_not_applied_prompts: std::collections::HashSet<String>,
|
||||
}
|
||||
/// Cap on the completed map (entries are small: identity, counts, and an
|
||||
/// error string; successful output text lives in `output.json`).
|
||||
pub(crate) const MAX_COMPLETED_ENTRIES: usize = 1024;
|
||||
/// Served when an entry's `output.json` cannot be read back.
|
||||
pub(crate) const OUTPUT_UNAVAILABLE_PLACEHOLDER: &str = "[subagent output no longer available]";
|
||||
fn tracker_to_summary(t: &SubagentTracker) -> ActiveSubagentSummary {
|
||||
ActiveSubagentSummary {
|
||||
subagent_id: t.subagent_id.clone(),
|
||||
|
|
@ -2131,7 +2142,7 @@ fn fail_subagent(
|
|||
duration_ms,
|
||||
..Default::default()
|
||||
};
|
||||
update_subagent_meta_completed(subagent_meta_dir, &result, gcs_ctx);
|
||||
persist_subagent_completion(subagent_meta_dir, &result, gcs_ctx);
|
||||
emit_subagent_notification(
|
||||
gateway,
|
||||
parent_session_id,
|
||||
|
|
@ -2190,7 +2201,7 @@ async fn cancel_pending_subagent_at_promote(
|
|||
duration_ms,
|
||||
..Default::default()
|
||||
};
|
||||
update_subagent_meta_completed(subagent_meta_dir, &result, gcs_ctx);
|
||||
persist_subagent_completion(subagent_meta_dir, &result, gcs_ctx);
|
||||
emit_subagent_notification(
|
||||
gateway,
|
||||
parent_session_id,
|
||||
|
|
@ -2552,14 +2563,21 @@ impl SubagentSessionMetadata {
|
|||
}
|
||||
}
|
||||
}
|
||||
/// Write via a same-directory temp file and rename, so a crash mid-write
|
||||
/// cannot leave a torn `meta.json` or `output.json`.
|
||||
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no parent")
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let tmp = tempfile::NamedTempFile::new_in(parent)?;
|
||||
std::fs::write(tmp.path(), contents)?;
|
||||
tmp.persist(path)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Write `meta.json`. Returns `true` on success so callers on the resume-pointer
|
||||
/// path can gate worktree disposal on a durable write.
|
||||
fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
tracing::warn!(error = % e, "failed to create subagent meta dir");
|
||||
return false;
|
||||
}
|
||||
let meta_path = dir.join("meta.json");
|
||||
let json = match serde_json::to_string_pretty(meta) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
|
|
@ -2567,12 +2585,63 @@ fn write_subagent_meta(dir: &Path, meta: &SubagentMeta) -> bool {
|
|||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = std::fs::write(&meta_path, json) {
|
||||
if let Err(e) = atomic_write(&dir.join("meta.json"), &json) {
|
||||
tracing::warn!(error = % e, "failed to write subagent meta");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// On-disk schema of `output.json`, written beside `meta.json`.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SubagentOutputFile {
|
||||
schema_version: u32,
|
||||
output: String,
|
||||
}
|
||||
/// Borrowed twin of [`SubagentOutputFile`] so serialization does not copy
|
||||
/// the output text.
|
||||
#[derive(serde::Serialize)]
|
||||
struct SubagentOutputFileRef<'a> {
|
||||
schema_version: u32,
|
||||
output: &'a str,
|
||||
}
|
||||
const SUBAGENT_OUTPUT_SCHEMA_VERSION: u32 = 1;
|
||||
fn write_subagent_output(dir: &Path, output: &str) -> bool {
|
||||
let file = SubagentOutputFileRef {
|
||||
schema_version: SUBAGENT_OUTPUT_SCHEMA_VERSION,
|
||||
output,
|
||||
};
|
||||
let json = match serde_json::to_string(&file) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = % e, "failed to serialize subagent output");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = atomic_write(&dir.join("output.json"), &json) {
|
||||
tracing::warn!(error = % e, "failed to write subagent output");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Read back `output.json`. `None` on any read or parse failure.
|
||||
pub(crate) fn read_subagent_output(dir: &Path) -> Option<String> {
|
||||
let data = std::fs::read_to_string(dir.join("output.json")).ok()?;
|
||||
let file: SubagentOutputFile = match serde_json::from_str(&data) {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = % e, "failed to parse subagent output.json");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if file.schema_version != SUBAGENT_OUTPUT_SCHEMA_VERSION {
|
||||
tracing::warn!(
|
||||
found = file.schema_version,
|
||||
expected = SUBAGENT_OUTPUT_SCHEMA_VERSION,
|
||||
"unexpected output.json schema version"
|
||||
);
|
||||
}
|
||||
Some(file.output)
|
||||
}
|
||||
/// Extra runtime context for GCS artifact upload. `SubagentMeta` doesn't
|
||||
/// persist these fields, so they're carried from the spawn site.
|
||||
#[derive(Clone)]
|
||||
|
|
@ -2595,7 +2664,7 @@ struct GcsUploadContext {
|
|||
/// any read/parse/write failure is `warn!`-logged (this is the critical resume
|
||||
/// pointer) so the caller keeps the worktree rather than removing it without a
|
||||
/// recoverable ref. Also re-asserts the terminal `status` so a failed
|
||||
/// `update_subagent_meta_completed` write can't leave a non-terminal record that
|
||||
/// `persist_subagent_completion` write can't leave a non-terminal record that
|
||||
/// `resumable_source_for` rejects after the worktree is removed.
|
||||
fn update_subagent_meta_snapshot_ref(dir: &Path, snapshot_ref: &str, status: &str) -> bool {
|
||||
let meta_path = dir.join("meta.json");
|
||||
|
|
@ -2622,7 +2691,12 @@ fn update_subagent_meta_snapshot_ref(dir: &Path, snapshot_ref: &str, status: &st
|
|||
meta.status = status.to_string();
|
||||
write_subagent_meta(dir, &meta)
|
||||
}
|
||||
fn update_subagent_meta_completed(dir: &Path, result: &SubagentResult, gcs_ctx: &GcsUploadContext) {
|
||||
#[must_use]
|
||||
fn persist_subagent_output(dir: &Path, result: &SubagentResult) -> Option<PathBuf> {
|
||||
(result.success && !result.output.is_empty() && write_subagent_output(dir, &result.output))
|
||||
.then(|| dir.to_path_buf())
|
||||
}
|
||||
fn persist_subagent_completion(dir: &Path, result: &SubagentResult, gcs_ctx: &GcsUploadContext) {
|
||||
let meta_path = dir.join("meta.json");
|
||||
if let Ok(data) = std::fs::read_to_string(&meta_path)
|
||||
&& let Ok(mut meta) = serde_json::from_str::<SubagentMeta>(&data)
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ fn lookup_returns_ready_for_completed_subagent() {
|
|||
duration_ms: 1234,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let lookup = coordinator.lookup("sub-1");
|
||||
assert!(lookup.is_some());
|
||||
|
|
@ -384,6 +385,7 @@ async fn running_gauge_tracks_pending_and_active() {
|
|||
"gauge task".into(),
|
||||
"general-purpose".into(),
|
||||
SubagentResult::default(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(gauge.load(Ordering::Relaxed), 0, "completed does not count");
|
||||
coordinator
|
||||
|
|
@ -435,6 +437,7 @@ fn mark_block_waited_sets_flag_on_completed() {
|
|||
child_session_id: "sub-bw".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert!(! coordinator.is_block_waited("sub-bw"));
|
||||
coordinator.mark_block_waited("sub-bw");
|
||||
|
|
@ -525,6 +528,7 @@ async fn mark_explicitly_killed_active_then_propagates_to_completed() {
|
|||
child_session_id: "sub-ek".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
coordinator.is_explicitly_killed("sub-ek"),
|
||||
|
|
@ -631,6 +635,7 @@ fn mark_explicitly_killed_sets_flag_on_completed() {
|
|||
child_session_id: "sub-ek-c".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert!(! coordinator.is_explicitly_killed("sub-ek-c"));
|
||||
coordinator.mark_explicitly_killed("sub-ek-c");
|
||||
|
|
@ -658,6 +663,7 @@ async fn block_waited_propagates_through_move_to_completed() {
|
|||
child_session_id: "sub-prop".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert!(coordinator.is_block_waited("sub-prop"));
|
||||
}
|
||||
|
|
@ -676,6 +682,7 @@ fn complete_dummy(coordinator: &mut SubagentCoordinator, id: &str, surface: bool
|
|||
child_session_id: id.into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
|
|
@ -861,44 +868,173 @@ fn move_pending_to_cancelled_creates_cancelled_entry() {
|
|||
}
|
||||
}
|
||||
}
|
||||
fn completed_with_output(
|
||||
id: &str,
|
||||
text: &str,
|
||||
persisted_output_dir: Option<PathBuf>,
|
||||
) -> CompletedSubagent {
|
||||
CompletedSubagent {
|
||||
subagent_id: id.into(),
|
||||
parent_session_id: String::new(),
|
||||
parent_prompt_id: None,
|
||||
child_session_id: String::new(),
|
||||
description: "task".into(),
|
||||
subagent_type: "explore".into(),
|
||||
persona: None,
|
||||
started_at: std::time::Instant::now(),
|
||||
completed_at: std::time::Instant::now(),
|
||||
result: SubagentResult {
|
||||
success: true,
|
||||
output: std::sync::Arc::from(text),
|
||||
..Default::default()
|
||||
},
|
||||
resumed_from: None,
|
||||
child_cwd: String::new(),
|
||||
worktree_path: None,
|
||||
snapshot_ref: None,
|
||||
effective_model_id: String::new(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
persisted_output_dir,
|
||||
}
|
||||
}
|
||||
fn lookup_output(coordinator: &SubagentCoordinator, id: &str) -> String {
|
||||
match coordinator.lookup(id) {
|
||||
Some(SnapshotLookup::Ready(snap)) => {
|
||||
match snap.status {
|
||||
SubagentSnapshotStatus::Completed { output, .. } => output,
|
||||
other => panic!("expected Completed status, got {other:?}"),
|
||||
}
|
||||
}
|
||||
other => {
|
||||
panic!(
|
||||
"expected Ready lookup, got {:?}", other.map(| _ | "NeedsSignals/other")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn evict_stale_completed_uses_completion_time() {
|
||||
fn lookup_degrades_to_placeholder_when_output_file_is_missing() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut coordinator = SubagentCoordinator::new();
|
||||
coordinator
|
||||
.completed
|
||||
.insert(
|
||||
"sub-recent".to_string(),
|
||||
CompletedSubagent {
|
||||
subagent_id: "sub-recent".into(),
|
||||
parent_session_id: String::new(),
|
||||
parent_prompt_id: None,
|
||||
child_session_id: String::new(),
|
||||
description: "long-running".into(),
|
||||
subagent_type: "explore".into(),
|
||||
persona: None,
|
||||
started_at: std::time::Instant::now()
|
||||
- std::time::Duration::from_secs(31 * 60),
|
||||
completed_at: std::time::Instant::now(),
|
||||
result: SubagentResult {
|
||||
success: true,
|
||||
..Default::default()
|
||||
},
|
||||
resumed_from: None,
|
||||
child_cwd: String::new(),
|
||||
worktree_path: None,
|
||||
snapshot_ref: None,
|
||||
effective_model_id: String::new(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
},
|
||||
"sub-gone".to_string(),
|
||||
completed_with_output("sub-gone", "", Some(dir.path().to_path_buf())),
|
||||
);
|
||||
coordinator.evict_stale_completed();
|
||||
assert!(
|
||||
coordinator.completed.contains_key("sub-recent"),
|
||||
"recently completed subagent should not be evicted"
|
||||
assert_eq!(
|
||||
lookup_output(& coordinator, "sub-gone"), OUTPUT_UNAVAILABLE_PLACEHOLDER,
|
||||
"an entry whose output.json is gone must degrade, not fail the query"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn lookup_serves_unpersisted_output_from_memory() {
|
||||
let mut coordinator = SubagentCoordinator::new();
|
||||
coordinator
|
||||
.completed
|
||||
.insert("sub-mem".to_string(), completed_with_output("sub-mem", "output", None));
|
||||
assert_eq!(
|
||||
lookup_output(& coordinator, "sub-mem"), "output",
|
||||
"an entry with nothing on disk must serve its in-memory output"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn completed_entries_are_capped_oldest_first() {
|
||||
let mut coordinator = SubagentCoordinator::new();
|
||||
let base = std::time::Instant::now();
|
||||
for i in 0..(MAX_COMPLETED_ENTRIES + 2) {
|
||||
let mut entry = completed_with_output(
|
||||
&format!("sub-{i}"),
|
||||
"",
|
||||
Some(std::path::PathBuf::from("/nonexistent")),
|
||||
);
|
||||
entry.completed_at = base + std::time::Duration::from_millis(i as u64);
|
||||
coordinator.completed.insert(format!("sub-{i}"), entry);
|
||||
}
|
||||
coordinator.enforce_completed_cap();
|
||||
assert_eq!(
|
||||
coordinator.completed.len(), MAX_COMPLETED_ENTRIES,
|
||||
"the completed map must be capped at MAX_COMPLETED_ENTRIES"
|
||||
);
|
||||
assert!(
|
||||
! coordinator.completed.contains_key("sub-0") && ! coordinator.completed
|
||||
.contains_key("sub-1"), "the oldest completions must be evicted first"
|
||||
);
|
||||
assert!(
|
||||
coordinator.completed.contains_key("sub-2"),
|
||||
"entries within the cap must survive"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn move_to_completed_clears_persisted_output_after_the_summary_clone() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let full_output = "final report".repeat(100);
|
||||
assert!(write_subagent_output(dir.path(), & full_output));
|
||||
let mut coordinator = SubagentCoordinator::new();
|
||||
coordinator
|
||||
.move_to_completed(
|
||||
"sub-e2e",
|
||||
"task".into(),
|
||||
"explore".into(),
|
||||
SubagentResult {
|
||||
success: true,
|
||||
output: std::sync::Arc::from(full_output.as_str()),
|
||||
subagent_id: "sub-e2e".into(),
|
||||
child_session_id: "sub-e2e".into(),
|
||||
..Default::default()
|
||||
},
|
||||
Some(dir.path().to_path_buf()),
|
||||
);
|
||||
let entry = coordinator.completed.get("sub-e2e").expect("entry inserted");
|
||||
assert!(
|
||||
entry.result.output.is_empty(),
|
||||
"a persisted entry must not keep the output in memory"
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_output(& coordinator, "sub-e2e"), full_output,
|
||||
"lookup must serve the persisted output from disk"
|
||||
);
|
||||
let summaries = coordinator.drain_pending_completions();
|
||||
assert_eq!(
|
||||
&* summaries[0].output, full_output,
|
||||
"the completion summary must carry the full output"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn persist_gate_only_persists_successful_nonempty_outputs() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let ok = SubagentResult {
|
||||
success: true,
|
||||
output: std::sync::Arc::from("text"),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
persist_subagent_output(dir.path(), & ok), Some(dir.path().to_path_buf())
|
||||
);
|
||||
let empty = SubagentResult {
|
||||
success: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(persist_subagent_output(dir.path(), & empty), None);
|
||||
let failed = SubagentResult {
|
||||
success: false,
|
||||
output: std::sync::Arc::from("partial"),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(persist_subagent_output(dir.path(), & failed), None);
|
||||
}
|
||||
#[test]
|
||||
fn subagent_output_roundtrips_through_output_json() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let output = "line one\nline two with unicode ✓";
|
||||
assert!(write_subagent_output(dir.path(), output));
|
||||
assert_eq!(read_subagent_output(dir.path()).as_deref(), Some(output));
|
||||
assert_eq!(read_subagent_output(& dir.path().join("missing")), None);
|
||||
std::fs::write(dir.path().join("output.json"), "not json").expect("corrupt file");
|
||||
assert_eq!(read_subagent_output(dir.path()), None);
|
||||
}
|
||||
#[test]
|
||||
fn cancel_with_outcome_fires_pending_token() {
|
||||
let mut coordinator = SubagentCoordinator::new();
|
||||
let token = CancellationToken::new();
|
||||
|
|
@ -945,6 +1081,7 @@ async fn cancel_with_outcome_returns_variant_for_active_finished_unknown() {
|
|||
subagent_id: "sub-done".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
matches!(coordinator.cancel_with_outcome("sub-done"),
|
||||
|
|
@ -1026,6 +1163,7 @@ fn completed_takes_precedence_over_pending_in_lookup() {
|
|||
child_session_id: "child-dup".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let lookup = coordinator.lookup("sub-dup");
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ fn resumable_source_returns_info_for_completed_subagent() {
|
|||
effective_model_id: "grok-3".into(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
persisted_output_dir: None,
|
||||
},
|
||||
);
|
||||
let info = coordinator
|
||||
|
|
@ -373,6 +374,7 @@ fn resumable_source_survives_move_to_completed_with_metadata() {
|
|||
child_session_id: "sub-moved".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let info = coordinator
|
||||
.resumable_source_for("sub-moved", "", Path::new("/tmp"))
|
||||
|
|
@ -593,7 +595,7 @@ fn snapshot_ref_write_promotes_nonterminal_status_to_terminal() {
|
|||
assert_eq!("completed", reread.status);
|
||||
}
|
||||
/// The coordinator setter stamps the snapshot ref onto the in-memory
|
||||
/// completed entry so `resume_from` can rehydrate before TTL eviction.
|
||||
/// completed entry so `resume_from` can rehydrate before cap eviction.
|
||||
#[tokio::test]
|
||||
async fn set_completed_snapshot_ref_updates_in_memory_entry() {
|
||||
let mut coordinator = SubagentCoordinator::new();
|
||||
|
|
@ -609,6 +611,7 @@ async fn set_completed_snapshot_ref_updates_in_memory_entry() {
|
|||
child_session_id: "sa-mem".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let before = coordinator
|
||||
.resumable_source_for("sa-mem", "session-A", Path::new("/tmp"))
|
||||
|
|
@ -621,7 +624,7 @@ async fn set_completed_snapshot_ref_updates_in_memory_entry() {
|
|||
.unwrap();
|
||||
assert_eq!(after.snapshot_ref.as_deref(), Some("refs/grok/subagents/sa-mem"));
|
||||
}
|
||||
/// Unknown id is a no-op (entry already TTL-evicted; meta.json still holds it).
|
||||
/// Unknown id is a no-op (entry already cap-evicted; meta.json still holds it).
|
||||
#[test]
|
||||
fn set_completed_snapshot_ref_unknown_id_is_noop() {
|
||||
let mut coordinator = SubagentCoordinator::new();
|
||||
|
|
@ -704,6 +707,7 @@ fn coordinator_with_completed(id: &str) -> SubagentCoordinator {
|
|||
child_session_id: id.into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
coordinator
|
||||
}
|
||||
|
|
@ -775,7 +779,8 @@ async fn gate_on_completion_clears_model_facing_worktree_path_but_resume_retains
|
|||
if worktree_removed {
|
||||
result.worktree_path = None;
|
||||
}
|
||||
coordinator.move_to_completed("disp-1", "task".into(), "explore".into(), result);
|
||||
coordinator
|
||||
.move_to_completed("disp-1", "task".into(), "explore".into(), result, None);
|
||||
coordinator
|
||||
.set_completed_snapshot_ref("disp-1", "refs/grok/subagents/disp-1".into());
|
||||
let listed = coordinator.completed.get("disp-1").expect("completed entry");
|
||||
|
|
@ -805,7 +810,8 @@ async fn gate_on_completion_retains_worktree_path_when_not_removed() {
|
|||
if worktree_removed {
|
||||
result.worktree_path = None;
|
||||
}
|
||||
coordinator.move_to_completed("keep-1", "task".into(), "explore".into(), result);
|
||||
coordinator
|
||||
.move_to_completed("keep-1", "task".into(), "explore".into(), result, None);
|
||||
let entry = coordinator.completed.get("keep-1").expect("completed entry");
|
||||
assert_eq!(Some(wt.to_string_lossy().into_owned()), entry.result.worktree_path);
|
||||
}
|
||||
|
|
@ -856,6 +862,7 @@ async fn disposal_completes_before_subagent_is_observable() {
|
|||
child_session_id: "order-1".into(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
if let Some(r) = disposed_snapshot_ref {
|
||||
coordinator.set_completed_snapshot_ref("order-1", r);
|
||||
|
|
@ -1351,6 +1358,7 @@ fn resumable_source_rejects_cross_session_lookup() {
|
|||
effective_model_id: String::new(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
persisted_output_dir: None,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -1803,6 +1811,7 @@ async fn reconcile_reemits_rewound_finish_even_when_id_still_in_completed_regist
|
|||
success: true,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel();
|
||||
let unfinished = vec![(id.to_string(), format!("child-{id}"))];
|
||||
|
|
@ -1845,6 +1854,7 @@ async fn reconcile_reemits_real_outcome_for_completed_with_running_meta() {
|
|||
success: true,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel();
|
||||
let unfinished = vec![(id.to_string(), format!("child-{id}"))];
|
||||
|
|
@ -2166,6 +2176,7 @@ fn completed_subagent_propagates_resumed_from() {
|
|||
effective_model_id: "grok-3".into(),
|
||||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
persisted_output_dir: None,
|
||||
},
|
||||
);
|
||||
let refs = coordinator.spawned_refs_for_prompt("prompt-1");
|
||||
|
|
@ -2193,6 +2204,7 @@ async fn completion_notify_fires_on_move_to_completed() {
|
|||
duration_ms: 100,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
tokio::time::timeout(std::time::Duration::from_millis(50), notified)
|
||||
.await
|
||||
|
|
@ -2216,6 +2228,7 @@ fn drain_pending_completions_returns_and_clears() {
|
|||
duration_ms: 500,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
coordinator
|
||||
.move_to_completed(
|
||||
|
|
@ -2231,6 +2244,7 @@ fn drain_pending_completions_returns_and_clears() {
|
|||
duration_ms: 200,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let summaries = coordinator.drain_pending_completions();
|
||||
assert_eq!(summaries.len(), 2);
|
||||
|
|
@ -2262,6 +2276,7 @@ fn drain_pending_completions_cancelled_is_not_success() {
|
|||
child_session_id: "sub-c1".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let summaries = coordinator.drain_pending_completions();
|
||||
assert_eq!(summaries.len(), 1);
|
||||
|
|
@ -2315,6 +2330,7 @@ async fn outstanding_for_prompt_excludes_completed() {
|
|||
child_session_id: "sub-done".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let outstanding = coordinator.outstanding_for_prompt("prompt-X");
|
||||
assert!(
|
||||
|
|
@ -2369,6 +2385,7 @@ async fn subagent_usage_not_applied_sticky_after_completion_and_is_prompt_scoped
|
|||
child_session_id: "sub-1".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
assert!(coordinator.outstanding_for_prompt("p-1").is_empty());
|
||||
assert!(coordinator.subagent_usage_not_applied("p-1"));
|
||||
|
|
@ -2444,6 +2461,7 @@ fn completions_buffered_while_turn_inactive_drained_later() {
|
|||
child_session_id: "sub-idle".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
let drained = coordinator.drain_pending_completions();
|
||||
assert_eq!(drained.len(), 1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue