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:
parent
dd04f397b1
commit
a422116582
165 changed files with 15161 additions and 1969 deletions
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue