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:
grokkybara[bot] 2026-07-17 14:19:50 +01:00
commit 98c3b2438a
225 changed files with 18836 additions and 7156 deletions

View file

@ -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]

View file

@ -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()

View file

@ -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 {

View file

@ -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),

View file

@ -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).

View file

@ -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()

View file

@ -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;

View file

@ -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

View file

@ -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
}

View file

@ -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.
///

View file

@ -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);

View file

@ -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)

View file

@ -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!(

View file

@ -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);