Synced from monorepo

Synced from monorepo

Changes:
- Release a shell session's resources in one drop
- Make the tools blocking-wait cap client-configurable and self-describing
- Recognize API "exceeds budget" errors as context overflow
- Retry /btw on model overload
- Carry running background tasks and subagents across compaction
- Require round-trip time for SDK liveness checks
- Background-subagent completion reminders with a selectable delivery surface
- Make a PTY shell reap itself until it reaches the registry
- Recover the OS error code from a TLS-phase connection reset
- Consume the attached-client signal and report why idle is withheld
- Treat `.grok/sandbox.toml` edits as protected so auto mode prompts before writing
- Surface history/search in the Ctrl+. cheatsheet and keep it working in history view
- Delete sessions from the dashboard and welcome list
- Release a session's activity record when the session ends
- Stop charging auth-retry budget for fail-closed 401s; reset it across suspends
- Scope skills watches on project vendor roots
- Make [stop] cancel in-flight compaction
- Make the leader soak measure the leader, not its harness

Source-Revision: 8d69c91f02bcacf01e98d5aebbf2f92547c45738
This commit is contained in:
grokkybara[bot] 2026-07-31 18:08:03 +00:00
commit a422116582
165 changed files with 15161 additions and 1969 deletions

View file

@ -80,6 +80,8 @@ tokio-stream = { workspace = true }
tracing-opentelemetry = { workspace = true }
[dev-dependencies]
# Pre-main unified-log redirect for this crate's own test binary.
ctor = { workspace = true }
tonic = { workspace = true, features = ["transport"] }
# In-memory log/metric exporters for the external-stream wire-shape tests.
opentelemetry_sdk = { workspace = true, features = ["testing"] }

View file

