Synced from monorepo

Synced from monorepo

Changes:
- Cache growing transcripts on the messages backend
- Tell the model when a wait was clamped instead of re-inviting it
- Stop the stationarity nudge from claiming results are identical
- Deliver the stationarity nudge after the tool result
- Run auth provider commands through the platform shell (fixes Windows)
- Keep monitor tool stdout short and prescriptive
- Use UUIDs for analytics event insert IDs
- Stop crashing at startup when the host runs out of threads
- Delete the current session from within the session
- Add project forking-settings toggle (backend and deploy-time control)
- Reap a session’s bash and background commands when it closes
- Reap a session’s hook child processes when it closes
- Track coding-data consent decisions
- Fail open the access gate to stop false CLI paywalls
- Ship Agent Dashboard user guide
- Enable doom-loop recovery by default
- Kill agent children and the idle inhibitor when the parent process dies
- Fix multi-process credential wipe and orphaned session log writers

Source-Revision: 6372e41d828b8a6ee82c29e01a69e27ec895cca9
This commit is contained in:
grokkybara[bot] 2026-07-29 17:17:54 +00:00
commit 500129c714
89 changed files with 3841 additions and 771 deletions

View file

@ -526,6 +526,7 @@ mod tests {
RunContext {
session_id: "test-session",
workspace_root: "/tmp",
process_scope: None,
}
}

View file