@ -289,14 +289,30 @@ pub struct LoginCompleted {
pub mid_session: bool,
}
/// A login flow failed. `error` is the raw error message from the auth flow.
/// How a login attempt's HTTP request failed.
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LoginFailureKind {
/// `is_connect`: a dead TCP connect *or* a TLS handshake killed
/// mid-flight. `os_error` tells them apart.
TransportConnect,
/// In-flight request cut short: reset, close, timeout, body phase.
TransportInterrupted,
/// Client-side request construction / redirect policy defect.
TransportPermanent,
Decode,
}
/// One per failed login attempt, emitted by the login funnel so a retried
/// request can't inflate the count. Failures that never reached HTTP (user
/// backed out, loopback bind, id_token validation) are not reported.
#[derive(Serialize)]
pub struct LoginFailed {
pub method: String,
pub mode: String,
pub error_kind: LoginFailureKind,
/// OS code from the failure's cause chain (54/104 ECONNRESET, 10054 on
/// Windows).
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub duration_ms: u64,
pub os_error: Option<i32>,
}
/// The user backed out of the login funnel. `stage` is "picker",
@ -2204,6 +2220,29 @@ mod tests {
);
}
#[test]
fn login_failed_serializes_kind_and_os_code() {
let v = serde_json::to_value(LoginFailed {
error_kind: LoginFailureKind::TransportInterrupted,
os_error: Some(104),
})
.unwrap();
assert_eq!(
v,
serde_json::json!({ "error_kind": "transport_interrupted", "os_error": 104 })
);
}
#[test]
fn login_failed_omits_absent_os_code() {
let v = serde_json::to_value(LoginFailed {
error_kind: LoginFailureKind::Decode,
os_error: None,
})
.unwrap();
assert_eq!(v, serde_json::json!({ "error_kind": "decode" }));
}
#[test]
fn api_key_save_result_omits_error_when_ok() {
let ok = serde_json::to_value(ApiKeySaveResult {

View file

@ -5,6 +5,7 @@
//! Extracted from `xai-grok-shell::agent::telemetry`.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde::Serialize;
use serde_json::json;
@ -198,6 +199,37 @@ pub fn emit_event<T: Serialize + Send + 'static>(event_suffix: impl Into<String>
emit_event_with_origin(EmitterOrigin::Shell, event_suffix, data);
}
/// Posts spawned by [`emit_event_with_origin`] that haven't finished. Emission
/// is fire-and-forget so it never blocks a turn, which also means a process
/// exiting right after emitting drops the event — see [`drain_pending`].
static PENDING_EVENTS: AtomicUsize = AtomicUsize::new(0);
/// Decrement on every exit path, including a panicking or cancelled post.
struct PendingEventGuard;
impl Drop for PendingEventGuard {
fn drop(&mut self) {
PENDING_EVENTS.fetch_sub(1, Ordering::Release);
}
}
/// Wait (up to `timeout`) for in-flight event posts to finish. For commands
/// that exit as soon as their work is done; the agent runs long enough that
/// its events land on their own.
pub async fn drain_pending(timeout: std::time::Duration) {
let deadline = std::time::Instant::now() + timeout;
while PENDING_EVENTS.load(Ordering::Acquire) > 0 {
if std::time::Instant::now() >= deadline {
tracing::debug!(
pending = PENDING_EVENTS.load(Ordering::Acquire),
"telemetry: gave up draining pending events"
);
return;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
}
/// Emit an event whose analytics name is `{origin prefix}{event_suffix}`.
pub fn emit_event_with_origin<T: Serialize + Send + 'static>(
origin: EmitterOrigin,
@ -214,7 +246,15 @@ pub fn emit_event_with_origin<T: Serialize + Send + 'static>(
})
.ok();
if tokio::runtime::Handle::try_current().is_err() {
// `spawn` below panics without a runtime; counting first would pin the
// gauge above zero for the rest of the process.
tracing::debug!(event = %event_name, "telemetry: no runtime, dropping event");
return;
}
PENDING_EVENTS.fetch_add(1, Ordering::Release);
tokio::spawn(async move {
let _pending = PendingEventGuard;
let user_ctx = UserContext::collect();
let request_id = format!("{}-{}", event_name, uuid::Uuid::new_v4());
@ -264,6 +304,30 @@ mod tests {
});
}
/// What a command exiting right after emitting (`grok login`) relies on.
/// Asserts on the wait, not on the gauge: it is process-global and other
/// tests in this binary emit concurrently.
#[tokio::test]
async fn drain_pending_waits_for_in_flight_posts() {
emit_event_with_origin(
EmitterOrigin::Shell,
"drain_probe",
json!({ "probe": true }),
);
assert!(
PENDING_EVENTS.load(Ordering::Acquire) > 0,
"emission must register before the post is awaited"
);
let started = std::time::Instant::now();
let budget = std::time::Duration::from_secs(5);
drain_pending(budget).await;
assert!(
started.elapsed() < budget,
"drain must observe the post finish, not time out"
);
}
/// Event-name prefixes are wire contract — analytics queries match on them, so
/// they must not drift.
#[test]

View file

@ -167,10 +167,69 @@ type FileIdentity = (u64, u64);
static WRITER: LazyLock<Mutex<Option<LogWriter>>> = LazyLock::new(|| Mutex::new(open_writer()));
/// See [`redirect_to_temp_for_tests`].
static TEST_REDIRECT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Redirect all subsequent unified-log writes **and** snapshot reads to a
/// per-process file under the system temp directory, so test binaries stop
/// writing synthetic events into the developer's real
/// `~/.grok/logs/unified.jsonl` (those bursts inflate exactly the counters
/// an incident responder greps for). Runtime-activated rather than a cargo
/// feature: Bazel compiles production and test targets with one shared
/// feature set, so a feature gate would leak into production builds.
///
/// Idempotent and safe at any point: an already-open writer is re-pointed,
/// so an emit that precedes the redirect cannot pin the real path. Test
/// binaries install it pre-main via `#[ctor]`.
pub fn redirect_to_temp_for_tests() {
TEST_REDIRECT.store(true, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut guard) = WRITER.lock() {
*guard = open_writer();
}
}
fn log_path() -> PathBuf {
if TEST_REDIRECT.load(std::sync::atomic::Ordering::Relaxed) {
return test_log_dir().join(LOG_FILE);
}
grok_home().join(LOG_DIR).join(LOG_FILE)
}
/// Owner-only (0o700), freshly-created directory for the test redirect.
///
/// The stream carries path metadata and credential tail fragments, and the
/// system temp dir is world-writable on Linux: a pre-planted directory or
/// symlink would let another local user read the file — or make the writer
/// and [`trim_file`] operate through a symlink onto a victim file. The
/// non-recursive `create` fails on any pre-existing path instead of
/// adopting it, and the nanos component makes the name unpredictable.
/// Panicking on failure is deliberate: this branch only runs in test
/// binaries, and silently falling back would reopen the hole via
/// `open_writer_at`'s `create_dir_all`.
fn test_log_dir() -> &'static PathBuf {
static TEST_LOG_DIR: OnceLock<PathBuf> = OnceLock::new();
TEST_LOG_DIR.get_or_init(|| {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"grok-unified-log-test-{}-{nanos}",
std::process::id()
));
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder
.create(&dir)
.expect("create private unified-log test dir");
dir
})
}
pub fn file_size(path: &std::path::Path) -> u64 {
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
@ -516,6 +575,34 @@ pub fn snapshot_session_log(session_id: &str) -> Option<Vec<u8>> {
mod tests {
use super::*;
/// Pre-main, so no test in this binary can race the lazily-opened
/// writer onto the developer's real `~/.grok/logs/unified.jsonl`.
#[ctor::ctor]
fn redirect_for_tests() {
redirect_to_temp_for_tests();
}
/// The redirect must cover both the writer and the snapshot readers:
/// an emit lands in a per-process temp file, never under `grok_home()`.
#[test]
fn redirect_routes_writes_and_snapshots_to_process_temp_file() {
info(
"unified-log redirect probe",
Some("redirect-probe-sid"),
None,
);
let snapshot = snapshot_log().expect("snapshot after emit");
assert!(
String::from_utf8_lossy(&snapshot).contains("unified-log redirect probe"),
"snapshot must read the same redirected file the writer appended to"
);
assert!(
log_path().starts_with(std::env::temp_dir()),
"the shared file must live under the temp dir, not grok_home(): {}",
log_path().display()
);
}
#[test]
fn log_entry_serializes_minimal() {
let entry = LogEntry {