@ -1,6 +1,8 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::AsyncWriteExt;
use xai_grok_tools::util::ProcessGroup;
use crate::config::HookSpec;
use crate::event::HookEventEnvelope;
@ -18,6 +20,23 @@ const MAX_OUTPUT_BYTES: usize = 64 * 1024;
/// or block (Stop/SubagentStop, with stderr as the feedback).
const GATE_EXIT_CODE: i32 = 2;
/// `None` when the group cannot be built, which only costs session reaping, so
/// the hook still runs.
fn hook_process_group(child: &tokio::process::Child) -> Option<Arc<ProcessGroup>> {
let mut group = ProcessGroup::new()
.inspect_err(
|e| tracing::warn!(pid = child.id(), error = %e, "hook: no process group; not reaped on session close"),
)
.ok()?;
group
.attach(child)
.inspect_err(
|e| tracing::warn!(pid = child.id(), error = %e, "hook: process group attach failed; not reaped on session close"),
)
.ok()?;
Some(Arc::new(group))
}
/// Run a single hook command.
///
/// Spawns the command as a child process, writes the envelope JSON on stdin,
@ -166,6 +185,21 @@ pub async fn run_command_hook(
}
};
let mut hook_group = None;
if let Some(scope) = ctx.process_scope.as_ref()
&& let Some(group) = hook_process_group(&child)
{
// A closed scope means the session is gone and `register` already killed
// the child, so stop rather than write stdin to a corpse.
if !scope.register(&group) {
return (
HookRunnerResult::Failed("session closed before the hook ran".to_string()),
start.elapsed(),
);
}
hook_group = Some(group);
}
// Write stdin concurrently with draining output, under the timeout: a hook
// that never reads stdin would otherwise block `write_all` on a full pipe
// buffer, outside the deadline.
@ -184,14 +218,18 @@ pub async fn run_command_hook(
let elapsed = start.elapsed();
// killpg takes grandchildren that kill_on_drop would miss.
if !matches!(result, Ok(Ok(_)))
&& let Some(group) = &hook_group
{
let _ = group.kill();
}
match result {
Err(_) => {
// Timeout: kill_on_drop handles cleanup.
(
HookRunnerResult::Failed(format!("timed out after {}ms", spec.timeout_ms)),
elapsed,
)
}
Err(_) => (
HookRunnerResult::Failed(format!("timed out after {}ms", spec.timeout_ms)),
elapsed,
),
Ok(Err(e)) => (
HookRunnerResult::Failed(format!("command execution failed: {e}")),
elapsed,
@ -907,6 +945,14 @@ mod tests {
RunContext {
session_id: "test-session",
workspace_root: "/tmp",
process_scope: None,
}
}
fn make_scoped_ctx(scope: xai_grok_tools::util::ProcessScope) -> RunContext<'static> {
RunContext {
process_scope: Some(scope),
..make_ctx()
}
}
@ -1106,6 +1152,7 @@ mod tests {
let ctx = RunContext {
session_id: "test-session",
workspace_root: &workspace,
process_scope: None,
};
let (result, _) = run_command_hook(&spec, &envelope, &ctx, GateKind::Observe).await;
@ -1382,4 +1429,70 @@ mod tests {
result
);
}
#[cfg(unix)]
#[tokio::test]
async fn command_hook_session_close_reaps_whole_group() {
let tmp = tempfile::tempdir().unwrap();
let marker = tmp.path().join("grandchild_alive");
// `& wait` keeps the leader alive while the grandchild outlives it, so
// only a group kill stops the marker being written.
let mut spec = make_shell_spec(&format!(
"sh -c 'sleep 2 && echo alive > {}' & wait",
marker.display()
));
spec.timeout_ms = 60_000;
let envelope = make_envelope();
let scope = xai_grok_tools::util::ProcessScope::new();
let hook_scope = scope.clone();
let hook = tokio::spawn(async move {
run_command_hook(
&spec,
&envelope,
&make_scoped_ctx(hook_scope),
GateKind::Observe,
)
.await
});
tokio::time::sleep(Duration::from_millis(800)).await;
scope.kill_all();
tokio::time::timeout(Duration::from_secs(15), hook)
.await
.expect("kill_all must reap the enrolled hook, not leave it on its 60s timeout")
.expect("hook task join");
tokio::time::sleep(Duration::from_secs(3)).await;
assert!(
!marker.exists(),
"grandchild outlived session close, so the group was not killpg'd"
);
}
#[cfg(unix)]
#[tokio::test]
async fn command_hook_fails_fast_when_scope_already_closed() {
let scope = xai_grok_tools::util::ProcessScope::new();
scope.kill_all();
let mut spec = make_shell_spec("sleep 600");
spec.timeout_ms = 60_000;
let (result, _) = tokio::time::timeout(
Duration::from_secs(15),
run_command_hook(
&spec,
&make_envelope(),
&make_scoped_ctx(scope),
GateKind::Observe,
),
)
.await
.expect("a closed scope must fail the hook immediately, not run to its 60s timeout");
assert!(
matches!(result, HookRunnerResult::Failed(_)),
"got {result:?}"
);
}
}

View file

@ -673,6 +673,7 @@ mod tests {
let ctx = crate::runner::RunContext {
session_id: "test",
workspace_root: "/tmp",
process_scope: None,
};
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await;
@ -751,6 +752,7 @@ mod tests {
let ctx = crate::runner::RunContext {
session_id: "test",
workspace_root: "/tmp",
process_scope: None,
};
let (result, _, info) = run_http_hook(&spec, &envelope, &ctx, GateKind::Tool).await;

View file

@ -17,6 +17,7 @@ pub use crate::event::GateKind;
pub struct RunContext<'a> {
pub session_id: &'a str,
pub workspace_root: &'a str,
pub process_scope: Option<xai_grok_tools::util::ProcessScope>,
}
/// Result of running a single hook (any handler type).

View file

@ -72,6 +72,7 @@ async fn hook_deny_via_exit_code_only() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result =
@ -101,6 +102,7 @@ async fn hook_fail_open_on_crash() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result =
@ -134,6 +136,7 @@ async fn hook_fail_open_on_timeout() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result =
@ -162,6 +165,7 @@ async fn matcher_filters_tool_name() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result = dispatcher::dispatch_pre_tool_use(
@ -194,6 +198,7 @@ async fn non_blocking_dispatch() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let results = dispatcher::dispatch_non_blocking(
@ -232,6 +237,7 @@ async fn first_deny_stops_chain() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result = dispatcher::dispatch_pre_tool_use(
@ -264,6 +270,7 @@ async fn hook_receives_stdin_envelope() {
let ctx = RunContext {
session_id: "test-sess-123",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result =
@ -288,6 +295,7 @@ async fn shell_pipe_command_works() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result =
@ -419,6 +427,7 @@ async fn new_event_types_fire_and_receive_correct_envelope() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let results =
@ -508,6 +517,7 @@ async fn runner_injected_vars_override_extra_env_at_spawn() {
let ctx = RunContext {
session_id: real_session,
workspace_root: real_workspace,
process_scope: None,
};
let result =
@ -628,6 +638,7 @@ async fn direct_exec_command_with_env_var_resolves_at_load_time() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let result =
dispatcher::dispatch_pre_tool_use(&registry, &pre_tool_use_envelope("read_file"), &ctx)
@ -697,6 +708,7 @@ async fn http_hook_url_env_expansion_end_to_end() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let pre_result =
dispatcher::dispatch_pre_tool_use(&registry, &pre_tool_use_envelope("read_file"), &ctx)
@ -779,6 +791,7 @@ async fn lenient_parsing_with_mixed_claude_events() {
let ctx = RunContext {
session_id: "test",
workspace_root: dir.path().to_str().unwrap(),
process_scope: None,
};
let result = dispatcher::dispatch_pre_tool_use(
&registry,