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

@ -963,7 +963,7 @@ pub(crate) fn start_with_timeout(
let progress_for_thread = progress.clone();
let thread = std::thread::spawn(move || {
let watcher_loop = move || {
let update_stage = |stage: &'static str| {
if let Ok(mut p) = progress_for_thread.lock() {
p.set_stage(stage);
@ -1342,7 +1342,11 @@ pub(crate) fn start_with_timeout(
let _ = ready_tx.send(Err(Box::new(e)));
}
}
});
};
let thread = std::thread::Builder::new()
.name("fsnotify-watcher".into())
.spawn(watcher_loop)
.map_err(|e| crate::FsNotifyError::WatcherStart(Box::new(e)))?;
// Wait for watcher to be ready (with timeout)
if let Ok(mut p) = progress.lock() {

View file

@ -36,8 +36,9 @@ pub struct CampaignOverride {
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default)]
pub struct DoomLoopRecoverySettings {
/// Send the `x-grok-doom-loop-check` header and parse the reported
/// triggers. `Some(false)` is a kill-switch; absent ⇒ client default (off).
/// Send the `x-grok-doom-loop-check` header, parse the reported
/// triggers, and resample confident loops. `Some(false)` is a
/// kill-switch; absent ⇒ client default (ON).
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
/// Highest `tail_repetition` threshold considered confident (clamped to

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,

View file

@ -1,6 +1,6 @@
[package]
name = "xai-grok-pager-bin"
version = "0.2.112"
version = "0.2.114"
edition.workspace = true
license = "Apache-2.0"
authors = ["xAI"]

View file

@ -1229,6 +1229,13 @@ async fn run_agent_command(
let cancel = CancellationToken::new();
match mode {
ClientMode::Stdio => {
if let Err(error) = xai_tty_utils::kill_current_process_on_parent_death() {
tracing::warn!(
%error,
"failed to bind to parent death; stdio bridge will not die \
with its parent stdin EOF remains the only cleanup"
);
}
let replay_state = Arc::new(std::sync::Mutex::new(StdioReplayState::default()));
let leader_tx = Arc::new(TokioMutex::new(tx));
let leader_tx_stdin = leader_tx.clone();

View file

@ -1,6 +1,6 @@
[package]
name = "xai-grok-pager"
version = "0.2.112"
version = "0.2.114"
edition.workspace = true
license = "Apache-2.0"
authors = ["xAI"]
@ -161,8 +161,8 @@ core-foundation = { workspace = true }
windows-sys = { version = "0.59", features = ["Win32_System_Console"] }
[dev-dependencies]
# Enable the render crate's test-only helpers for the pager's test build.
xai-grok-pager-render = { path = "../xai-grok-pager-render", features = ["test-support"] }
xai-grok-shell = { workspace = true, features = ["test-support"] }
pretty_assertions = { workspace = true }
insta = { workspace = true }
criterion = { workspace = true }

View file

@ -212,6 +212,7 @@ Actions available from any screen.
| Key | Alt Key | Action | Confirmation |
|-----|---------|--------|-------------|
| `Ctrl+N` | | Create a new session (optionally in a git worktree) | Yes (double-press within 1000ms) |
| `Ctrl+\` | | Open or toggle the [Agent Dashboard](23-dashboard.md) | No |
| `Ctrl+Q` | `Ctrl+D` | Quit the application | Yes (double-press within 1000ms) |
**VS Code family terminal** (VS Code, Cursor, Windsurf, Zed integrated terminals): `Ctrl+Q` is captured by the host, so Grok makes **`Ctrl+D` the sole quit key** (`Ctrl+Q` is not bound). Half-page-down is rebound to bare **`Shift+D`**. Mid-turn interject uses **`Ctrl+L`** (no alternates) because `Ctrl+Enter` / `Ctrl+I` do not reliably reach the PTY; extensions are opened via `/plugins` instead of `Ctrl+L`.
@ -239,6 +240,30 @@ Bindings that only fire on the welcome screen (before any agent session is open)
---
## Agent Dashboard
Bindings while the [Agent Dashboard](23-dashboard.md) is focused (`Ctrl+\` or `/dashboard`).
| Key | Action |
|-----|--------|
| `↑` / `↓`, `j` / `k` | Navigate agent rows (selecting a row opens peek) |
| `Enter` | Open the selected agent, or send a typed peek reply / dispatch prompt |
| `Ctrl+S` | Reply or dispatch **and** attach to that agent |
| `Ctrl+/` | Toggle search / filter mode |
| `Ctrl+R` | Rename the selected agent |
| `Ctrl+T` | Pin / unpin |
| `Ctrl+G` | Toggle grouping (state ↔ working directory) |
| `Ctrl+X` | Stop a running turn, or press twice within 2s to close the session |
| `Ctrl+O` | Toggle always-approve on the selected agent |
| `Tab` | Toggle focus between the list and the dispatch / peek input |
| `Esc` | Step back (cancel search → close peek → clear filter → unfocus → unselect → exit) |
| `Ctrl+\` | Exit the dashboard (or return from an attached agent) |
| `Ctrl+.` (alt: `?`) | Shortcuts cheatsheet |
Details (peek vs dispatch, search prefixes, persistence): [Agent Dashboard](23-dashboard.md).
---
## Command Palette
Press `Ctrl+P` or `?` to open the command palette -- a searchable list of actions. The palette shows:

View file

@ -18,6 +18,12 @@ Start a fresh session and clear the current conversation. Alias: `/clear`.
Open the session picker to reload a previous session from disk.
### `/dashboard`
Open the [Agent Dashboard](23-dashboard.md): live roster of top-level sessions in this pager (peek, reply, dispatch, pin, rename, stop, attach). Aliases: `/agents-dashboard`, `/sessions`.
Not `/config-agents` (alias `/agents`), which manages agent *definitions* and personas. Hidden in minimal mode; disable with `GROK_AGENT_DASHBOARD=0` or `[dashboard].enabled = false`.
### `/compact [context]`
Compress conversation history to reclaim context-window space. Pass a note to tell Grok what to keep:
@ -80,6 +86,12 @@ Quit the application. Alias: `/exit`.
Leave the current session and return to the welcome screen. Alias: `/welcome`.
### `/delete`
Delete the current session's history and return to the welcome screen. Confirms first.
To delete a session you are not in, open `/resume` and press `d` then `y`.
### `/rename`
Rename the current session. Alias: `/title`.
@ -369,6 +381,8 @@ Open the Claude import modal to bring over `~/.claude` settings: permissions, en
Open the agents modal to view and manage agent definitions, set the default, and switch the active one. Alias: `/agents`.
Not the live multi-session [Agent Dashboard](23-dashboard.md) (`/dashboard` / `Ctrl+\`).
### `/personas`
Create, edit, and delete personas. A subagent can apply a persona to shape how it behaves.

View file

@ -62,6 +62,14 @@ End the session and quit Grok:
Alias: `/exit`. To leave the current session but stay in Grok, use `/home` to return to the welcome screen.
### Delete the current session
```
/delete
```
Confirms, then permanently removes the session history and returns to the welcome screen. From `/resume`, press `d` then `y` on a row to delete a session you are not currently in.
---
## Resuming Sessions
@ -78,7 +86,7 @@ This opens a session picker that lists recent sessions for the current workspace
Typing in the picker filters the list by title and also searches your conversation content as you type; content matches appear under an "Extended search results" heading. Press `Ctrl+/` to search immediately without the brief pause.
To switch between, rename, or close the sessions that are currently active (the parent session and any forks), use `/dashboard` (or its alias `/sessions`) instead.
For the live top-level sessions in this pager (parent and forks) — switch, rename, peek, dispatch, or close — use the [Agent Dashboard](23-dashboard.md): `/dashboard` (aliases `/sessions`, `/agents-dashboard`) or `Ctrl+\`.
### From the Command Line

View file

@ -1,22 +1,26 @@
# Agent Dashboard
The Agent Dashboard is a centralised, agent-native overview of every
top-level session you have in flight — your local sessions and forks
— grouped by state, with peek, attach, and dispatch from one screen.
Subagents are not listed here: they run under their parent session,
which already shows when work is in flight.
The Agent Dashboard lists every top-level session in this pager process —
local sessions and forks — grouped by state. From one screen you can peek,
reply, attach, pin, rename, stop, or dispatch a new agent. Subagents are not
listed; they run under their parent, which already shows when work is in
flight.
Not the agents modal (`/config-agents` / `/agents` — definitions and
personas), the session picker (`/resume` / `Ctrl+S` — past conversations on
disk), or the workflows run UI (`/workflows`).
---
## Opening the dashboard
Three entry points, all opening the same view:
- **`grok dashboard`** — launches the TUI directly into the dashboard.
- **`grok dashboard`** — launch the TUI into the dashboard.
- **`/dashboard`** (aliases **`/agents-dashboard`**, **`/sessions`**) — open
from inside an active session.
- **Ctrl+\\** — same as the slash command, two keystrokes. Configurable
in `~/.grok/config.toml` under `[keybindings]` like every other shortcut.
from inside a session.
- **`Ctrl+\`** — same view as the slash command.
Hidden in minimal mode. Set `GROK_AGENT_DASHBOARD=0` or
`[dashboard].enabled = false` to disable.
---
@ -35,46 +39,36 @@ Three entry points, all opening the same view:
↑/↓ select (peek) · Enter open · Ctrl+R rename · Ctrl+T pin · Ctrl+X stop · ? help · Esc new
```
Each row is a top-level agent (subagents aren't shown — they run under
their parent). Rows are sorted by state (Needs input → Working → Idle →
Inactive → Completed → Failed) so same-state rows sit adjacent, or by
working directory (toggle with `Ctrl+G`). **Inactive** holds roster-only
sessions — idle/dormant sessions owned by other pager processes that
haven't been loaded in this one — so **Idle** stays focused on the
sessions you're actively cycling between. Because it's background noise,
**Inactive starts collapsed** (expand it with `→` / click — see below).
Each row is a top-level agent. Sort by state (Needs input → Working → Idle →
Inactive → Completed → Failed) so same-state rows sit together, or by working
directory (`Ctrl+G` toggles). **Inactive** is roster-only sessions owned by
other pager processes that this process has not loaded — background noise, so
the section **starts collapsed** (expand with `→` / click).
To keep the **Idle** group scannable, only the most recent idle agents
stay visible — the 8 freshest, plus any active within the last hour. The
rest fold into a **"N more"** row (marked with a `+` / `-` toggle) at the
bottom of the group; select it and press `Enter` / `→` (or click it) to
reveal them all, and `←` to re-fold. The Idle header always shows the true total. Folding is
suspended while a filter or search is active (so every match shows).
To keep **Idle** scannable, only the most recent idle agents stay visible —
the 8 freshest, plus any active within the last hour. The rest fold into a
**"N more"** row at the bottom of the group; select it and press `Enter` /
`→` (or click) to expand, `←` to re-fold. The Idle header always shows the
true total. Folding is suspended while a filter or search is active.
The state icon matches Grok Build's sibling views (
`tasks_pane`):
State icons match other session lists in Grok Build:
- `⋅`/`:`/`⸬`/`⁙` — animated spinner for **Working** rows.
- `⋅`/`:`/`⸬`/`⁙` — animated spinner for **Working**
- `●` — filled circle for **Needs input**, **Completed**, **Failed**,
**Blocked**. Colour communicates the state (yellow / green / red /
amber).
- `○` — hollow circle for **Idle** and **Inactive** rows.
**Blocked** (color: yellow / green / red / amber)
- `○` — hollow circle for **Idle** and **Inactive**
A row stays in **Working** while it has live background work even if its
turn has finished — a running background task, a `monitor`, or an active
scheduled `/loop`. The activity line says what's running (e.g.
`1 monitor · 2 loops still running`), since each can wake the agent for a
new turn.
A row stays **Working** while it has live background work even if its turn
has finished — a background task, a `monitor`, or an active scheduled
`/loop`. The activity line says what is still running (for example
`1 monitor · 2 loops still running`).
There are no inline group headers — the sort order keeps same-state
rows adjacent and the per-row dot+colour communicates which group
each row belongs to (matching other session lists).
There are no inline group headers; sort order keeps same-state rows adjacent,
and the per-row dot + color shows the group.
The dispatch input shares the same `PromptWidget` chrome as the
agent view's prompt (rounded box, `` prefix, accent border, info
line). Pressing `Ctrl+/` flips it into **search mode**: the ``
prefix becomes a yellow `Search:` and whatever you type live-filters
the row list instead of being dispatched.
The dispatch input uses the same prompt chrome as the agent view. Press
`Ctrl+/` to flip it into **search mode**: the `` prefix becomes a yellow
`Search:` and typing live-filters the list instead of dispatching.
---
@ -82,248 +76,185 @@ the row list instead of being dispatched.
| Key | Action |
| --- | --- |
| `↑` / `↓`, `j` / `k` | Navigate rows AND section titles (selecting a row opens its peek panel) |
| `→` / `←` (on a section title) | Expand / collapse the section (shows / hides its rows); `l` / `h` in vim mode |
| `↑` / `↓`, `j` / `k` | Navigate rows and section titles (selecting a row opens peek) |
| `→` / `←` (on a section title) | Expand / collapse the section (`l` / `h` in vim mode) |
| `Enter` (on a section title) | Toggle the section collapsed / expanded |
| `Enter` (empty reply) | Open the selected agent's conversation full-screen (details view) |
| `Ctrl+S` | Send the peek reply AND open the agent (or dispatch + attach a new session) |
| `Shift+Enter` / `Alt+Enter` | Insert a newline in the reply / dispatch input (multiline compose) |
| `1``9` | Answer a pending permission / ask question (when the peek shows options) |
| `Enter` (empty reply) | Open the selected agent full-screen (details view) |
| `Ctrl+S` | Send the peek reply and open the agent (or dispatch and attach a new session) |
| `Shift+Enter` / `Alt+Enter` | Newline in the reply / dispatch input |
| `1``9` | Answer a pending permission / ask question when peek shows options |
| `Enter` (typed reply) | Send / queue the reply to the selected agent |
| `/` | Types a literal `/` into the prompt |
| `Ctrl+/` | Toggle search mode (live-filter the rows) |
| `/` | Literal `/` into the prompt |
| `Ctrl+/` | Toggle search mode (live-filter rows) |
| `Ctrl+R` | Rename selected row |
| `Ctrl+T` | Pin / unpin |
| `Ctrl+G` | Toggle grouping (state ↔ directory) |
| `Ctrl+X` | Stop / kill (two presses within 2s to close a session) |
| `Shift+↑` / `Shift+↓` | Reorder pinned rows |
| `Esc` | Step back one level: cancel search → close peek (clear reply draft, then unselect) → clear filter → **unfocus the dispatch input** (so `↑`/`↓`, `j`/`k` navigate the list) → unselect row (→ `[+ New Agent]`) → exit dashboard. Esc never clears your typed dispatch draft — use `Ctrl+U` / `Ctrl+C` for that |
| `Ctrl+\` | Return to the dashboard from the details view, or exit dashboard |
| `Ctrl+.` (alt: `?`) | Open the keyboard shortcuts cheatsheet. Footer advertises `?` when `Ctrl+.` cannot be delivered. Bare `?` opens help when list-focused or the draft is empty (otherwise types); `Ctrl+X` remains stop |
| `Esc` | Step back: cancel search → close peek → clear filter → unfocus dispatch → unselect row → exit. Never clears a typed dispatch draft (`Ctrl+U` / `Ctrl+C` for that) |
| `Ctrl+\` | Return from details view, or exit dashboard |
| `Ctrl+.` (alt: `?`) | Keyboard shortcuts cheatsheet. Footer shows `?` when `Ctrl+.` cannot be delivered. Bare `?` opens help when list-focused or the draft is empty |
When grouping by state, each group has a **section title** (e.g. `Working`,
`Idle`) with a `▸`/`▾` disclosure marker. Section titles are part of the
up/down navigation: select one and press `→` to expand it (showing its rows)
or `←` to collapse it — `l` / `h` do the same when vim mode is on.
**Clicking** a section title toggles it, and **hovering**
brightens its text. Collapse state is remembered while the dashboard stays open.
The **Inactive** section starts collapsed by default each time the pager
starts; expanding it sticks until you quit.
When grouping by state, each group has a **section title** (for example
`Working`, `Idle`) with a `▸`/`▾` marker. Select a title and press `→` /
`←` to expand or collapse (`l` / `h` in vim mode). Click toggles; hover
brightens. Collapse state is remembered while the dashboard stays open.
**Inactive** starts collapsed each time the pager starts; expanding it sticks
until you quit.
Opening a row shows the agent's conversation in the **details view**:
a single top header row (the agent name on the left, `{i}/{n} [][]
[Dashboard]` cycle/close affordances on the right) sits above the conversation,
which renders **full-width** — no bordered modal frame — so the prompt
position and overall padding match the dashboard list view. All key
presses route to the attached agent; `Esc` / `Ctrl+\\` (or the `[Dashboard]`
affordance) return to the dashboard, the `[]` / `[]` chips cycle to
the previous / next agent, and the agent's shortcuts bar shows a
`Ctrl+\\: back to dashboard` hint. Quick gotcha — `Esc` only returns to
the dashboard; typing `/exit` inside the agent actually closes the
underlying session (returning to the dashboard with a "Session closed"
toast).
Opening a row shows the agent's conversation in the **details view**: a top
header (agent name; `{i}/{n}` cycle chips and `[Dashboard]` on the right)
above a full-width conversation — no bordered modal — so padding matches the
list view. Keys go to the attached agent; `Esc` / `Ctrl+\` (or `[Dashboard]`)
return to the dashboard; `[]` / `[]` cycle agents. The shortcuts bar shows
`Ctrl+\: back to dashboard`. Gotcha: `Esc` only returns; `/exit` inside the
agent closes the session (dashboard toast: "Session closed").
`Ctrl+X` in the details view is state-dependent. While a **turn is
running** it cancels the turn — the same behaviour as `Ctrl+C`,
including the keep-subagents prompt — and never touches the session
itself, so mashing it to stop a turn can't close anything. In any
other state — **idle**, a slash command in flight (commands can't
be cancelled yet), or a cancel still pending — `Ctrl+X` arms a
confirmation: the shortcuts bar flips to "press Ctrl+x again to
close this session", and a second press within 2 seconds closes the
session and returns you to the dashboard. Pressing any other key
cancels the confirmation, and a turn that starts inside the window
downgrades the confirmed press to a cancel instead of closing.
(On terminals where `Ctrl+X` doubles as the shortcuts-cheatsheet
binding, the cheatsheet stays reachable via `Ctrl+.` inside the
details view.)
`Ctrl+X` in the details view is state-dependent. While a **turn is running**
it cancels the turn (same as `Ctrl+C`, including the keep-subagents prompt)
and never closes the session. Otherwise — **idle**, a slash command in
flight, or a cancel still pending — `Ctrl+X` arms a confirmation: press again
within 2 seconds to close the session and return to the dashboard. Any other
key cancels the confirmation; a turn that starts inside the window turns the
confirmed press into a cancel instead. (If `Ctrl+X` is also the cheatsheet
binding on your terminal, use `Ctrl+.` inside the details view.)
For the full behavioural specification (including the registry-lookup
rules and the mouse-event intercept matrix) see plan
[§3.10](../../plan/agent-dashboard.md) "Keybindings (v1)" — the user
guide here is intentionally short and cross-references the plan as
the source of truth.
See [Keyboard Shortcuts](03-keyboard-shortcuts.md#agent-dashboard).
All shortcuts are registered under `When::DashboardFocused` and can be
rebound via `~/.grok/config.toml`.
---
## Completing or closing a session
There is **no** “mark completed” command. Row state is derived from the agent:
- **Completed** / **Failed** when work ends on its own (turn finished and no
background task / monitor / `/loop` still running).
- **`Ctrl+X` once** while a turn is running cancels the turn.
- **`Ctrl+X` twice** (within 2s) on an idle / stopped row **closes** the
session and removes it from the live roster.
- In the details view, `/exit` also closes the session (Esc only returns).
Use close/stop when you want a row gone; there is no manual complete flag.
---
## Dispatch input
The bottom textarea **always spawns a NEW session** — it is never a
reply target. A selected row is the overview's navigation cursor, not a
reply destination; to talk to an existing agent, open it (navigate +
`Enter`, or click) and reply inside its own view.
The bottom textarea **always spawns a new session**. A selected row is the
navigation cursor, not a reply target — open an agent to talk to it.
Enter handler:
- Free text → new top-level session seeded with the prompt. Text is never
treated as a filter (even if it starts with `/`, `s:`, `a:`, or `#`);
filtering is `Ctrl+/` search mode. A leading `/` runs a pager-global slash
command.
- Empty input → open the selected row, or create a new agent when
`[+ New Agent]` is focused.
- Free text → creates a new top-level session, seeded with the prompt.
Text is **never** reinterpreted as a filter — a prompt may start with
`/`, `s:`, `a:`, or `#` and still dispatches verbatim (filtering is
the explicit `Ctrl+/` search mode). A leading `/` runs a pager-global
slash command.
- Empty input → opens the selected row (`Attach`), or creates a new
agent when the `[+ New Agent]` button is focused.
`Ctrl+S` after typing dispatches **and** attaches; plain `Enter` stays on the
dashboard so you can dispatch several sessions. `Shift+Enter` / `Alt+Enter`
insert a newline; the box grows with the draft (up to a cap, then scrolls).
Press `Ctrl+S` after typing a prompt to dispatch AND attach
(jump into the new session); plain `Enter` stays on the dashboard so
you can dispatch several sessions in a row. `Shift+Enter` / `Alt+Enter`
insert a newline for a multi-line prompt — the box **grows in height**
as you add lines (up to a cap, after which it scrolls), so the whole
draft stays visible.
The dispatch input accepts any non-empty prompt; an empty /
whitespace-only prompt is ignored. Prompts above 64 KiB are rejected
with a toast.
Empty or whitespace-only prompts are ignored. Prompts above 64 KiB are
rejected with a toast.
### Focus: input bar ↔ overview list (`Tab`)
The dashboard has two focus areas — the **dispatch input bar** (typing)
and the **overview list** (navigating). `Tab` toggles between them; the
inactive input dims its border and hides its caret.
Two focus areas: the **dispatch input** and the **overview list**. `Tab`
toggles between them; the inactive input dims its border and hides its caret.
On open, focus defaults to the **overview list** when at least one agent
exists (so `↑`/`↓` / vim `j`/`k` navigate immediately). With **no**
agents, focus stays on the **dispatch input** so you can type a first
prompt right away. Either way, the `[+ New Agent]` button is the cursor
target (no agent row is pre-selected).
exists (so `↑`/`↓` / vim `j`/`k` navigate immediately). With **no** agents,
focus stays on the **dispatch input**. Either way, the cursor starts on
`[+ New Agent]` (no agent row pre-selected).
- **Input focused**: type to compose a new-session prompt. `↑`/`↓`
navigate the row list when the prompt is empty (a convenience),
otherwise move the caret. `Esc` unfocuses the input → overview list
(your typed draft is kept) so you can navigate straight away.
- **Overview focused**: `↑`/`↓` — and, in **vim mode**, `j`/`k` — move
between agent rows. `Enter` opens the highlighted agent (on
`[+ New Agent]`, it sends a typed draft, else creates a new session).
`Esc` **stays on the list** and steps back — clearing an active filter,
then unselecting the row (→ `[+ New Agent]`), then exiting the
dashboard. `Tab` or `i` (vim) — or any other printable key — return to
the input.
- **Input focused**: type a new-session prompt. Empty prompt: `↑`/`↓`
navigate rows; non-empty: move the caret. `Esc` unfocuses to the list
(draft kept).
- **Overview focused**: `↑`/`↓` (and vim `j`/`k`) move between rows. `Enter`
opens the highlighted agent (on `[+ New Agent]`, sends a typed draft or
creates a new session). `Esc` stays on the list and steps back — clear
filter, then unselect (→ `[+ New Agent]`), then exit. `Tab`, `i` (vim), or
any printable key returns to the input.
---
## Peek panel
The peek panel is shown **by default whenever an agent row is
selected** — it **replaces** the new-session dispatch box. With no row
selected (the `[+ New Agent]` button focused, or after `Esc`), the
dispatch box returns for starting a new session. So selecting a row is
how you talk to an existing agent; deselecting is how you start a new
Selecting an agent row shows the **peek panel** in place of the dispatch box.
With no row selected (`[+ New Agent]`, or after `Esc`), the dispatch box
returns. Select a row to talk to an existing agent; deselect to start a new
one.
The panel shows, top to bottom, a header (the **last response type**
`Thinking` / `Thought` / `Response` / `Edit` / `Read` / `Bash` / … — on
the left, **time** on the far right), the most recent response
(**word-wrapped** to fit, up to ~3 rows), and a live ` reply` input. A
`…` marker appears on the last row only when there's more than fits.
Top to bottom: header (**last response type** — `Thinking` / `Thought` /
`Response` / `Edit` / `Read` / `Bash` / … — and **time**), the most recent
response (word-wrapped, up to ~3 rows; `…` when truncated), and a live
` reply` input.
The selected agent's **model** and, when it's in always-approve (yolo)
mode, an **`always-approve`** flag are shown on the panel's **bottom
border** (bottom-right) — the same config-badge slot the new-session
dispatch box uses. This holds in the question / approval modes too, so
the model and approval mode are always in view while you answer. (The
dashboard list rows no longer repeat the model or an always-approve badge,
keeping the list compact.)
The selected agent's **model** and, in always-approve (yolo) mode, an
**`always-approve`** flag sit on the panel's bottom border (same badge slot as
the dispatch box), including while answering questions. List rows no longer
repeat model or always-approve badges.
**`Shift+Tab` cycles the peeked agent's mode** (Normal → Plan →
Always-approve → Normal) — the same cycle as Shift+Tab inside that agent's
chat view, applied to the **live** agent (the badge updates to match).
This differs from the new-session dispatch box, where Shift+Tab only
stages the mode for the *next* agent.
Always-approve → Normal) on the **live** agent. On the dispatch box,
Shift+Tab only stages mode for the *next* agent.
Unlike the dispatch box (which only ever spawns new sessions), the
peek's reply **talks to the selected agent**:
Unlike dispatch (new sessions only), peek reply **talks to the selected
agent**:
- **Type into ` reply`, then `Enter`** to send. An **idle** agent
starts the turn immediately; a **busy** agent **queues** the message
so it sends after the current turn finishes (the same queue/drain
behaviour as the agent view's own prompt). `Ctrl+S` replies AND
opens the agent's detail view; `Shift+Enter` / `Alt+Enter` insert a
newline (multiline compose) and the reply **grows in height** to fit
the draft (up to a cap, then it scrolls).
- With an **empty** reply, `Enter` opens the agent.
- **`↑`/`↓` move the caret within the reply** once it has content (so you
can edit a multi-line draft). While the reply is **empty** (or
unfocused via `Tab`), `↑`/`↓` instead **switch the selected agent**
the panel follows the selection cursor and refreshes live, and the
switch clears any half-typed draft so a reply can't land on the wrong
agent. (`Tab` to the row list to navigate agents while a draft is in
the reply.)
- **`Esc` unselects**: it first clears a typed reply, then deselects the
row and focuses the `[+ New Agent]` button (bringing back the
new-session input).
- **`Tab`** toggles focus between the reply input and the row list: an
unfocused reply dims its border and hides the caret; a printable key
re-focuses it and starts composing.
- The reply is a **full prompt editor** (the same component as the
dispatch box and the agent prompt): pasting multi-line text folds
into a `[Pasted: N lines]` chip with the same preview overlay and
expand affordances as the agent prompt (`Enter` / double-click /
paste-again), mouse click / drag place the caret and select text,
and the usual editing chords work (word navigation, `Ctrl+A`/`Ctrl+E`,
`Alt+Backspace`, `Ctrl+W`/`Ctrl+U`/`Ctrl+K`, undo, Shift+arrow
selection, `Ctrl+Shift+V` inline paste).
Typing **`@`** opens the file-context picker rooted at the **peeked
agent's** working directory (so `@path` resolves against the agent
you're replying to); its dropdown floats **above** the panel and
`↑`/`↓`/`Tab`/`Enter`/`Esc` drive it while it's open.
Dashboard chords (`Ctrl+X` stop, `Ctrl+T` pin, `Shift+↑/↓` reorder,
…) still win over the editor while the panel is open.
- When a **permission / ask-tool question** is pending, the ` reply`
row is hidden and the options are listed instead: **`↑`/`↓` move the
highlighted option** (marked with `▸`) and **`Enter` answers** it.
**`1``9`** still answer an option directly. (While answering, the
arrows pick options rather than switching agents.)
- The **free-text row** accepts an inline typed answer (just like the
chat panel): the permission **"No" / reject** option ("No, reject
(type to add feedback)") and the ask-tool **"Other"** row ("Other
(type your own answer)"). Type on it and `Enter` sends the rejection +
message / the free-text answer.
- This also covers the agent's **Ask tool** (`AskUserQuestion`): its
options + the "Other" row show in the peek, answered the same way.
**Multi-question** forms are walked one question at a time — a `(i/N)`
marker shows progress and each answer advances to the next, submitting
on the last. (Forms with a **multi-select** question are left to the
agent's own view — open the agent to answer those.)
- **Type into ` reply`, then `Enter`** to send. Idle agents start immediately;
busy agents **queue** the message (same as the agent view prompt). `Ctrl+S`
replies and opens the detail view; `Shift+Enter` / `Alt+Enter` insert a
newline (reply grows with the draft).
- Empty reply + `Enter` opens the agent.
- **`↑`/`↓` move the caret** once the reply has content. While empty (or
unfocused via `Tab`), `↑`/`↓` **switch the selected agent** — the panel
follows, and a half-typed draft is cleared so it cannot land on the wrong
agent. (`Tab` to the list to navigate while a draft is in the reply.)
- **`Esc` unselects**: clear a typed reply first, then deselect and focus
`[+ New Agent]`.
- **`Tab`** toggles focus between reply and row list; a printable key
re-focuses the reply.
- Full prompt editor (same as dispatch / agent prompt): multi-line paste
chips, mouse select, word navigation, `Ctrl+A`/`Ctrl+E`, `Alt+Backspace`,
`Ctrl+W`/`Ctrl+U`/`Ctrl+K`, undo, Shift+arrow selection, `Ctrl+Shift+V`
inline paste. **`@`** opens the file picker rooted at the **peeked agent's**
working directory; the dropdown floats above the panel. Dashboard chords
(`Ctrl+X` stop, `Ctrl+T` pin, `Shift+↑/↓` reorder, …) still win while the
panel is open.
- Pending **permission / ask-tool** question: ` reply` hides; options list
instead. **`↑`/`↓` highlight**, **`Enter` answers**, **`1``9`** answer
directly. Free-text **No / reject** and ask-tool **Other** accept a typed
answer on the free-text row. Multi-question Ask forms walk one at a time
(`(i/N)`); multi-select forms need the agent's own view.
The panel only renders when the terminal is tall enough; on very short
terminals the dispatch box shows even with a row selected.
On very short terminals the panel may not fit; the dispatch box stays even
with a row selected.
---
## Search / filter (`Ctrl+/`)
Filtering lives behind an explicit **search mode** so normal typing
always dispatches. Press `Ctrl+/` to toggle it: the prompt prefix
flips from `` to a yellow `Search:` and every keystroke live-filters
the row list.
`Ctrl+/` toggles search mode so normal typing always dispatches. Prefix
flips from `` to yellow `Search:`; every keystroke live-filters the list.
Inside search mode:
- `Enter` — confirm: keep the filter and return to the dispatch prompt.
- `Esc` or `Ctrl+/` — cancel: clear the filter and exit search.
- `↑` / `↓` — navigate filtered rows.
- `Enter`**confirm**: keep the filter applied and return to the
dispatch prompt (rows stay filtered; `Esc` later clears them).
- `Esc` or `Ctrl+/`**cancel**: clear the filter and exit search.
- `↑` / `↓` — navigate the filtered rows.
Prefixes (only inside search mode):
The query supports the same prefixes as before (they are only honoured
*inside* search mode now):
- `a:<name>` — filter by agent label (case-insensitive substring,
matches persona / role).
- `s:<state>` — filter by row state. Accepts `working`, `idle`,
`completed`, `failed`, `needs-input`, `blocked` and synonyms
(`busy`/`running`/`done`/etc.).
- `#<text>` — substring match on `#<text>` (matches the literal
`#` in labels; reserved for future PR filtering).
- anything else — plain substring match over label + working dir.
- `a:<name>` — agent label (case-insensitive substring; persona / role).
- `s:<state>` — row state: `working`, `idle`, `completed`, `failed`,
`needs-input`, `blocked` and synonyms (`busy`/`running`/`done`/etc.).
- `#<text>` — substring match on `#<text>` (literal `#` in labels).
- anything else — substring over label + working dir.
---
## Persistence
Per-user dashboard preferences live under `[dashboard]` in
`~/.grok/config.toml`:
Per-user preferences under `[dashboard]` in `~/.grok/config.toml`:
```toml
[dashboard]
@ -333,19 +264,5 @@ pinned = ["top:<session_id>", "sub:<parent_session_id>:<child_session_id>"]
reorder = ["top:<session_id>"]
```
Pinned/reorder entries are keyed by **session id**, not by the
per-process `AgentId(usize)`, so they survive restarts and don't
attach to whatever agent happens to share the old slot number.
Set `GROK_AGENT_DASHBOARD=0` to force-disable the feature for a single
pager invocation; the slash command and CLI subcommand will print a
friendly toast.
---
## Phase 4 (out of scope for v1)
The current dashboard lists only agents owned by **this** pager
process. The plan's Phase 4 ("supervisor / `grok --bg`") would list
sessions that survive pager exit — that's a separate roadmap and not
shipped yet.
Pinned/reorder entries use **session id** (not a per-process agent slot), so
they survive restarts.

View file

@ -85,6 +85,7 @@ pub(crate) fn handle_ask_user_question(
LocalQuestionKind::AgentTypeMismatch { .. } => "model switch",
LocalQuestionKind::ProjectSelect { .. } => "project select",
LocalQuestionKind::DoctorFix { .. } => "/doctor fix",
LocalQuestionKind::DeleteCurrentSession => "/delete",
};
let message = if matches!(kind, LocalQuestionKind::DoctorFix { .. }) {
"/doctor fix was cancelled because another question opened.".to_owned()

View file

@ -56,6 +56,11 @@ pub enum Action {
ExitSession,
/// Exit session without double-press confirmation (e.g., from command palette).
ExitSessionConfirmed,
/// `/delete`: confirm, then delete history and return home.
DeleteCurrentSession,
DeleteCurrentSessionAnswered {
confirmed: bool,
},
/// Open grok.com in the browser for SuperGrok subscription upsell.
OpenSupergrokUrl,
/// Re-check subscription status via the shell's `x.ai/auth/check_subscription`.
@ -1368,6 +1373,14 @@ pub struct DoctorFixTarget {
pub session_binding_epoch: u32,
pub cwd: std::path::PathBuf,
}
/// Aftermath of a successful session delete.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AfterSessionDelete {
/// Picker delete — stay put.
Stay,
/// `/delete` — return to welcome.
Welcome,
}
#[derive(Debug)]
pub enum Effect {
/// Create a new ACP session.
@ -2012,6 +2025,7 @@ pub enum Effect {
source: String,
session_id: String,
cwd: String,
after: AfterSessionDelete,
},
/// Deep-search sessions by content (FTS via ACP).
DeepSearchSessions { query: String, seq: u64 },
@ -2587,6 +2601,7 @@ pub enum TaskResult {
DeleteSessionComplete {
source: String,
session_id: String,
after: AfterSessionDelete,
},
/// Session delete failed.
DeleteSessionFailed {

View file

@ -1733,6 +1733,11 @@ fn translate_local_submit(
InputOutcome::Action(Action::DoctorFixCancelled(target))
}
}
LocalQuestionKind::DeleteCurrentSession => {
InputOutcome::Action(Action::DeleteCurrentSessionAnswered {
confirmed: *idx == 0,
})
}
LocalQuestionKind::ProjectSelect { .. } => unreachable!(),
}
}

View file

@ -448,7 +448,9 @@ impl AgentView {
{
let history = self.combined_prompt_history();
let current_text = self.prompt.text().to_string();
if !history.is_empty() {
// Without a matcher thread the panel can never populate, and filling
// the composer would only be undone by the next Down/Enter.
if !history.is_empty() && self.prompt.history_search.is_available() {
self.prompt
.history_search
.activate_browse(&history, &current_text);

View file

@ -265,7 +265,7 @@ pub enum TickDemand {
pub const SLOW_TICK_INTERVAL: Duration = Duration::from_millis(83);
/// Welcome toast lifetime (wall clock, so the duration holds whether the
/// event loop is ticking Slow or Fast).
const WELCOME_TOAST_DURATION: Duration = Duration::from_secs(4);
const WELCOME_TOAST_DURATION: Duration = Duration::from_secs(2);
/// Which prompt box in-flight voice dictation appends its finalized text to.
/// Captured when recording **starts** so a trailing STT final still lands where
/// the user was dictating, even if they navigate away — or toggle a dashboard
@ -1197,8 +1197,16 @@ fn privacy_banner_reshow_elapsed(acked_at: &str, reshow_days: Option<u64>) -> bo
};
chrono::Utc::now() >= next
}
/// Bottom-right toast overlay on the welcome screen (mirrors agent toast style).
fn paint_welcome_toast(buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, msg: &str) {
/// Welcome-screen toast overlay (mirrors agent toast style).
///
/// Prefer one row above the prompt, right-aligned to it. Fall back to
/// the view bottom-right when no prompt rect is available (login / gate).
fn paint_welcome_toast(
buf: &mut ratatui::buffer::Buffer,
area: ratatui::layout::Rect,
msg: &str,
prompt_rect: Option<ratatui::layout::Rect>,
) {
let theme = crate::theme::Theme::current();
let max_msg = (area.width as usize).saturating_sub(4);
if max_msg == 0 || area.height == 0 {
@ -1211,8 +1219,16 @@ fn paint_welcome_toast(buf: &mut ratatui::buffer::Buffer, area: ratatui::layout:
format!(" {}", truncated.trim_end())
};
let w = toast.chars().count() as u16;
let x = area.right().saturating_sub(w + 1);
let y = area.bottom().saturating_sub(1);
let (x, y) = if let Some(prompt) = prompt_rect.filter(|r| r.width > 0 && r.y > area.y) {
let max_x = area.right().saturating_sub(w).max(area.x);
let x = prompt.right().saturating_sub(w + 1).clamp(area.x, max_x);
(x, prompt.y.saturating_sub(1))
} else {
(
area.right().saturating_sub(w + 1),
area.bottom().saturating_sub(1),
)
};
for (i, ch) in toast.chars().enumerate() {
if let Some(cell) = buf.cell_mut((x + i as u16, y)) {
cell.set_char(ch);
@ -1964,7 +1980,7 @@ impl AppView {
///
/// From the dashboard, toasts route into the dispatch input's inline
/// error slot. From an agent view the existing per-agent toast machinery
/// fires. On welcome, a bottom-right overlay for
/// fires. On welcome, an overlay above the prompt for
/// [`WELCOME_TOAST_DURATION`].
pub fn show_toast(&mut self, msg: &str) {
match self.active_view {
@ -4373,7 +4389,12 @@ impl AppView {
self.welcome_privacy_banner_policy_rect = result.privacy_banner_policy_rect;
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
if let Some((ref msg, _)) = self.welcome_toast {
paint_welcome_toast(f.buffer_mut(), view_area, msg);
paint_welcome_toast(
f.buffer_mut(),
view_area,
msg,
self.welcome_prompt_rect,
);
}
self.welcome_announcement.truncated = result.announcement_truncated;
self.welcome_announcement.rect = result.announcement_rect;

View file

@ -60,9 +60,10 @@ use super::session::fork::{
dispatch_startup_fork_session,
};
use super::session::lifecycle::{
clear_startup_actions, dispatch_agent_type_mismatch_answered, dispatch_exit_session,
dispatch_new_session, dispatch_new_session_inner, dispatch_new_session_with_id,
dispatch_new_worktree_session, dispatch_trust_folder, open_new_session_question,
clear_startup_actions, dispatch_agent_type_mismatch_answered,
dispatch_delete_current_session_answered, dispatch_exit_session, dispatch_new_session,
dispatch_new_session_inner, dispatch_new_session_with_id, dispatch_new_worktree_session,
dispatch_trust_folder, open_delete_current_session_question, open_new_session_question,
};
use super::session::load::{
dispatch_cycle_session_source_filter, dispatch_load_session, dispatch_pick_content_session,
@ -194,6 +195,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::NewSession => dispatch_new_session(app),
Action::ChooseNewSessionMode => open_new_session_question(app),
Action::ExitSession | Action::ExitSessionConfirmed => dispatch_exit_session(app),
Action::DeleteCurrentSession => open_delete_current_session_question(app),
Action::DeleteCurrentSessionAnswered { confirmed } => {
dispatch_delete_current_session_answered(app, confirmed)
}
Action::NewWorktreeSession {
load_session_id,
label,
@ -949,7 +954,11 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
Action::SendBtw(question) => dispatch_send_btw(app, question),
Action::SendRecap { auto } => dispatch_send_recap(app, auto),
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(app, opted_in),
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(
app,
opted_in,
xai_grok_telemetry::events::CodingDataConsentSource::Settings,
),
Action::ToggleYolo => dispatch_toggle_yolo(app),
Action::ToggleMultiline => dispatch_toggle_multiline(app),
Action::ToggleCompactMode => dispatch_toggle_compact_mode(app),
@ -1119,6 +1128,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
source,
session_id,
cwd,
after: crate::app::actions::AfterSessionDelete::Stay,
}]
}
Action::Fork(args) => dispatch_fork(app, args),

View file

@ -394,6 +394,108 @@ pub(in crate::app::dispatch) fn dispatch_exit_session(app: &mut AppView) -> Vec<
app.exit_session_pending = None;
effects
}
/// Confirm deleting the parent session (not a subagent view).
pub(in crate::app::dispatch) fn open_delete_current_session_question(
app: &mut AppView,
) -> Vec<Effect> {
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
use xai_grok_tools::implementations::grok_build::ask_user_question::{
Question, QuestionOption,
};
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if agent.session.session_id.is_none() {
app.show_toast("No active session to delete");
return vec![];
}
if agent.question_view.is_some() {
app.show_toast("Finish answering the current question first");
return vec![];
}
let question = Question {
question: "Delete this session permanently?".into(),
id: None,
options: vec![
QuestionOption {
label: "Delete".into(),
description: "Remove history and return home".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Cancel".into(),
description: "Keep the session".into(),
preview: None,
id: None,
},
],
multi_select: Some(false),
};
let stashed = agent.prompt.stash();
agent.question_view = Some(
QuestionViewState::new(
format!("delete-session-{}", uuid::Uuid::new_v4()),
vec![question],
stashed,
)
.with_local_kind(LocalQuestionKind::DeleteCurrentSession)
.with_no_freeform(),
);
agent.prompt.set_text("");
vec![]
}
pub(in crate::app::dispatch) fn dispatch_delete_current_session_answered(
app: &mut AppView,
confirmed: bool,
) -> Vec<Effect> {
if !confirmed {
return vec![];
}
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some((session_id, cwd, running_bg_tasks)) = app.agents.get(&id).and_then(|agent| {
let session_id = agent.session.session_id.clone()?;
let cwd = agent.session.cwd.display().to_string();
let running_bg_tasks: Vec<String> = agent
.session
.bg_tasks
.values()
.filter(|t| t.status == crate::app::agent::BgTaskStatus::Running)
.map(|t| t.task_id.clone())
.collect();
Some((session_id, cwd, running_bg_tasks))
}) else {
app.show_toast("No active session to delete");
return vec![];
};
let mut effects = vec![Effect::CancelTurn {
session_id: session_id.clone(),
cancel_subagents: true,
trigger: None,
rewind_if_pristine: false,
}];
effects.extend(
running_bg_tasks
.into_iter()
.map(|task_id| Effect::KillBgTask {
session_id: session_id.clone(),
task_id,
}),
);
app.show_toast("Deleting session\u{2026}");
effects.push(Effect::DeleteSession {
source: "current".into(),
session_id: session_id.to_string(),
cwd,
after: crate::app::actions::AfterSessionDelete::Welcome,
});
effects
}
/// Handle the user accepting the folder-trust question: persist the grant for
/// the workspace (writes `~/.grok/trusted_folders.toml`), mark trust resolved,
/// then replay any deferred session startup (only if auth is also done).

View file

@ -413,6 +413,18 @@ pub(in crate::app::dispatch) fn dispatch_pick_session_in_worktree(
}
dispatch_new_worktree_session(app, Some(session_id), None, None, None, None, None)
}
fn keep_picker_entry(
entry: &crate::app::app_view::SessionPickerEntry,
source: &str,
session_id: &str,
match_id_only: bool,
) -> bool {
if match_id_only {
entry.id != session_id
} else {
entry.source != source || entry.id != session_id
}
}
/// Remove a deleted session identity from the modal session picker and the
/// welcome-screen picker, then re-anchor the selection on a real row.
///
@ -422,6 +434,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
app: &mut AppView,
source: &str,
session_id: &str,
match_id_only: bool,
) {
use crate::views::modal::ActiveModal;
use crate::views::session_picker::build_entry_map;
@ -447,7 +460,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
*pending_delete = None;
}
if let Some(list) = entries.as_mut() {
list.retain(|entry| entry.source != source || entry.id != session_id);
list.retain(|entry| keep_picker_entry(entry, source, session_id, match_id_only));
}
if let Some(hits) = content_results.as_mut() {
hits.retain(|h| h.session_id != session_id);
@ -469,7 +482,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
reanchor_grouped_selection(state, &map);
}
if let Some(list) = app.session_picker_entries.as_mut() {
list.retain(|entry| entry.source != source || entry.id != session_id);
list.retain(|entry| keep_picker_entry(entry, source, session_id, match_id_only));
}
if let Some(hits) = app.session_picker_content_results.as_mut() {
hits.retain(|h| h.session_id != session_id);

View file

@ -110,9 +110,27 @@ fn is_current_coding_data_write(app: &AppView, seq: u64, agent_id: AgentId) -> b
false
}
fn log_coding_data_consent_selected(
source: xai_grok_telemetry::events::CodingDataConsentSource,
opted_in: bool,
previous_opted_in: bool,
) {
use xai_grok_telemetry::events::{CodingDataConsentChoice, CodingDataConsentSelected};
xai_grok_telemetry::session_ctx::log_event(CodingDataConsentSelected {
source,
choice: CodingDataConsentChoice::from_opted_in(opted_in),
previous_choice: CodingDataConsentChoice::from_opted_in(previous_opted_in),
changed: opted_in != previous_opted_in,
});
}
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
/// (persists via ACP ext-request, NOT `~/.grok/config.toml`).
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
pub(super) fn set_coding_data_sharing(
app: &mut AppView,
opted_in: bool,
source: xai_grok_telemetry::events::CodingDataConsentSource,
) -> Vec<Effect> {
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
if app.is_zdr {
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
@ -131,6 +149,7 @@ pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<
}
let agent_id = coding_data_sharing_agent_id(app);
let prev = !app.coding_data_retention_opt_out;
log_coding_data_consent_selected(source, opted_in, prev);
// ── Idempotent path: skip the ACP round-trip. ────────────────────
if prev == opted_in {
@ -455,7 +474,11 @@ pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_in(app: &mut AppView
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
return vec![];
}
let effects = set_coding_data_sharing(app, true);
let effects = set_coding_data_sharing(
app,
true,
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
);
// should_show guarantees opted-out + unguarded, so effects is only empty
// if a guard regresses; leaving inflight false keeps [Opt in] clickable.
app.privacy_banner_opt_in_inflight = !effects.is_empty();
@ -476,6 +499,12 @@ pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_out(app: &mut AppVie
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
return vec![];
}
let previous_opted_in = !app.coding_data_retention_opt_out;
log_coding_data_consent_selected(
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
false,
previous_opted_in,
);
let mut effects = ack_privacy_banner(app);
effects.push(Effect::SetCodingDataSharing {
agent_id: coding_data_sharing_agent_id(app),

View file

@ -39,6 +39,7 @@ use super::session::load::{
handle_session_loaded, handle_session_restore_failed, handle_session_restored,
handle_session_search_debounce_expired, remove_session_from_pickers,
};
use super::session::modal::remove_agent_and_cleanup;
use super::settings::ui::apply_setting_rollback;
use super::status::{
commit_session_usage_block, handle_coding_data_sharing_failed,
@ -896,10 +897,40 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
}
vec![]
}
TaskResult::DeleteSessionComplete { source, session_id } => {
remove_session_from_pickers(app, &source, &session_id);
TaskResult::DeleteSessionComplete {
source,
session_id,
after,
} => {
use crate::app::actions::AfterSessionDelete;
remove_session_from_pickers(
app,
&source,
&session_id,
after != AfterSessionDelete::Stay,
);
if after == AfterSessionDelete::Stay {
app.show_toast("Session deleted");
return vec![];
}
let sid = acp::SessionId::new(session_id.clone());
let to_remove: Vec<_> = app
.agents
.iter()
.filter(|(_, agent)| agent.session.session_id.as_ref() == Some(&sid))
.map(|(id, _)| *id)
.collect();
let foreground =
matches!(app.active_view, ActiveView::Agent(id) if to_remove.contains(&id));
for id in to_remove {
remove_agent_and_cleanup(app, id);
}
let mut effects = unregister_session_effect(Some(sid));
if foreground && after == AfterSessionDelete::Welcome {
effects.extend(dispatch_exit_session(app));
}
app.show_toast("Session deleted");
vec![]
effects
}
TaskResult::DeleteSessionFailed {
source,

View file

@ -1681,6 +1681,7 @@ fn translate_local_submit_never_returns_persist_never_for_new_session() {
}
#[test]
fn delete_session_action_emits_delete_effect() {
use crate::app::actions::AfterSessionDelete;
let mut app = test_app_with_agent();
open_session_picker_with(&mut app, vec![make_picker_entry("s1", "/repo")]);
let effects = dispatch(
@ -1691,17 +1692,108 @@ fn delete_session_action_emits_delete_effect() {
},
&mut app,
);
assert!(matches!(
effects.as_slice(),
[Effect::DeleteSession {
source,
session_id,
cwd,
after: AfterSessionDelete::Stay,
}] if source == "local" && session_id == "s1" && cwd == "/repo"
));
}
#[test]
fn delete_current_session_confirm_emits_effect() {
use crate::app::actions::AfterSessionDelete;
let mut app = test_app_with_agent();
{
let a = app.agents.get_mut(&AgentId(0)).unwrap();
a.session.session_id = Some(acp::SessionId::new("sess-current"));
a.session.cwd = std::path::PathBuf::from("/repo");
}
assert!(dispatch(Action::DeleteCurrentSession, &mut app).is_empty());
assert!(matches!(
app.agents[&AgentId(0)]
.question_view
.as_ref()
.unwrap()
.local_kind,
Some(crate::views::question_view::LocalQuestionKind::DeleteCurrentSession)
));
assert!(
dispatch(
Action::DeleteCurrentSessionAnswered { confirmed: false },
&mut app,
)
.is_empty()
);
let effects = dispatch(
Action::DeleteCurrentSessionAnswered { confirmed: true },
&mut app,
);
assert!(
matches!(
effects.as_slice(),
[Effect::DeleteSession {
source,
session_id,
cwd,
}] if source == "local" && session_id == "s1" && cwd == "/repo"
effects.first(),
Some(Effect::CancelTurn {
cancel_subagents: true,
..
})
),
"DeleteSession action must emit exactly one matching DeleteSession effect"
"must cancel the turn/subagents before delete, got {effects:?}"
);
assert!(
matches!(
effects.last(),
Some(Effect::DeleteSession {
session_id,
after: AfterSessionDelete::Welcome,
..
}) if session_id == "sess-current"
),
"got {effects:?}"
);
}
#[test]
fn delete_current_session_complete_welcome_and_guard() {
use crate::app::actions::{AfterSessionDelete, TaskResult};
let mut app = test_app_with_agent();
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id =
Some(acp::SessionId::new("sess-a"));
let effects = dispatch_task_result(
TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id: "sess-a".into(),
after: AfterSessionDelete::Welcome,
},
&mut app,
);
assert!(matches!(app.active_view, ActiveView::Welcome));
assert!(app.agents.is_empty());
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::UnregisterActiveSession { .. }))
);
let mut app = test_app_with_agent();
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id =
Some(acp::SessionId::new("sess-a"));
let other = AgentId(1);
let session = make_test_agent_session(&app, other, "unused");
app.agents
.insert(other, AgentView::new(session, ScrollbackState::new()));
app.agents.get_mut(&other).unwrap().session.session_id = Some(acp::SessionId::new("sess-b"));
app.active_view = ActiveView::Agent(other);
let effects = dispatch_task_result(
TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id: "sess-a".into(),
after: AfterSessionDelete::Welcome,
},
&mut app,
);
assert!(matches!(app.active_view, ActiveView::Agent(id) if id == other));
assert!(!app.agents.contains_key(&AgentId(0)));
assert!(!effects.iter().any(|e| matches!(e, Effect::Quit)));
}
#[test]
fn entry_title_falls_back_to_short_session_id_when_no_prompt() {

View file

@ -1701,6 +1701,7 @@ fn delete_session_complete_removes_only_matching_source_and_id() {
TaskResult::DeleteSessionComplete {
source: "local".into(),
session_id: "s1".into(),
after: crate::app::actions::AfterSessionDelete::Stay,
},
&mut app,
);
@ -1781,6 +1782,7 @@ fn delete_both_session_clears_modal_and_welcome_content_hits() {
TaskResult::DeleteSessionComplete {
source: "both".into(),
session_id: "shared".into(),
after: crate::app::actions::AfterSessionDelete::Stay,
},
&mut app,
);
@ -1878,6 +1880,7 @@ fn delete_remote_session_clears_modal_and_welcome_content_hits() {
TaskResult::DeleteSessionComplete {
source: "remote".into(),
session_id: "remote-only".into(),
after: crate::app::actions::AfterSessionDelete::Stay,
},
&mut app,
);

View file

@ -3247,7 +3247,7 @@ pub(crate) fn execute(
}
});
}
Effect::DeleteSession { source, session_id, cwd } => {
Effect::DeleteSession { source, session_id, cwd, after } => {
let tx = acp_tx.clone();
tasks
.spawn(async move {
@ -3291,6 +3291,7 @@ pub(crate) fn execute(
TaskResult::DeleteSessionComplete {
source,
session_id,
after,
}
}
Err(e) => {

View file

@ -37,16 +37,9 @@ use std::time::Duration;
use agent_client_protocol as acp;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::task::JoinSet;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use tokio_util::sync::CancellationToken;
use xai_acp_lib::{
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
AcpClientRx, LineBufferedRead, acp_send,
};
use xai_grok_shell::agent::config::Config as AgentConfig;
use xai_grok_shell::agent::mvp_agent::MvpAgent;
use xai_acp_lib::{AcpClientRx, acp_send};
use xai_grok_shell::leader::{
ClientCapabilities as LeaderClientCapabilities, ClientMode, ConnectionStatus,
LEADER_SOCKET_ENV, LeaderClient, LeaderEnvUrls, LeaderLock, LeaderReconnector,
@ -63,7 +56,6 @@ use crate::acp::leader_bridge::bridge_channels;
use crate::acp::model_state::ModelState;
use crate::scrollback::block::RenderBlock;
const SIMPLEX_BUF: usize = 8 * 1024 * 1024;
const PUMP_TICK: Duration = Duration::from_millis(10);
const TURN_BUDGET: Duration = Duration::from_secs(60);
@ -358,7 +350,7 @@ impl PagerLeaderCluster {
/// wire a fresh REAL agent behind it.
async fn spawn_leader_generation(&mut self) {
let _ = std::fs::remove_file(&self.sock_path);
let (acp_tx, mut acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (acp_tx, acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let cancel = CancellationToken::new();
self.server_cancel = cancel.clone();
@ -396,66 +388,10 @@ impl PagerLeaderCluster {
.await;
}));
// Real agent behind the server. Copied from `run_leader`'s
// agent-spawn + IPC/stdout bridge blocks in
// xai-grok-shell/src/agent/app.rs (inside its LocalSet body) — a
// deliberate copy so production stays untouched. Second copy of the
// same wiring: xai-grok-shell/tests/test_leader_soak.rs ("Real agent
// behind it" block) — keep the two copies behaviorally identical.
let (agent_in_read, agent_in_write) = tokio::io::simplex(SIMPLEX_BUF);
let (agent_out_read, agent_out_write) = tokio::io::simplex(SIMPLEX_BUF);
generation_tasks.push(tokio::task::spawn_local(async move {
let agent_config = AgentConfig::default();
let auth_manager = Arc::new(agent_config.create_auth_manager());
let (gw_tx, gw_rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(gw_tx);
let agent = MvpAgent::new(gateway, &agent_config, auth_manager, None)
.expect("valid agent config");
let incoming = LineBufferedRead::spawn_local(agent_in_read.compat());
let (conn, handle_io) = acp::AgentSideConnection::new(
agent,
agent_out_write.compat_write(),
incoming,
|fut| {
tokio::task::spawn_local(fut);
},
);
tokio::task::spawn_local(
GatewayReceiver::new(gw_rx, conn)
.with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent)
.run(),
);
let _ = handle_io.await;
}));
generation_tasks.push(tokio::task::spawn_local(async move {
let mut agent_in_write = agent_in_write;
while let Some(msg) = acp_rx.recv().await {
if agent_in_write.write_all(msg.as_bytes()).await.is_err()
|| agent_in_write.write_all(b"\n").await.is_err()
{
break;
}
}
}));
generation_tasks.push(tokio::task::spawn_local(async move {
let mut reader = BufReader::new(agent_out_read);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let msg = line.trim_end_matches(['\r', '\n']).to_string();
if !msg.is_empty() && response_tx.send(msg).is_err() {
break;
}
}
Err(_) => break,
}
}
}));
generation_tasks.extend(xai_grok_shell::leader::in_process::spawn_agent(
acp_rx,
response_tx,
));
self.generation_tasks = generation_tasks;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);

View file

@ -156,6 +156,16 @@ pub static USER_GUIDE: &[Doc] = &[
"Permissions and Safety",
"Modes, authorization order, allow/ask/deny rules, matching, and hooks"
),
guide!(
"23-dashboard.md",
"Agent Dashboard",
"Live multi-session roster: peek, dispatch, pin, stop, and search"
),
guide!(
"24-monitoring-usage.md",
"Monitoring Usage (External OpenTelemetry)",
"Export usage metrics to a customer OpenTelemetry collector"
),
];
/// Non-user-guide reference docs. Separate from USER_GUIDE because they

View file

@ -111,6 +111,13 @@ impl SleepInhibitor {
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_tty_utils::detach_std_command(&mut cmd);
// The spawned process is the lock holder: `systemd-inhibit` keeps
// the idle-inhibit fd itself and runs `sleep infinity` as its child
// — it is the same pid `release()` SIGTERMs on a clean turn end.
// Bind that pid to us so a crashed/killed grok (SIGKILL,
// `panic=abort` SIGABRT — no Drop runs) can't leave an immortal
// inhibitor holding the lock and pid slots on shared hosts.
xai_tty_utils::kill_on_parent_death_std(&mut cmd);
let result = cmd.spawn();
match result {

View file

@ -0,0 +1,31 @@
//! `/delete` — delete this session's history and return home.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct DeleteCommand;
impl SlashCommand for DeleteCommand {
fn name(&self) -> &str {
"delete"
}
fn description(&self) -> &str {
"Delete this session and return home"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/delete"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session to delete".into());
}
CommandResult::Action(Action::DeleteCurrentSession)
}
}

View file

@ -15,6 +15,7 @@ pub mod context;
pub mod copy;
pub mod dashboard;
pub mod debug;
pub mod delete;
pub mod docs;
pub mod doctor;
pub mod edit_prompt;
@ -81,6 +82,7 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(help::HelpCommand),
Arc::new(docs::DocsCommand),
Arc::new(home::HomeCommand),
Arc::new(delete::DeleteCommand),
Arc::new(new::NewCommand),
Arc::new(fork::ForkCommand),
Arc::new(compact::CompactCommand),
@ -263,6 +265,7 @@ mod tests {
"cost",
"dashboard",
"debug",
"delete",
"docs",
"doctor",
"edit-prompt",
@ -397,6 +400,19 @@ mod tests {
assert!(matches!(result, CommandResult::Action(Action::ExitSession)));
}
#[test]
fn delete_requires_session_and_dispatches() {
let models = ModelState::default();
let cmd = delete::DeleteCommand;
let mut ctx = make_ctx(&models);
assert!(matches!(cmd.run(&mut ctx, ""), CommandResult::Error(_)));
let session_id = acp::SessionId::new("sess-delete");
ctx.session_id = Some(&session_id);
assert!(matches!(
cmd.run(&mut ctx, ""),
CommandResult::Action(Action::DeleteCurrentSession)
));
}
#[test]
fn view_plan_returns_show_plan_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);

View file

@ -63,7 +63,7 @@ enum Msg {
struct Daemon {
shared: Arc<Mutex<Snapshot>>,
tx: SyncSender<Msg>,
_handle: JoinHandle<()>,
handle: Option<JoinHandle<()>>,
}
const MAX_RESULTS: usize = 100;
@ -74,7 +74,7 @@ impl Daemon {
let (tx, rx) = sync_channel::<Msg>(256);
let out = shared.clone();
let handle = thread::spawn(move || {
let worker = move || {
let mut pattern = MultiPattern::new(1);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut items: Vec<(String, Utf32String)> = Vec::new();
@ -82,7 +82,6 @@ impl Daemon {
let mut prev_q = String::new();
while let Ok(msg) = rx.recv() {
// Drain to latest — skip intermediate queries.
let msg = drain_to_latest(msg, &rx);
match msg {
@ -144,13 +143,23 @@ impl Daemon {
Msg::Stop => break,
}
}
});
};
let handle = thread::Builder::new()
.name("history-search".into())
.spawn(worker);
Self {
shared,
tx,
_handle: handle,
}
let handle = match handle {
Ok(h) => Some(h),
Err(e) => {
tracing::error!(
error = %e,
"history search daemon thread spawn failed; history search disabled"
);
None
}
};
Self { shared, tx, handle }
}
}
@ -355,6 +364,9 @@ impl HistorySearchState {
}
fn activate_inner(&mut self, history: &[HistoryEntry], current_text: &str, browse: bool) {
if !self.is_available() {
return;
}
self.active = true;
self.browse = browse;
self.saved_text = current_text.to_string();
@ -369,6 +381,12 @@ impl HistorySearchState {
self.selected = self.snapshot.items.len().saturating_sub(1);
}
/// False when the matcher thread never started, so the overlay cannot open
/// and callers must leave the composer alone.
pub fn is_available(&self) -> bool {
self.daemon.handle.is_some()
}
/// True while the overlay is in browse mode (see [`Self::activate_browse`]).
pub fn is_browse(&self) -> bool {
self.active && self.browse

View file

@ -399,6 +399,11 @@ pub(crate) fn default_palette_entries(
shortcut: "/home".into(),
command: PaletteCommand::Home,
},
PaletteEntry {
label: "Delete This Session".into(),
shortcut: "/delete".into(),
command: PaletteCommand::SlashCommand("/delete".into()),
},
PaletteEntry {
label: "Resume Session".into(),
shortcut: "/resume".into(),

View file

@ -131,6 +131,7 @@ pub enum LocalQuestionKind {
target: crate::app::actions::DoctorFixTarget,
plan: Box<crate::diagnostics::FixPlan>,
},
DeleteCurrentSession,
}
// ── State ──────────────────────────────────────────────────────────────

View file

@ -327,8 +327,9 @@ async fn subscription_watch_polls_free_tier_then_goes_dormant_after_upgrade() {
#[ignore = "PTY e2e; run the owning pty_e2e_* Cargo test with --ignored (see Cargo.toml)"]
async fn startup_gate_shows_paywall_for_free_user_after_live_check() {
let content = ContentController::start().await.expect("start content");
// Gated settings (no allow_access), free user (no subscriptionTier).
// Explicit deny + gate copy. Absent allow_access now fails open.
content.server().set_settings(json!({
"allow_access": false,
"gate_message": GATE_MSG,
"gate_url": "https://grok.com/supergrok?referrer=grok-build",
"gate_label": "Subscribe",
@ -398,7 +399,10 @@ async fn stale_gate_push_never_flashes_paywall_for_subscribed_user() {
// One stale gated snapshot: the "remote settings stale moment".
content.enqueue_response(
"/v1/settings",
ScriptedResponse::json(200, json!({ "gate_message": GATE_MSG })),
ScriptedResponse::json(
200,
json!({ "allow_access": false, "gate_message": GATE_MSG }),
),
);
harness.inject_keys(b"/new\r").expect("run /new");

View file

@ -205,11 +205,7 @@ pub fn stream_messages<'a>(
};
}
}
ContentBlock::ToolUse {
id,
name,
input: _,
} => {
ContentBlock::ToolUse { id, name, .. } => {
let tool_index = next_tool_index;
next_tool_index += 1;
block_to_tool_index.insert(index, tool_index);

View file

@ -216,6 +216,8 @@ async fn tool_use_block_assembles_into_tool_call() {
id: "call_xyz".into(),
name: "do_thing".into(),
input: serde_json::json!({}),
// Set: a parser matching only the absent case must fail here.
cache_control: Some(xai_grok_sampling_types::messages::CacheControl::ephemeral()),
},
};
let arg_delta_1 = MessageStreamEvent::ContentBlockDelta {
@ -416,6 +418,7 @@ async fn refusal_after_tool_use_blocks_keeps_tool_calls_stop_reason() {
id: "call_refused".into(),
name: "do_thing".into(),
input: serde_json::json!({}),
cache_control: None,
},
};
let arg_delta = MessageStreamEvent::ContentBlockDelta {

View file

@ -3064,12 +3064,77 @@ pub fn dedup_duplicate_tool_results(conversation: &mut Vec<ConversationItem>) ->
// Anthropic Messages API Conversion
// ============================================================================
/// Marks the last block that can carry one, scanning back past `Thinking`,
/// which the API rejects a breakpoint on.
fn mark_message_cache_breakpoint(msg: &mut crate::messages::Message) -> bool {
use crate::messages::{CacheControl, ContentBlock, MessageContent};
match &mut msg.content {
MessageContent::Blocks(blocks) => {
for block in blocks.iter_mut().rev() {
let cache_control = match block {
ContentBlock::Text { cache_control, .. }
| ContentBlock::ToolResult { cache_control, .. }
| ContentBlock::Image { cache_control, .. }
| ContentBlock::ToolUse { cache_control, .. } => cache_control,
ContentBlock::Thinking { .. } => continue,
};
*cache_control = Some(CacheControl::ephemeral());
return true;
}
false
}
// Plain text cannot carry a breakpoint, so promote it to block form.
MessageContent::Text(text) => {
let text = std::mem::take(text);
msg.content = MessageContent::Blocks(vec![ContentBlock::Text {
text,
cache_control: Some(CacheControl::ephemeral()),
}]);
true
}
}
}
/// An entry is written only at a breakpoint, so marking the system prompt alone
/// leaves the transcript uncached. The third covers a turn that appends more
/// than the API's 20 block lookback. The fourth slot stays free: a gateway that
/// turns on automatic caching takes it, and five is rejected outright.
fn apply_cache_breakpoints(
system_blocks: &mut [crate::messages::TextBlock],
messages: &mut [crate::messages::Message],
) {
use crate::messages::{CacheControl, MessageRole};
if let Some(last) = system_blocks.last_mut() {
last.cache_control = Some(CacheControl::ephemeral());
}
let tip = (0..messages.len())
.rev()
.find(|&i| mark_message_cache_breakpoint(&mut messages[i]));
// Where the previous request ended. A turn can append several user messages
// in a row, so skip the whole trailing run rather than a neighbour of the tip.
if let Some(tip) = tip
&& let Some(prev) = messages[..tip]
.iter()
.rposition(|m| matches!(m.role, MessageRole::Assistant))
.and_then(|assistant| {
messages[..assistant]
.iter()
.rposition(|m| matches!(m.role, MessageRole::User))
})
{
mark_message_cache_breakpoint(&mut messages[prev]);
}
}
/// Convert a ConversationRequest to Anthropic MessagesRequest.
pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::MessagesRequest {
use crate::messages::{
CacheControl, ContentBlock, ImageSource, Message, MessageContent, MessageRole,
MessagesRequest, OutputConfig, SystemParam, TextBlock, ToolChoiceParam, ToolParam,
ToolResultContent,
ContentBlock, ImageSource, Message, MessageContent, MessageRole, MessagesRequest,
OutputConfig, SystemParam, TextBlock, ToolChoiceParam, ToolParam, ToolResultContent,
};
let mut system_blocks: Vec<TextBlock> = Vec::new();
@ -3115,6 +3180,7 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
media_type,
data: data.to_string(),
},
cache_control: None,
}
} else {
// Malformed data URI, treat as text
@ -3128,6 +3194,7 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
source: ImageSource::Url {
url: url.as_ref().to_owned(),
},
cache_control: None,
}
} else {
// Unknown format, treat as text
@ -3207,6 +3274,7 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
id: sanitize_tool_call_id(&tc.id),
name: tc.name.clone(),
input,
cache_control: None,
});
}
}
@ -3237,7 +3305,10 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
url: url.as_ref().to_owned(),
}
};
blocks.push(ContentBlock::Image { source });
blocks.push(ContentBlock::Image {
source,
cache_control: None,
});
}
}
ToolResultContent::Blocks(blocks)
@ -3283,12 +3354,7 @@ pub fn build_messages_request(req: &ConversationRequest) -> crate::messages::Mes
flush_assistant(&mut pending_assistant, &mut messages);
flush_tool_results(&mut pending_tool_results, &mut messages);
// Attach cache_control: {type: "ephemeral"} to last system block
if let Some(last) = system_blocks.last_mut() {
last.cache_control = Some(CacheControl {
r#type: "ephemeral".to_string(),
});
}
apply_cache_breakpoints(&mut system_blocks, &mut messages);
// Build system param
let system: Option<SystemParam> = if system_blocks.is_empty() {
@ -3394,7 +3460,9 @@ impl From<crate::messages::MessagesResponse> for ConversationItem {
}
content.push_str(&text);
}
ContentBlock::ToolUse { id, name, input } => {
ContentBlock::ToolUse {
id, name, input, ..
} => {
tool_calls.push(ToolCall {
id: Arc::<str>::from(id),
name,
@ -5493,6 +5561,176 @@ mod tests {
);
}
fn count_cache_control(value: &serde_json::Value) -> usize {
match value {
serde_json::Value::Object(map) => {
usize::from(map.contains_key("cache_control"))
+ map.values().map(count_cache_control).sum::<usize>()
}
serde_json::Value::Array(items) => items.iter().map(count_cache_control).sum(),
_ => 0,
}
}
fn marker_on_last_block(message: &serde_json::Value) -> Option<&str> {
message
.get("content")?
.as_array()?
.last()?
.pointer("/cache_control/type")?
.as_str()
}
fn agent_turn(n: usize) -> Vec<ConversationItem> {
let id = format!("call_{n}");
vec![
ConversationItem::assistant_tool_calls(vec![ToolCall {
id: id.as_str().into(),
name: "read_file".to_string(),
arguments: r#"{"path": "src/main.rs"}"#.into(),
}]),
ConversationItem::tool_result(id, "fn main() {}"),
]
}
fn agent_request(turns: usize) -> serde_json::Value {
let mut items = vec![
ConversationItem::system("You are a helpful assistant."),
ConversationItem::user("Fix the bug"),
];
for n in 0..turns {
items.extend(agent_turn(n));
}
serde_json::to_value(build_messages_request(
&ConversationRequest::from_items(items).with_model("messages-compatible-model"),
))
.unwrap()
}
#[test]
fn test_messages_request_cache_breakpoint_placement() {
let json = agent_request(2);
let messages = json["messages"].as_array().unwrap();
assert_eq!(
json.pointer("/system/0/cache_control/type")
.and_then(|v| v.as_str()),
Some("ephemeral"),
"{json:#}",
);
assert_eq!(
marker_on_last_block(messages.last().unwrap()),
Some("ephemeral"),
"tip: {json:#}"
);
assert_eq!(
messages.last().unwrap()["content"]
.as_array()
.and_then(|b| b.last())
.and_then(|b| b["type"].as_str()),
Some("tool_result"),
);
let previous_user = messages[..messages.len() - 1]
.iter()
.rposition(|m| m["role"] == "user")
.unwrap();
assert_eq!(
marker_on_last_block(&messages[previous_user]),
Some("ephemeral"),
"previous turn's tip: {json:#}",
);
assert_eq!(count_cache_control(&json), 3, "{json:#}");
}
#[test]
fn test_messages_request_previous_tip_skips_a_trailing_user_run() {
let mut items = vec![
ConversationItem::system("You are a helpful assistant."),
ConversationItem::user("Fix the bug"),
];
items.extend(agent_turn(0));
items.extend(agent_turn(1));
// The shape after a parallel batch: tool results, then followups.
items.push(ConversationItem::user("[Image content]"));
items.push(ConversationItem::user("<system-reminder>"));
let json = serde_json::to_value(build_messages_request(
&ConversationRequest::from_items(items).with_model("messages-compatible-model"),
))
.unwrap();
let messages = json["messages"].as_array().unwrap();
let marked: Vec<usize> = (0..messages.len())
.filter(|&i| marker_on_last_block(&messages[i]).is_some())
.collect();
let last_assistant = messages
.iter()
.rposition(|m| m["role"] == "assistant")
.unwrap();
assert_eq!(marked.len(), 2, "tip and previous tip only: {json:#}");
assert_eq!(marked[1], messages.len() - 1, "tip: {json:#}");
assert!(
marked[0] < last_assistant,
"the previous tip must sit before the last assistant turn, not inside \
the trailing user run; got {marked:?} in {json:#}",
);
}
#[test]
fn test_messages_request_cache_breakpoint_marks_an_image_tip() {
let req = ConversationRequest::from_items(vec![
ConversationItem::system("You are a helpful assistant."),
ConversationItem::User(UserItem {
content: vec![
ContentPart::Text {
text: "what is in this screenshot".into(),
},
ContentPart::Image {
url: "data:image/png;base64,iVBOR".into(),
},
],
..Default::default()
}),
])
.with_model("messages-compatible-model");
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
let blocks = json["messages"][0]["content"].as_array().unwrap();
assert_eq!(blocks.last().unwrap()["type"].as_str(), Some("image"));
assert_eq!(
marker_on_last_block(&json["messages"][0]),
Some("ephemeral"),
"{json:#}",
);
assert!(blocks[0].get("cache_control").is_none(), "{json:#}");
}
#[test]
fn test_messages_request_cache_breakpoint_skips_thinking() {
let req = ConversationRequest::from_items(vec![
ConversationItem::user("Fix the bug"),
ConversationItem::Reasoning(synthesized_reasoning_item("weighing options")),
ConversationItem::assistant("Fixed it."),
])
.with_model("messages-compatible-model");
let json = serde_json::to_value(build_messages_request(&req)).unwrap();
let blocks = json["messages"][1]["content"].as_array().unwrap();
let thinking = blocks
.iter()
.find(|b| b["type"] == "thinking")
.expect("reasoning should emit a thinking block");
assert!(thinking.get("cache_control").is_none(), "{json:#}");
assert_eq!(
marker_on_last_block(&json["messages"][1]),
Some("ephemeral"),
"{json:#}",
);
}
/// Regression: /btw side questions snapshot the conversation (which may
/// include thinking blocks from prior turns) but fire without
/// reasoning_effort. If the caller forgets to strip reasoning from items,
@ -7954,7 +8192,7 @@ mod tests {
matches!(&inner[0], crate::messages::ContentBlock::Text { text, .. } if text == "Read image file: photo.png")
);
assert!(
matches!(&inner[1], crate::messages::ContentBlock::Image { source: crate::messages::ImageSource::Base64 { media_type, data } } if media_type == "image/png" && data == "iVBOR")
matches!(&inner[1], crate::messages::ContentBlock::Image { source: crate::messages::ImageSource::Base64 { media_type, data }, .. } if media_type == "image/png" && data == "iVBOR")
);
}

View file

@ -94,6 +94,14 @@ pub struct CacheControl {
pub r#type: String, // "ephemeral"
}
impl CacheControl {
pub fn ephemeral() -> Self {
Self {
r#type: "ephemeral".to_owned(),
}
}
}
/// Content blocks used in both requests and responses
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@ -105,11 +113,15 @@ pub enum ContentBlock {
},
Image {
source: ImageSource,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
ToolUse {
id: String,
name: String,
input: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
cache_control: Option<CacheControl>,
},
ToolResult {
tool_use_id: String,

View file

@ -1,5 +1,51 @@
# Changelog
# 0.2.114 — 2026-07-29
## Features
- **New `/delete` slash command** removes the current session's history after confirmation.
## Bug Fixes
- **Grok** no longer crashes on startup when the host machine has no free threads.
# 0.2.113 — 2026-07-28
## Features
- **MCP servers** can now be enabled or disabled directly from the CLI with `grok mcp enable <name>` and `grok mcp disable <name>`.
- **Full plan markdown** can now be copied to the clipboard with `y` during plan approval or preview.
- **Added support for the new SuperGrok Plus subscription tier** in authentication and feature gating.
- **Enabled automatic recovery** from repetitive loops in model output by default.
## Bug Fixes
- **Terminal command output** is no longer lost or duplicated when the gateway is unreachable.
- **Invalid MCP server entries** in config.toml no longer prevent Grok from starting; problems are shown in `grok inspect`.
- **SessionEnd hooks** now run on exit in non-leader TUI and headless sessions.
- **Paste chips** now display with the correct background in inline prompts and question inputs.
- **Pasted content chips** now behave consistently when editing answers in the question view.
- **Background task status** now shows only elapsed duration instead of absolute timestamps.
- **Session lists** no longer drop real sessions when the remote registry reports an outdated turn count of zero.
- **/loop** now stores prompts that include stop conditions so recurring tasks can terminate themselves when done.
- **Reduced spurious warning messages** for common auth and config scenarios.
- **Fixed conda activation** (and other sourced scripts that read $@) when using persistent or login-capture shells.
- **Fixed stuck background-task tray rows** after long foreground shell commands complete.
- **Agent subprocesses and idle inhibitors** are now cleaned up when the parent CLI process dies unexpectedly.
- **Fixed truncated plans** in minimal mode and improved visual separation between reasoning and output (including NO_COLOR).
- **Fixed credential loss** across multiple grok processes sharing the same auth file.
- **Fixed doubled Enter** and other keys on older Alacritty terminals.
- **Fixed false paywall** messages for free-tier and unmatched users.
## Performance
- **Cold start** shows the UI instantly while models and settings load in the background.
- **Large session forks and resumes** now use far less memory and avoid spikes.
- **Prevented thread exhaustion** on high-core shared machines by limiting the workspace daemon's worker threads.
# 0.2.112 — 2026-07-24
## Breaking Changes

View file

@ -1,7 +1,7 @@
[package]
license = "Apache-2.0"
name = "xai-grok-shell"
version = "0.2.112"
version = "0.2.114"
edition.workspace = true
[features]
@ -222,7 +222,10 @@ name = "fork_copy"
harness = false
required-features = ["test-support"]
# Consume `session::testkit`, so they need the gate (on by default under Bazel).
[[test]]
name = "test_leader_soak"
required-features = ["test-support"]
[[test]]
name = "test_session_load_memory"
required-features = ["test-support"]

View file

@ -232,7 +232,9 @@ export GROK_AUTH_TOKEN_TTL=3600 # optional
If your binary outputs a bare token string (not JSON with `expires_in`), set `auth_token_ttl` to the token's expected lifetime in seconds. Without it, Grok cannot detect expiry proactively and will only refresh after a 401.
The command is run via `sh -c`, so it can be a binary path, a shell script, or a pipeline.
The command runs through the platform shell — `sh -c` on macOS/Linux, `cmd /C` on Windows — so it can be a binary path, a script, or a pipeline.
> **Windows:** write the path as a TOML *literal* string (single quotes) so backslashes survive: `auth_provider_command = 'C:\corp\grok-auth.exe'`. Inside a double-quoted TOML string `\t`, `\n`, `\r`, `\b` and `\f` are escape sequences, so `"C:\temp\auth.exe"` parses into a path containing a tab character and the provider fails to start — after which Grok falls back to browser login as if the setting were ignored.
When `auth_provider_label` is set, the TUI welcome screen shows **"Login with Acme Corp"** instead of "Login with grok.com". In headless mode (`grok -p`), the label has no effect — stderr from your binary is printed directly to the terminal.

View file

@ -0,0 +1,117 @@
[
{
"category": "performance",
"description": "**Cold start** shows the UI instantly while models and settings load in the background.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Terminal command output** is no longer lost or duplicated when the gateway is unreachable.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Invalid MCP server entries** in config.toml no longer prevent Grok from starting; problems are shown in `grok inspect`.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**SessionEnd hooks** now run on exit in non-leader TUI and headless sessions.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Paste chips** now display with the correct background in inline prompts and question inputs.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Pasted content chips** now behave consistently when editing answers in the question view.",
"breaking_change": false
},
{
"category": "performance",
"description": "**Large session forks and resumes** now use far less memory and avoid spikes.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Background task status** now shows only elapsed duration instead of absolute timestamps.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Session lists** no longer drop real sessions when the remote registry reports an outdated turn count of zero.",
"breaking_change": false
},
{
"category": "features",
"description": "**MCP servers** can now be enabled or disabled directly from the CLI with `grok mcp enable <name>` and `grok mcp disable <name>`.",
"breaking_change": false
},
{
"category": "features",
"description": "**Full plan markdown** can now be copied to the clipboard with `y` during plan approval or preview.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**/loop** now stores prompts that include stop conditions so recurring tasks can terminate themselves when done.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Reduced spurious warning messages** for common auth and config scenarios.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed conda activation** (and other sourced scripts that read $@) when using persistent or login-capture shells.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed stuck background-task tray rows** after long foreground shell commands complete.",
"breaking_change": false
},
{
"category": "performance",
"description": "**Prevented thread exhaustion** on high-core shared machines by limiting the workspace daemon's worker threads.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Agent subprocesses and idle inhibitors** are now cleaned up when the parent CLI process dies unexpectedly.",
"breaking_change": false
},
{
"category": "features",
"description": "**Added support for the new SuperGrok Plus subscription tier** in authentication and feature gating.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed truncated plans** in minimal mode and improved visual separation between reasoning and output (including NO_COLOR).",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed credential loss** across multiple grok processes sharing the same auth file.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed doubled Enter** and other keys on older Alacritty terminals.",
"breaking_change": false
},
{
"category": "features",
"description": "**Enabled automatic recovery** from repetitive loops in model output by default.",
"breaking_change": false
},
{
"category": "fixes",
"description": "**Fixed false paywall** messages for free-tier and unmatched users.",
"breaking_change": false
}
]

View file

@ -0,0 +1,34 @@
# 0.2.113 — 2026-07-28
## Features
- **MCP servers** can now be enabled or disabled directly from the CLI with `grok mcp enable <name>` and `grok mcp disable <name>`.
- **Full plan markdown** can now be copied to the clipboard with `y` during plan approval or preview.
- **Added support for the new SuperGrok Plus subscription tier** in authentication and feature gating.
- **Enabled automatic recovery** from repetitive loops in model output by default.
## Bug Fixes
- **Terminal command output** is no longer lost or duplicated when the gateway is unreachable.
- **Invalid MCP server entries** in config.toml no longer prevent Grok from starting; problems are shown in `grok inspect`.
- **SessionEnd hooks** now run on exit in non-leader TUI and headless sessions.
- **Paste chips** now display with the correct background in inline prompts and question inputs.
- **Pasted content chips** now behave consistently when editing answers in the question view.
- **Background task status** now shows only elapsed duration instead of absolute timestamps.
- **Session lists** no longer drop real sessions when the remote registry reports an outdated turn count of zero.
- **/loop** now stores prompts that include stop conditions so recurring tasks can terminate themselves when done.
- **Reduced spurious warning messages** for common auth and config scenarios.
- **Fixed conda activation** (and other sourced scripts that read $@) when using persistent or login-capture shells.
- **Fixed stuck background-task tray rows** after long foreground shell commands complete.
- **Agent subprocesses and idle inhibitors** are now cleaned up when the parent CLI process dies unexpectedly.
- **Fixed truncated plans** in minimal mode and improved visual separation between reasoning and output (including NO_COLOR).
- **Fixed credential loss** across multiple grok processes sharing the same auth file.
- **Fixed doubled Enter** and other keys on older Alacritty terminals.
- **Fixed false paywall** messages for free-tier and unmatched users.
## Performance
- **Cold start** shows the UI instantly while models and settings load in the background.
- **Large session forks and resumes** now use far less memory and avoid spikes.
- **Prevented thread exhaustion** on high-core shared machines by limiting the workspace daemon's worker threads.

View file

@ -0,0 +1,12 @@
[
{
"category": "fixes",
"description": "**Grok** no longer crashes on startup when the host machine has no free threads.",
"breaking_change": false
},
{
"category": "features",
"description": "**New `/delete` slash command** removes the current session's history after confirmation.",
"breaking_change": false
}
]

View file

@ -0,0 +1,10 @@
# 0.2.114 — 2026-07-29
## Features
- **New `/delete` slash command** removes the current session's history after confirmation.
## Bug Fixes
- **Grok** no longer crashes on startup when the host machine has no free threads.

View file

@ -294,6 +294,22 @@ pub async fn run_stdio_agent(
memory_config: Option<crate::config::MemoryConfig>,
) -> anyhow::Result<()> {
register_fs_watch_runtime();
// A stdio agent is a protocol child speaking over pipes inherited from
// whoever spawned it (grok-desktop, IDE clients, the agent SDKs, a parent
// agent's subagent harness) — it is useless without that parent. stdin
// EOF already triggers shutdown below, but an agent wedged mid-turn (or
// under thread exhaustion) may never read stdin again; bind to parent
// death (Linux `PR_SET_PDEATHSIG(SIGTERM)`, no-op elsewhere) so the
// kernel reaps it instead of leaving an orphan accumulating pid slots on
// shared hosts. The leader entrypoint intentionally does NOT do this —
// it is designed to outlive its clients.
if let Err(error) = xai_tty_utils::kill_current_process_on_parent_death() {
tracing::warn!(
%error,
"failed to bind to parent death; agent will not die with its \
parent stdin EOF remains the only cleanup"
);
}
// Stamp binary version into unified log entries so zombie processes
// are identifiable by version in diagnostic logs.
xai_grok_telemetry::unified_log::set_version(xai_grok_version::VERSION);

View file

@ -2464,10 +2464,11 @@ impl Config {
/// remote settings `doom_loop_recovery` object (a partial remote object only
/// overrides the fields it sets). Gate precedence: env
/// `GROK_DOOM_LOOP_RECOVERY` > TOML `enabled` > remote `enabled` >
/// default off — `None` IS the off state, so disabled has exactly one
/// spelling. Tunables have no env layer (TOML > remote > default) and
/// are clamped to their documented ranges. Returns the composite runtime
/// policy rather than `Resolved` because each knob resolves from its own
/// default ON — each layer's `false` is an independent kill switch, and
/// `None` IS the off state, so disabled has exactly one spelling.
/// Tunables have no env layer (TOML > remote > default) and are clamped
/// to their documented ranges. Returns the composite runtime policy
/// rather than `Resolved` because each knob resolves from its own
/// source (the `resolve_reminder_policy` pattern).
pub(crate) fn resolve_doom_loop_recovery(
&self,
@ -2480,7 +2481,7 @@ impl Config {
let enabled = BoolFlag::env("GROK_DOOM_LOOP_RECOVERY")
.config(self.doom_loop_recovery.enabled)
.feature_flag(remote.and_then(|s| s.enabled))
.default(false)
.default(true)
.resolve()
.value;
enabled.then(|| Policy {
@ -9052,8 +9053,9 @@ reasoning_effort = "low"
unsafe { std::env::remove_var("GROK_TWO_PASS_COMPACTION") };
}
/// Gate precedence: env > `[doom_loop_recovery]` > remote settings >
/// default(off), with the remote layer merged PER-FIELD from the nested
/// `doom_loop_recovery` object. One test covers the full ladder (the
/// default(ON), with the remote layer merged PER-FIELD from the nested
/// `doom_loop_recovery` object and each layer's `false` an independent
/// kill switch. One test covers the full ladder (the
/// `resolve_two_pass_compaction_precedence` pattern).
#[test]
#[serial]
@ -9061,10 +9063,42 @@ reasoning_effort = "low"
use crate::util::config::DoomLoopRecoverySettings;
unsafe { std::env::remove_var("GROK_DOOM_LOOP_RECOVERY") };
let default_cfg = Config::default();
let p = default_cfg
.resolve_doom_loop_recovery()
.expect("default is ON");
assert_eq!(p.max_threshold, 8, "default tunables unchanged");
assert_eq!(p.max_retries, 2, "default tunables unchanged");
let toml_off = Config {
doom_loop_recovery: DoomLoopRecoverySettings {
enabled: Some(false),
..Default::default()
},
..Default::default()
};
assert!(
toml_off.resolve_doom_loop_recovery().is_none(),
"TOML kill switch"
);
let remote_off = Config {
remote_settings: Some(crate::util::config::RemoteSettings {
doom_loop_recovery: Some(DoomLoopRecoverySettings {
enabled: Some(false),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
assert!(
remote_off.resolve_doom_loop_recovery().is_none(),
"remote settings kill switch"
);
unsafe { std::env::set_var("GROK_DOOM_LOOP_RECOVERY", "0") };
assert!(
default_cfg.resolve_doom_loop_recovery().is_none(),
"default is opt-in off"
"env kill switch"
);
unsafe { std::env::remove_var("GROK_DOOM_LOOP_RECOVERY") };
let remote_on = Config {
remote_settings: Some(crate::util::config::RemoteSettings {
doom_loop_recovery: Some(DoomLoopRecoverySettings {
@ -9080,10 +9114,6 @@ reasoning_effort = "low"
assert_eq!(p.max_threshold, 16);
assert_eq!(p.max_retries, 1);
let partial_remote = Config {
doom_loop_recovery: DoomLoopRecoverySettings {
enabled: Some(true),
..Default::default()
},
remote_settings: Some(crate::util::config::RemoteSettings {
doom_loop_recovery: Some(DoomLoopRecoverySettings {
max_threshold: Some(16),
@ -9095,7 +9125,7 @@ reasoning_effort = "low"
};
let p = partial_remote
.resolve_doom_loop_recovery()
.expect("gate from TOML despite remote object omitting enabled");
.expect("default-on gate despite remote object omitting enabled");
assert_eq!(p.max_threshold, 16, "remote tunable applies");
assert_eq!(p.max_retries, 2, "unset field falls to the default");
let config_over_remote = Config {

View file

@ -134,6 +134,7 @@ pub(crate) fn jwt_tier_claim(jwt: &str) -> Option<String> {
4 => "x_premium_plus",
5 => "supergrok_heavy",
6 => "supergrok_lite",
7 => "supergrok_plus",
0 => "free",
_ => return Some(tier.to_string()),
}
@ -178,6 +179,7 @@ pub(crate) fn jwt_claim_matches_user_subscription_tier(
"XPremiumPlus" => jwt_claim == "x_premium_plus",
"SuperGrokPro" => jwt_claim == "supergrok_heavy",
"SuperGrokLite" => jwt_claim == "supergrok_lite",
"SuperGrokPlus" => jwt_claim == "supergrok_plus",
_ => jwt_claim.parse::<u64>().is_ok_and(|n| n != 0),
}
}
@ -1777,10 +1779,9 @@ impl MvpAgent {
/// Check whether the user has access via remote settings `allow_access`.
///
/// Non-xAI auth (API keys, enterprise) always passes. For xAI OAuth2
/// users, reads `allow_access` from remote settings. When settings exist
/// but the field is absent/false, defaults to `false` (blocked); when
/// settings have not arrived yet (background fetch pending) the gate is
/// provisionally open and re-resolved on arrival.
/// users, reads `allow_access` from remote settings (explicit `false`
/// blocks; absent field fails open). When settings have not arrived yet
/// the gate is provisionally open and re-resolved on arrival.
pub(super) async fn enforce_grok_code_access(&self, auth: &crate::auth::GrokAuth) {
if !auth.is_xai_auth() {
self.tier_allowed.set(true);
@ -2005,18 +2006,7 @@ impl MvpAgent {
.auth_manager
.current()
.map(|auth| {
let gate = if !self.tier_allowed.get() && gate.is_none() {
let message = "A subscription is required.".to_string();
Some(crate::auth::GateInfo {
message,
url: Some(
"https://grok.com/supergrok?referrer=grok-build".to_string(),
),
label: Some("Subscribe".to_string()),
})
} else {
gate
};
let gate = if self.tier_allowed.get() { None } else { gate };
let auth_meta = crate::auth::AuthMeta {
email: auth.email.clone(),
auth_mode: Some(format!("{:?}", auth.auth_mode)),
@ -2636,19 +2626,11 @@ fn spawn_post_unblock_jwt_and_catalog_retry(
}
});
}
/// Resolve `allow_access` from remote settings.
///
/// Returns `true` only when remote settings explicitly set `allow_access: true`.
/// Defaults to `false` (blocked) when settings are `None` or the field is
/// absent — matching the `grok_build_access_gate` flag's server-side default.
///
/// Used by both `enforce_grok_code_access` (initial login gate) and
/// `retry_subscription_check` (poller gate lift) to keep the decision in
/// one place.
/// `allow_access` from remote settings. Fail-open unless explicitly `false`.
pub(crate) fn settings_allow_access(
rs: Option<&crate::util::config::RemoteSettings>,
) -> bool {
rs.and_then(|s| s.allow_access).unwrap_or(false)
!matches!(rs.and_then(|s| s.allow_access), Some(false))
}
#[cfg(test)]
mod tests;

View file

@ -9,6 +9,30 @@ impl MvpAgent {
let _ = handle.cmd_tx.send(SessionCommand::Shutdown);
}
}
/// Hard-stop a live session before wiping its history.
///
/// Cancels the turn (subagents + background tasks), shuts the actor down,
/// reaps process scope, then waits briefly for flush so delete can remove
/// the session directory without the actor rewriting it.
pub(crate) async fn teardown_live_session_before_delete(&self, id: &acp::SessionId) {
let Some(handle) = self.sessions.borrow().get(id).cloned() else {
return;
};
let _ = handle.cmd_tx.send(SessionCommand::Cancel {
cancel_subagents: true,
kill_background_tasks: true,
rewind_if_pristine: false,
trigger: Some("session_delete".into()),
});
let _ = handle.cmd_tx.send(SessionCommand::Shutdown);
drop(handle);
let thread = self.session_threads.borrow_mut().remove(id);
self.remove_session_terminal(id, SessionLiveState::Completed);
if let Some(thread) = thread {
self.session_threads.borrow_mut().insert(id.clone(), thread);
self.drain_old_session_thread(id).await;
}
}
/// Finalize the cloud session replica (fire-and-forget, "Hook 4").
///
/// Marks the session **done** upstream, so this MUST only run on a genuine
@ -438,9 +462,10 @@ impl MvpAgent {
)
.registry_counts()
.await;
let (session_index_claims, require_gateway_sessions) = {
let (resident_resources, session_index_claims, require_gateway_sessions) = {
let resident = self.resident_resources.borrow();
(
resident.len(),
resident
.values()
.filter(|r| r.codebase_index.is_some())
@ -449,6 +474,7 @@ impl MvpAgent {
)
};
let retained = self.retained_resources.borrow();
let retained_resources = retained.len();
let dispatch_locks = retained
.values()
.filter(|d| d.dispatch_lock.is_some())
@ -465,6 +491,8 @@ impl MvpAgent {
RegistrySnapshot {
sessions: self.sessions.borrow().len(),
session_threads: self.session_threads.borrow().len(),
resident_resources,
retained_resources,
dispatch_locks,
session_turn_numbers,
permission_event_receivers,
@ -489,6 +517,8 @@ impl MvpAgent {
pub struct RegistrySnapshot {
pub sessions: usize,
pub session_threads: usize,
pub resident_resources: usize,
pub retained_resources: usize,
pub dispatch_locks: usize,
pub session_turn_numbers: usize,
pub permission_event_receivers: usize,

View file

@ -34,6 +34,10 @@ fn jwt_tier_claim_maps_free_and_paid() {
jwt_tier_claim(&jwt_with_tier(6)).as_deref(),
Some("supergrok_lite")
);
assert_eq!(
jwt_tier_claim(&jwt_with_tier(7)).as_deref(),
Some("supergrok_plus")
);
assert_eq!(jwt_tier_claim(&jwt_with_tier(9)).as_deref(), Some("9"));
assert_eq!(jwt_tier_claim(&jwt_with_tier(99)).as_deref(), Some("99"));
}
@ -102,6 +106,7 @@ fn jwt_claim_matches_user_subscription_tier_known_pairs() {
("supergrok_heavy", "SuperGrokPro"),
("9", "EnterpriseMystery"),
("supergrok_lite", "SuperGrokLite"),
("supergrok_plus", "SuperGrokPlus"),
];
for (claim, user_tier) in cases {
assert!(
@ -120,6 +125,14 @@ fn jwt_claim_matches_user_subscription_tier_rejects_stale_and_unknown() {
"supergrok",
"SuperGrokPro"
));
assert!(!jwt_claim_matches_user_subscription_tier(
"supergrok",
"SuperGrokPlus"
));
assert!(!jwt_claim_matches_user_subscription_tier(
"supergrok_heavy",
"SuperGrokPlus"
));
assert!(!jwt_claim_matches_user_subscription_tier("free", "GrokPro"));
assert!(!jwt_claim_matches_user_subscription_tier("", "XPremium"));
assert!(!jwt_claim_matches_user_subscription_tier(
@ -360,12 +373,10 @@ fn trace_turn_to_i32_saturates_at_max() {
let result = i32::try_from(boundary).unwrap_or(i32::MAX);
assert_eq!(result, i32::MAX);
}
/// When remote settings are absent (`None`), default to blocked.
#[test]
fn settings_allow_access_none_settings_is_blocked() {
assert!(!settings_allow_access(None));
fn settings_allow_access_none_settings_is_allowed() {
assert!(settings_allow_access(None));
}
/// When `allow_access` is `Some(true)`, user is allowed.
#[test]
fn settings_allow_access_true_is_allowed() {
let rs = crate::util::config::RemoteSettings {
@ -374,10 +385,6 @@ fn settings_allow_access_true_is_allowed() {
};
assert!(settings_allow_access(Some(&rs)));
}
/// When `allow_access` is `Some(false)` (remote settings default / rule
/// disabled), user stays blocked — even if they hold a qualifying
/// subscription. This is the regression guard for the bug where
/// `retry_subscription_check` unconditionally lifted the gate.
#[test]
fn settings_allow_access_false_is_blocked() {
let rs = crate::util::config::RemoteSettings {
@ -386,15 +393,13 @@ fn settings_allow_access_false_is_blocked() {
};
assert!(!settings_allow_access(Some(&rs)));
}
/// When `/settings` returned successfully but the field is absent
/// (`None`), default to blocked (conservative).
#[test]
fn settings_allow_access_field_absent_is_blocked() {
fn settings_allow_access_field_absent_is_allowed() {
let rs = crate::util::config::RemoteSettings {
allow_access: None,
..Default::default()
};
assert!(!settings_allow_access(Some(&rs)));
assert!(settings_allow_access(Some(&rs)));
}
/// After allocating a turn number, the retained (in-memory) turn counter holds
/// the next value (current + 1). This is the value that must be persisted via

View file

@ -147,6 +147,7 @@ mod tests {
fn all_paid_tiers_qualify() {
for tier in &[
"SuperGrokPro",
"SuperGrokPlus",
"GrokPro",
"SuperGrokLite",
"XPremiumPlus",

View file

@ -383,15 +383,7 @@ async fn mint_provider_token(
cmd.args(args);
cmd
}
None => {
#[cfg(windows)]
let (shell, flag) = ("cmd", "/C");
#[cfg(not(windows))]
let (shell, flag) = ("sh", "-c");
let mut cmd = tokio::process::Command::new(shell);
cmd.args([flag, config.command.as_str()]);
cmd
}
None => crate::util::subprocess::shell_c(config.command.as_str()),
};
if let Some(ref dir) = cwd {
cmd.current_dir(dir);

View file

@ -5,7 +5,7 @@ use crate::util::subprocess::CommandLog;
use crate::util::subprocess::RunError;
use crate::util::subprocess::RunOptions;
use crate::util::subprocess::run_detached_with_timeout;
use crate::util::subprocess::sh_c;
use crate::util::subprocess::shell_c;
use std::time::Duration;
/// Parse stdout into a session-credential `GrokAuth`.
@ -48,7 +48,7 @@ const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) async fn run_external_refresh(command: &str) -> Option<GrokAuth> {
tracing::info!(cmd = %command, timeout_secs = EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs(), "auth: running external auth provider (headless refresh)");
let mut cmd = sh_c(command);
let mut cmd = shell_c(command);
cmd.env("GROK_AUTH_EXPIRED", "1");
// Route through the group-killing runner so a provider that spawns helpers
// is torn down as a unit on timeout.

View file

@ -215,12 +215,14 @@ async fn run_external_auth_provider(
"auth: running external auth provider"
);
let mut cmd = tokio::process::Command::new("sh");
cmd.args(["-c", command])
.stdin(std::process::Stdio::null())
// `sh -c` on unix, `cmd /C` on Windows — a hardcoded `sh` cannot spawn on a
// default Windows install, and the spawn failure fell through to the
// built-in browser login instead of honoring `auth_provider_command`.
let mut cmd = crate::util::subprocess::shell_c(command);
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.kill_on_drop(true);
// TODO: `kill_on_drop` SIGKILLs only the direct `sh` child; a provider that
// TODO: `kill_on_drop` SIGKILLs only the direct shell child; a provider that
// backgrounds work (setsid / `&`) leaks the grandchild on shutdown-cancel.
// Proper fix: pgid-kill via xai-tty-utils.

View file

@ -1072,27 +1072,49 @@ impl AuthManager {
Some(auth)
}
/// `true` when a sibling process has rotated the refresh token on
/// disk (disk RT differs from in-memory RT). Used by `refresh_chain`
/// to demote a `PermanentFailure` to transient so the sibling's
/// fresher token can be tried on the next attempt.
/// `true` when the refresh token on disk is present and differs from the
/// one we actually spent — i.e. a sibling process rotated the RT while our
/// exchange was in flight, so the rejection we just got is a lost race
/// rather than a revoked session.
///
/// The single definition of "disk moved past the token we spent". Two
/// hand-rolled copies of this comparison is how the wrong one survived
/// long enough to log a dozen processes out at once.
///
/// Takes an already-observed `disk_rt` rather than reading `auth.json`
/// itself, so one observation drives the decision, the unattributed
/// fallback, and the telemetry that explains them. A second read can catch
/// a *later* sibling write and produce a record that contradicts the
/// branch it documents — in the log whose whole purpose is post-incident
/// truth. Callers read under the auth file lock, so the observation
/// includes the sibling's committed write.
///
/// Disk holding no RT is *not* divergence: there is no successor to fall
/// back to, so the rejection must be honored.
fn refresh_token_superseded(disk_rt: Option<&str>, spent_rt: &str) -> bool {
disk_rt.is_some_and(|disk_rt| disk_rt != spent_rt)
}
/// `true` when a sibling process has rotated the refresh token on disk
/// past the one in memory. Used by `refresh_chain` to demote a
/// `PermanentFailure` to transient so the sibling's fresher token can be
/// tried on the next attempt.
///
/// Requires an in-memory RT: empty `inner` means the disk credential is
/// the only candidate (not a multi-process rotation). Does **not**
/// require a non-expired disk AT — a sibling may still hold a usable RT
/// while its AT is buffer/hard-expired.
fn sibling_has_different_refresh_token(&self) -> bool {
let disk_auth = self.read_disk_auth();
let Some(ref disk) = disk_auth else {
return false;
};
let Some(disk_rt) = disk.refresh_token.as_deref() else {
return false;
};
let Some(mem_rt) = self.current_or_expired().and_then(|a| a.refresh_token) else {
return false;
};
mem_rt.as_str() != disk_rt
///
/// Only a fallback for authorities that cannot report which RT they spent.
/// Attributed refreshers pass the token they actually sent to
/// [`Self::refresh_token_superseded`] directly; because
/// `resolve_refresh_credential` is disk-first, the RT actually sent is
/// usually the disk one, and comparing disk against *memory* then answers
/// `false` in precisely the case that needs the demotion.
fn sibling_has_different_refresh_token(&self, disk_rt: Option<&str>) -> bool {
self.current_or_expired()
.and_then(|a| a.refresh_token)
.is_some_and(|mem_rt| Self::refresh_token_superseded(disk_rt, &mem_rt))
}
/// Re-read `auth.json` from disk without updating in-memory state.
@ -1815,7 +1837,11 @@ impl AuthManager {
Err(AuthError::transient_source(e))
}
},
RefreshOutcome::PermanentFailure { error, tried_key } => {
RefreshOutcome::PermanentFailure {
error,
tried_key,
tried_refresh_token,
} => {
tracing::warn!(reason = ?error.reason, "auth.refresh.permanent_failure");
xai_grok_telemetry::unified_log::warn(
"auth.refresh.permanent_failure",
@ -1843,10 +1869,42 @@ impl AuthManager {
if is_rtr {
let mem = self.current_or_expired();
let disk = self.read_disk_auth();
// Unattributed + diverging RTs: demote without recording so
// the next attempt can try the other side (no sticky lockout).
if tried_key.is_none() && self.sibling_has_different_refresh_token() {
// Diverging RTs mean a sibling rotated while we were in
// flight: our RT was superseded, not revoked. Demote
// without recording so the next attempt picks up the
// sibling's token (no sticky lockout, no credential loss).
//
// When the refresher told us which RT it spent (every
// in-tree OIDC path), compare disk against *that*. The
// legacy disk-vs-memory heuristic is only a fallback for
// unattributed authorities: it asks the wrong question,
// because `resolve_refresh_credential` is disk-first, so
// the RT actually spent is usually the disk one and the
// comparison collapses to "false" exactly when it matters.
//
// Both arms and the log below read one `disk` observation.
// Re-reading per use lets the decision and the line that
// explains it disagree about what disk held.
let disk_rt = disk.as_ref().and_then(|d| d.refresh_token.as_deref());
let sibling_rotated = match tried_refresh_token.as_deref() {
Some(tried_rt) => Self::refresh_token_superseded(disk_rt, tried_rt),
None => {
tried_key.is_none() && self.sibling_has_different_refresh_token(disk_rt)
}
};
if sibling_rotated {
tracing::info!("auth: sibling-rotation detected; demoting to transient");
xai_grok_telemetry::unified_log::info(
"auth.refresh.sibling_rotation_demoted",
None,
Some(serde_json::json!({
"reason": format!("{failed_reason:?}"),
"tried_rt_prefix": tried_refresh_token
.as_deref()
.map(token_suffix),
"disk_rt_prefix": disk_rt.map(token_suffix),
})),
);
return Err(AuthError::transient(format!(
"sibling-rotation: {failed_reason:?}"
)));
@ -1912,7 +1970,13 @@ impl AuthManager {
/// Re-read auth.json from disk and update the in-memory cache (used by the
/// refresh chains). Non-destructive: only updates in-memory if disk has a
/// different valid token (a sibling process wrote a fresher one).
pub(crate) fn pick_up_sibling_token(&self) {
///
/// Returns `true` only when in-memory state was actually replaced, so
/// callers can log adoption truthfully instead of inferring it from
/// "we have a token now" — which is also true when our own token was fine
/// all along, and made the proactive-refresh log actively misleading when
/// reconstructing a rotation chain after an incident.
pub(crate) fn pick_up_sibling_token(&self) -> bool {
let auth = match read_auth_json(&self.path) {
Ok(map) => lookup_auth(&map, &self.scope),
_ => None,
@ -1932,7 +1996,9 @@ impl AuthManager {
})),
);
self.with_inner_write(|inner| *inner = Some(a.clone()));
return true;
}
false
}
/// Check if a candidate auth has a different token than what's in memory.
@ -2227,7 +2293,7 @@ impl AuthManager {
// already refreshed and wrote a valid token to disk.
// Combined with jitter, the first process to wake
// refreshes; later processes adopt the result here.
this.pick_up_sibling_token();
let adopted_from_sibling = this.pick_up_sibling_token();
if this.current().is_some() {
let adopted = this.current().map(|a| token_suffix(&a.key).to_owned());
let expires_at = this
@ -2235,14 +2301,25 @@ impl AuthManager {
.read()
.as_ref()
.and_then(|a| a.expires_at.map(|e| e.to_rfc3339()));
tracing::info!(
"auth: proactive refresh skipped, adopted sibling token from disk"
);
// Distinguish "a sibling's token replaced ours" from "our
// own token is still valid". Both skip the refresh, but
// conflating them makes the log actively misleading when
// reconstructing a rotation chain after an incident.
if adopted_from_sibling {
tracing::info!(
"auth: proactive refresh skipped, adopted sibling token from disk"
);
} else {
tracing::info!(
"auth: proactive refresh skipped, in-memory token still valid"
);
}
xai_grok_telemetry::unified_log::info(
"auth: proactive refresh adopted sibling token",
"auth: proactive refresh skipped",
None,
Some(serde_json::json!({
"adopted_key_prefix": adopted,
"adopted_from_sibling": adopted_from_sibling,
"key_prefix": adopted,
"expires_at": expires_at,
})),
);

View file

@ -1396,6 +1396,145 @@ async fn refresh_chain_demotes_when_disk_rt_differs_even_if_at_expired() {
);
}
/// Regression test for the multi-process logout incident.
///
/// This is the shape `OidcRefresher` actually emits in production: the tried
/// credential is **fully attributed** (`tried_key` *and* `tried_refresh_token`
/// are `Some`). The pre-existing demotion tests all built the outcome with
/// `tried_key = None` — the external-binary shape — so they passed while the
/// OIDC path was gated behind `tried_key.is_none()` and could never demote.
///
/// Scenario: a sibling rotated the RT while our token exchange was in flight,
/// so the IdP rejected the RT we spent. That is a lost race, not a revoked
/// session: it must demote to transient and leave the sibling's credential on
/// disk untouched.
#[tokio::test]
async fn refresh_chain_demotes_when_attributed_tried_rt_differs_from_disk() {
let dir = tempfile::tempdir().unwrap();
let cfg = GrokComConfig::default();
let scope = cfg.auth_scope();
let mgr = Arc::new(AuthManager::new(dir.path(), cfg));
// We hold, and spend, the predecessor RT.
let tried = GrokAuth {
key: "tried-key".into(),
auth_mode: AuthMode::Oidc,
refresh_token: Some("rt-spent".into()),
expires_at: Some(Utc::now() - Duration::hours(1)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("client-1".into()),
..GrokAuth::test_default()
};
mgr.hot_swap(tried.clone());
// A sibling already rotated: disk carries the successor RT. Its AT is
// expired too, so disk adoption cannot short-circuit the failure path —
// the demotion is the only thing standing between us and a wipe.
let sibling = GrokAuth {
key: "sibling-key".into(),
auth_mode: AuthMode::Oidc,
refresh_token: Some("rt-successor".into()),
expires_at: Some(Utc::now() - Duration::minutes(30)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("client-1".into()),
..GrokAuth::test_default()
};
let mut store = AuthStore::new();
store.insert(scope, sibling);
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
struct AttributedRejection(GrokAuth);
#[async_trait::async_trait]
impl TokenRefresher for AttributedRejection {
async fn refresh(
&self,
_reason: crate::auth::manager::RefreshReason,
) -> crate::auth::refresh::RefreshOutcome {
// Exactly what OidcRefresher builds on a 400 invalid_grant.
crate::auth::refresh::RefreshOutcome::permanent_for(
crate::auth::error::RefreshTokenFailedReason::RefreshTokenRejected,
&self.0,
)
}
}
mgr.set_refresher(Arc::new(AttributedRejection(tried)));
let err = mgr
.refresh_chain(TokenType::OidcSession, RefreshReason::PreRequest)
.await
.unwrap_err();
assert!(
matches!(err, AuthError::Refresh(RefreshTokenError::Transient(_))),
"a rejected RT that disk has already rotated past is a lost race, \
not a revoked session; must demote to transient, got: {err:?}",
);
assert_eq!(
mgr.read_disk_auth().and_then(|a| a.refresh_token),
Some("rt-successor".into()),
"the sibling's successor RT must survive our rejection",
);
assert!(
mgr.permanent_failure().is_none(),
"demotion must not record a sticky verdict that locks out every \
sibling process until the user re-runs `grok login`",
);
}
/// The demotion must *not* fire when disk still holds the very RT that was
/// just rejected: nobody rotated, the session really is dead, and holding on
/// to a known-revoked credential would loop forever.
#[tokio::test]
async fn refresh_chain_still_discards_when_attributed_tried_rt_matches_disk() {
let dir = tempfile::tempdir().unwrap();
let cfg = GrokComConfig::default();
let scope = cfg.auth_scope();
let mgr = Arc::new(AuthManager::new(dir.path(), cfg));
let tried = GrokAuth {
key: "only-key".into(),
auth_mode: AuthMode::Oidc,
refresh_token: Some("rt-revoked".into()),
expires_at: Some(Utc::now() - Duration::hours(1)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("client-1".into()),
..GrokAuth::test_default()
};
mgr.hot_swap(tried.clone());
let mut store = AuthStore::new();
store.insert(scope, tried.clone());
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
struct AttributedRejection(GrokAuth);
#[async_trait::async_trait]
impl TokenRefresher for AttributedRejection {
async fn refresh(
&self,
_reason: crate::auth::manager::RefreshReason,
) -> crate::auth::refresh::RefreshOutcome {
crate::auth::refresh::RefreshOutcome::permanent_for(
crate::auth::error::RefreshTokenFailedReason::RefreshTokenRejected,
&self.0,
)
}
}
mgr.set_refresher(Arc::new(AttributedRejection(tried)));
let err = mgr
.refresh_chain(TokenType::OidcSession, RefreshReason::PreRequest)
.await
.unwrap_err();
assert!(
matches!(err, AuthError::Refresh(RefreshTokenError::Permanent(_))),
"an un-rotated rejected RT is a genuinely dead session, got: {err:?}",
);
assert!(
mgr.permanent_failure().is_some(),
"a genuine revocation must still record a verdict",
);
}
/// Disk-first invalid_grant must not wipe an untried in-memory successor RT
/// (mem-ahead-of-disk after a failed persist of a successful rotation).
#[tokio::test]
@ -2865,6 +3004,28 @@ async fn update_recovers_from_whitespace_only_auth_json() {
assert!(on_disk.contains("ws-token"), "credential must be persisted");
}
// -- sibling-rotation comparison ------------------------------------------
/// The demotion — and therefore whether a dozen processes keep their
/// credentials — rests entirely on this comparison, so pin its three cases
/// directly rather than only through the refresh chain.
#[test]
fn refresh_token_superseded_needs_a_successor_on_disk() {
assert!(
AuthManager::refresh_token_superseded(Some("rt-successor"), "rt-spent"),
"a different RT on disk is a sibling's successor: demote"
);
assert!(
!AuthManager::refresh_token_superseded(Some("rt-spent"), "rt-spent"),
"disk still holding the RT the IdP just rejected is a real revocation"
);
assert!(
!AuthManager::refresh_token_superseded(None, "rt-spent"),
"no RT on disk means there is no successor to fall back to, so the \
rejection must be honored rather than demoted into a retry loop"
);
}
// -- sibling_has_different_refresh_token ----------------------------------
/// Expired disk AT with different RT is still treated as a sibling RT
@ -2897,8 +3058,9 @@ async fn sibling_different_rt_with_expired_at_is_still_sibling() {
store.insert(cfg.auth_scope(), successor);
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
let disk_rt = mgr.read_disk_auth().and_then(|a| a.refresh_token);
assert!(
mgr.sibling_has_different_refresh_token(),
mgr.sibling_has_different_refresh_token(disk_rt.as_deref()),
"different disk RT must demote even when the sibling AT is expired"
);
}
@ -2931,8 +3093,9 @@ async fn sibling_different_rt_with_valid_at_is_treated_as_live() {
store.insert(cfg.auth_scope(), sibling);
write_auth_json(&dir.path().join("auth.json"), &store).unwrap();
let disk_rt = mgr.read_disk_auth().and_then(|a| a.refresh_token);
assert!(
mgr.sibling_has_different_refresh_token(),
mgr.sibling_has_different_refresh_token(disk_rt.as_deref()),
"valid disk token with different RT must be treated as live sibling"
);
}

View file

@ -104,6 +104,16 @@ pub(crate) enum RefreshOutcome {
/// has no token key (external binary flow); the caller falls back to
/// its own resolution.
tried_key: Option<String>,
/// The **refresh token** actually spent at the IdP. `refresh_chain`
/// compares it against disk to tell "this session is revoked" apart
/// from "a sibling process rotated the RT out from under us" — the
/// latter must never discard credentials.
///
/// `tried_key` cannot answer that question: it is the *access* token,
/// and a sibling's rotation changes the RT while the AT the loser
/// holds may be untouched. `None` when the authority does not expose
/// which RT it sent (external binary flow).
tried_refresh_token: Option<String>,
},
/// Transient / unknown failure. Caller may retry later. Message-only: the
/// underlying cause is logged structurally at the refresher, then flattened
@ -119,6 +129,12 @@ impl RefreshOutcome {
/// Terminal failure for an already-classified reason against the credential
/// `tried_key` (the one actually sent to the IdP).
///
/// Leaves the tried **refresh token** unattributed, which disables the
/// sibling-rotation check in `refresh_chain`. Only correct for authorities
/// that genuinely cannot report which RT they spent (the external-binary
/// flow). Any refresher holding the [`GrokAuth`] it sent must use
/// [`Self::permanent_for`] instead.
pub(crate) fn permanent(
reason: crate::auth::error::RefreshTokenFailedReason,
tried_key: Option<String>,
@ -126,6 +142,23 @@ impl RefreshOutcome {
Self::PermanentFailure {
error: reason.into(),
tried_key,
tried_refresh_token: None,
}
}
/// Terminal failure attributed to the exact credential sent to the IdP.
///
/// Prefer this wherever the attempted [`GrokAuth`] is in hand: it captures
/// both the AT key (verdict scope) and the RT (sibling-rotation check), so
/// a lost rotation race cannot be mistaken for a revoked session.
pub(crate) fn permanent_for(
reason: crate::auth::error::RefreshTokenFailedReason,
tried: &GrokAuth,
) -> Self {
Self::PermanentFailure {
error: reason.into(),
tried_key: Some(tried.key.clone()),
tried_refresh_token: tried.refresh_token.clone(),
}
}

View file

@ -147,10 +147,7 @@ impl OidcRefresher {
None,
Some(serde_json::json!({ "reason": format!("{reason:?}") })),
);
Some(RefreshOutcome::permanent(
reason,
Some(disk_now.key.clone()),
))
Some(RefreshOutcome::permanent_for(reason, &disk_now))
}
OidcRefreshResult::Failed => {
Some(RefreshOutcome::transient("OIDC disk-retry refresh failed"))
@ -245,7 +242,7 @@ impl TokenRefresher for OidcRefresher {
&self.upload_in_flight,
);
}
RefreshOutcome::permanent(reason, Some(auth.key.clone()))
RefreshOutcome::permanent_for(reason, &auth)
}
OidcRefreshResult::Failed => {
tracing::warn!(

View file

@ -251,6 +251,83 @@ async fn oidc_refresher_e2e_near_expiry_within_buffer_refreshes() {
server.abort();
}
/// Contract: on `invalid_grant`, `OidcRefresher` must report **which refresh
/// token it spent**, not just the access-token key.
///
/// `refresh_chain` uses `tried_refresh_token` to tell a lost rotation race
/// apart from a revoked session; an unattributed outcome silently disables
/// that check and turns any concurrent-refresh race into a machine-wide
/// logout. This is the shape assertion that the previous demotion tests
/// missed — they hand-built outcomes with `tried_key: None`, a shape this
/// refresher never emits, so they passed while production was unprotected.
#[tokio::test]
async fn oidc_refresher_attributes_the_refresh_token_it_spent_on_invalid_grant() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
let base_for_discovery = base_url.clone();
let app = axum::Router::new()
.route(
"/.well-known/openid-configuration",
axum::routing::get(move || {
let b = base_for_discovery.clone();
async move {
axum::Json(serde_json::json!({
"authorization_endpoint": format!("{b}/authorize"),
"token_endpoint": format!("{b}/token"),
}))
}
}),
)
.route(
"/token",
axum::routing::post(|| async {
(
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid_grant"})),
)
}),
);
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(
AuthManager::new(dir.path(), GrokComConfig::default()).with_proxy_base_url(&base_url),
);
mgr.hot_swap(GrokAuth {
key: "spent-access-token".into(),
user_id: "user-42".into(),
refresh_token: Some("rt-spent".into()),
expires_at: Some(Utc::now() - Duration::minutes(1)),
oidc_issuer: Some(base_url.clone()),
oidc_client_id: Some("test-client".into()),
..GrokAuth::test_default()
});
let outcome = OidcRefresher::new(mgr.clone())
.refresh(crate::auth::manager::RefreshReason::PreRequest)
.await;
match outcome {
RefreshOutcome::PermanentFailure {
tried_key,
tried_refresh_token,
..
} => {
assert_eq!(
tried_refresh_token.as_deref(),
Some("rt-spent"),
"the RT actually sent to the IdP must be reported so \
refresh_chain can detect a sibling rotation",
);
assert_eq!(tried_key.as_deref(), Some("spent-access-token"));
}
other => panic!("expected PermanentFailure, got: {other:?}"),
}
server.abort();
}
/// When the near-expiry token has a refresh_token but the IdP rejects
/// the refresh (e.g. refresh_token revoked), silent refresh must fail.
#[tokio::test]

View file

@ -253,6 +253,13 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
let needs_remote =
agent.is_writeback_storage() && agent.current_auth().is_some_and(|a| !a.is_zdr_team());
// Tear down any live actor first (cancel turn/subagents/bg tasks,
// process-scope kill, flush). Then wipe history so shutdown cannot
// rewrite the session directory after delete.
if agent.sessions.borrow().contains_key(&session_id) {
agent.teardown_live_session_before_delete(&session_id).await;
}
// Shared delete: remote-first, then local disk + FTS eviction.
// Mirrored by the `grok sessions delete <id>` CLI path.
crate::session::persistence::delete_session_history(
@ -269,15 +276,6 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
acp::Error::internal_error().data(e.to_string())
})?;
// If an in-memory live session exists for this id (e.g. the user
// deleted history for a session that is still open in another agent
// or the current one), shut it down and drop the MvpAgent bookkeeping
// so we don't leave a live actor whose on-disk/FTS state is gone.
if agent.sessions.borrow().contains_key(&session_id) {
agent.request_session_shutdown(&session_id);
agent.remove_session(&session_id);
}
tracing::info!(session_id = %req.session_id, "Session deleted");
to_raw_response(&serde_json::json!({ "success": true }))

View file

@ -0,0 +1,77 @@
//! A real agent behind a leader server, in this process rather than a child.
use std::sync::Arc;
use agent_client_protocol as acp;
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader, simplex};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt as _};
use xai_acp_lib::{
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
LineBufferedRead,
};
use crate::agent::config::Config as AgentConfig;
use crate::agent::mvp_agent::MvpAgent;
const SIMPLEX_BUF: usize = 8 * 1024 * 1024;
/// Spawns an agent on the current `LocalSet`, reading requests from `to_agent`
/// and writing responses to `from_agent`. Returns the task handles so a caller
/// can end the agent. Panics if the ambient configuration cannot build one.
pub fn spawn_agent(
mut to_agent: UnboundedReceiver<String>,
from_agent: UnboundedSender<String>,
) -> Vec<JoinHandle<()>> {
let (agent_in_read, mut agent_in_write) = simplex(SIMPLEX_BUF);
let (agent_out_read, agent_out_write) = simplex(SIMPLEX_BUF);
let agent = tokio::task::spawn_local(async move {
let config = AgentConfig::default();
let auth_manager = Arc::new(config.create_auth_manager());
let (gateway_tx, gateway_rx) = tokio::sync::mpsc::unbounded_channel();
let agent = MvpAgent::new(GatewaySender::new(gateway_tx), &config, auth_manager, None)
.expect("valid agent config");
let incoming = LineBufferedRead::spawn_local(agent_in_read.compat());
let (conn, handle_io) =
acp::AgentSideConnection::new(agent, agent_out_write.compat_write(), incoming, |fut| {
tokio::task::spawn_local(fut);
});
tokio::task::spawn_local(
GatewayReceiver::new(gateway_rx, conn)
.with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent)
.run(),
);
let _ = handle_io.await;
});
let requests = tokio::task::spawn_local(async move {
while let Some(msg) = to_agent.recv().await {
if agent_in_write.write_all(msg.as_bytes()).await.is_err()
|| agent_in_write.write_all(b"\n").await.is_err()
{
break;
}
}
});
let responses = tokio::task::spawn_local(async move {
let mut reader = BufReader::new(agent_out_read);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {
let msg = line.trim_end_matches(['\r', '\n']).to_string();
if !msg.is_empty() {
let _ = from_agent.send(msg);
}
}
}
}
});
vec![agent, requests, responses]
}

View file

@ -51,6 +51,8 @@
//! }
//! ```
mod client;
#[cfg(feature = "test-support")]
pub mod in_process;
mod lock;
pub mod protocol;
mod server;

View file

@ -1822,6 +1822,9 @@ mod build_tool_parse_error_message_tests;
#[path = "acp_session_tests/cancel_running_task_tests.rs"]
mod cancel_running_task_tests;
#[cfg(test)]
#[path = "acp_session_tests/turn/chat_history_integrity_tests.rs"]
mod chat_history_integrity_tests;
#[cfg(test)]
#[path = "acp_session_tests/feedback_turn_lookup_tests.rs"]
mod feedback_turn_lookup_tests;
#[cfg(test)]

View file

@ -98,6 +98,7 @@ impl SessionActor {
xai_grok_hooks::runner::RunContext {
session_id: &self.session_info.id.0,
workspace_root: &self.hook_resolved_workspace_root,
process_scope: self.tool_context.process_scope.clone(),
}
}

View file

@ -698,6 +698,7 @@ pub(crate) async fn spawn_session_actor(
std::sync::Arc::new(LocalTerminalBackend::new_local_with_persistent_shell(
resolve_search_shadows(),
resolve_policy(),
tool_context.process_scope.clone(),
))
}
TerminalBackendKind::LocalNonPersistent => {
@ -708,6 +709,7 @@ pub(crate) async fn spawn_session_actor(
resolve_search_shadows(),
login_shell_capture,
resolve_policy(),
tool_context.process_scope.clone(),
))
}
};

View file

@ -2035,6 +2035,37 @@ impl SessionActor {
snapshot: Box::new(snapshot),
});
}
if identical_tool_calls.take_nudge() {
let run_len = identical_tool_calls.run_len;
let tool_name = identical_tool_calls.tool_name.clone();
tracing::warn!(
session_id = %self.session_info.id,
tool_name = %tool_name,
run_len,
"action stationarity: nudging model to break repeated identical tool calls"
);
xai_grok_telemetry::unified_log::warn(
"shell.turn.action_stationarity_nudge",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!({
"loop_index": loop_index,
"tool_name": tool_name,
"run_len": run_len,
})),
);
let reminder = self
.tool_bridge_handle()
.render_prompt(
ACTION_STATIONARITY_NUDGE_TEMPLATE,
&serde_json::json!({
"tool_name": tool_name,
"run_len": run_len,
}),
)
.await
.unwrap_or_else(|| ACTION_STATIONARITY_NUDGE_TEMPLATE.to_string());
self.push_system_reminder(&reminder);
}
self.drain_pending_interjections().await;
self.flush_pending_skill_reminders().await;
self.inject_pending_monitor_events().await;
@ -2506,8 +2537,7 @@ impl SessionActor {
.map(|tc| tc.name.clone())
.unwrap_or_default();
let is_true_noop = self.is_run_true_step(&tool_calls).await;
let identical_run_len =
identical_tool_calls.observe(&step_signature, &step_tool_name, is_true_noop);
identical_tool_calls.observe(&step_signature, &step_tool_name, is_true_noop);
if is_true_noop {
xai_grok_telemetry::session_ctx::log_event(
xai_grok_telemetry::events::ShellTrueNoop {
@ -2515,35 +2545,6 @@ impl SessionActor {
},
);
}
if identical_run_len == NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
tracing::warn!(
session_id = %self.session_info.id,
tool_name = %step_tool_name,
run_len = identical_run_len,
"action stationarity: nudging model to break repeated identical tool calls"
);
xai_grok_telemetry::unified_log::warn(
"shell.turn.action_stationarity_nudge",
Some(self.session_info.id.0.as_ref()),
Some(serde_json::json!({
"loop_index": loop_index,
"tool_name": step_tool_name,
"run_len": identical_run_len,
})),
);
let reminder = self
.tool_bridge_handle()
.render_prompt(
ACTION_STATIONARITY_NUDGE_TEMPLATE,
&serde_json::json!({
"tool_name": step_tool_name,
"run_len": identical_run_len,
}),
)
.await
.unwrap_or_else(|| ACTION_STATIONARITY_NUDGE_TEMPLATE.to_string());
self.push_system_reminder(&reminder);
}
let tool_call_responses: Vec<ToolCallResponse> = tool_calls
.into_iter()
.map(|tc| ToolCallResponse {
@ -2627,13 +2628,13 @@ const MAX_CONSECUTIVE_TRUE_NOOPS: u32 = 4;
const _: () = assert!(NUDGE_AFTER_IDENTICAL_TOOL_CALLS < MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS);
const _: () = assert!(MAX_CONSECUTIVE_TRUE_NOOPS < NUDGE_AFTER_IDENTICAL_TOOL_CALLS);
const ACTION_STATIONARITY_NUDGE_TEMPLATE: &str = "You have called the same tool \
(`${{ tool_name }}`) with the exact same arguments ${{ run_len }} times in a row, \
getting the same result each time you appear to be stuck in a polling loop. Stop \
repeating this call. If you are waiting on a long-running job or command, use a \
background task${%- if tools.by_kind.monitor %} or the `${{ tools.by_kind.monitor }}` \
tool${%- endif %}, or run a single `sleep` and then check once do not poll in a tight \
loop. If you cannot make progress, stop and tell the user what you are waiting for. This \
turn will be halted automatically if the identical call keeps repeating.";
(`${{ tool_name }}`) with the exact same arguments ${{ run_len }} times in a row \
you appear to be stuck in a polling loop. Stop repeating this call. If you are \
waiting on a long-running job or command, use a background task${%- if tools.by_kind.monitor %} \
or the `${{ tools.by_kind.monitor }}` tool${%- endif %}, or run a single `sleep` and \
then check once do not poll in a tight loop. If you cannot make progress, stop and \
tell the user what you are waiting for. This turn will be halted automatically if the \
identical call keeps repeating.";
fn hash_step_signature(signature: &str) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
@ -2649,6 +2650,7 @@ struct IdenticalToolCallRun {
tool_name: String,
run_len: u32,
is_true_noop_run: bool,
nudged: bool,
}
impl IdenticalToolCallRun {
fn observe(&mut self, signature: &str, tool_name: &str, is_true_noop: bool) -> u32 {
@ -2663,10 +2665,17 @@ impl IdenticalToolCallRun {
self.run_len = 1;
self.last_signature_hash = Some(hash);
self.is_true_noop_run = is_true_noop;
self.nudged = false;
}
self.tool_name = tool_name.to_string();
self.run_len
}
/// Once per identical run at/after the nudge threshold. Call only after results are committed.
fn take_nudge(&mut self) -> bool {
let fire = self.run_len >= NUDGE_AFTER_IDENTICAL_TOOL_CALLS && !self.nudged;
self.nudged |= fire;
fire
}
fn hard_stop_threshold(&self) -> u32 {
if self.is_true_noop_run {
MAX_CONSECUTIVE_TRUE_NOOPS
@ -2679,7 +2688,7 @@ impl IdenticalToolCallRun {
mod identical_tool_call_run_tests {
use super::{
IdenticalToolCallRun, MAX_CONSECUTIVE_IDENTICAL_TOOL_CALLS, MAX_CONSECUTIVE_TRUE_NOOPS,
command_is_true,
NUDGE_AFTER_IDENTICAL_TOOL_CALLS, command_is_true,
};
#[test]
fn identical_non_true_resets_and_caps_at_16() {
@ -2715,6 +2724,31 @@ mod identical_tool_call_run_tests {
assert!(!command_is_true("true && echo hi"));
assert!(!command_is_true("lisa status"));
}
#[test]
fn nudge_latch_fires_once_per_run_after_threshold() {
let mut run = IdenticalToolCallRun::default();
for i in 1..NUDGE_AFTER_IDENTICAL_TOOL_CALLS {
assert_eq!(run.observe("poll", "get_task_output", false), i);
assert!(
!run.take_nudge(),
"must not nudge before threshold; run_len={i}"
);
}
assert_eq!(
run.observe("poll", "get_task_output", false),
NUDGE_AFTER_IDENTICAL_TOOL_CALLS
);
assert!(run.take_nudge());
assert!(!run.take_nudge());
assert_eq!(
run.observe("poll", "get_task_output", false),
NUDGE_AFTER_IDENTICAL_TOOL_CALLS + 1
);
assert!(!run.take_nudge());
assert_eq!(run.observe("other", "bash", false), 1);
assert!(!run.nudged);
assert!(!run.take_nudge());
}
}
/// Backoff schedule for resubmits after a *successful* 401 auth recovery
/// (fresh token minted, request to be re-sent).

View file

@ -0,0 +1,252 @@
//! Chat-history integrity across mid-turn user-message injection.
//!
//! A `push_user_message` (system reminder, interjection, etc.) while an
//! assistant `tool_use` is committed but unanswered makes integrity repair
//! fabricate a `"cancelled by the user"` `tool_result`. If the tool then
//! runs and appends its real result, the conversation has **two**
//! `tool_result`s for one `tool_use_id`. Providers reject that shape with
//! HTTP 400 on every subsequent request — a permanently bricked session
//! with no in-band recovery.
//!
//! The concrete injector that first hit this was the action-stationarity
//! nudge (8 consecutive identical tool calls). The invariant is broader:
//! **no mid-turn user injection may leave duplicate results for one id.**
//! The nudge is only the driver that reaches the vulnerable window.
//!
//! This is intentionally *not* a unit test of `IdenticalToolCallRun`'s latch
//! (see `identical_tool_call_run_tests` in `turn.rs`). It drives a real
//! turn loop against a scripted model so a reordering that pushes the
//! reminder between `record_assistant_response` and `execute_tool_calls`
//! fails here.
use super::support::*;
use super::*;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use xai_grok_test_support::sse::{
responses_api_reasoning_then_tool_call_events, responses_api_script_exact,
};
use xai_grok_test_support::{MockInferenceServer, ScriptedResponse};
/// Product threshold at which the stationarity nudge fires. Kept as a local
/// literal so this suite does not couple to the private latch constants;
/// changing the threshold still trips the same history invariant as long as
/// a nudge is delivered mid-turn after identical calls.
const IDENTICAL_CALLS_TO_TRIP_NUDGE: usize = 8;
const TODO_ARGS: &str = r#"{"todos":[{"id":"t1","content":"poll","status":"completed"}]}"#;
const CANCEL_MARKER: &str = "cancelled by the user";
/// Distinctive fragment of the action-stationarity reminder. Present in both
/// the rendered template and the unrendered fallback string.
const STATIONARITY_NUDGE_MARKER: &str = "stuck in a polling loop";
fn tool_call_sse(call_id: &str) -> ScriptedResponse {
ScriptedResponse::sse(responses_api_reasoning_then_tool_call_events(
"poll",
call_id,
"todo_write",
TODO_ARGS,
"test",
))
}
fn drain_gateway(mut rx: tokio::sync::mpsc::UnboundedReceiver<xai_acp_lib::AcpClientMessage>) {
tokio::task::spawn_local(async move {
while let Some(msg) = rx.recv().await {
if let xai_acp_lib::AcpClientMessage::SessionNotification(args) = msg {
let _ = args.response_tx.send(Ok(()));
}
}
});
}
fn drain_persistence(mut rx: tokio::sync::mpsc::UnboundedReceiver<PersistenceMsg>) {
tokio::task::spawn_local(async move {
while let Some(msg) = rx.recv().await {
if let PersistenceMsg::FlushAndAck { respond_to } = msg {
let _ = respond_to.send(());
}
}
});
}
/// Group `ToolResult` bodies by `tool_call_id` across the whole conversation
/// (not just the contiguous run after an assistant message). Provider validation
/// is global; a user row between two results for the same id still 400s.
fn tool_results_by_call_id(conv: &[ConversationItem]) -> HashMap<String, Vec<String>> {
let mut by_id: HashMap<String, Vec<String>> = HashMap::new();
for item in conv {
if let ConversationItem::ToolResult(tr) = item {
by_id
.entry(tr.tool_call_id.clone())
.or_default()
.push(item.text_content());
}
}
by_id
}
/// Mid-turn system reminder (stationarity nudge after 8 identical tool calls)
/// must not fabricate a phantom cancel that duplicates a live tool's result.
///
/// Pre-fix this failed: the nudge was pushed after the assistant `tool_use`
/// was committed and before `execute_tool_calls`, so integrity repair wrote
/// a cancel result and the real result landed beside it under the same id.
#[tokio::test(flavor = "current_thread")]
async fn mid_turn_user_injection_must_not_duplicate_tool_results_for_one_tool_use_id() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let server = MockInferenceServer::start().await.expect("mock inference server");
for i in 1..=IDENTICAL_CALLS_TO_TRIP_NUDGE {
server.enqueue_response(
"/v1/responses",
tool_call_sse(&format!("stat-call-{i}")),
);
}
server.enqueue_response(
"/v1/responses",
ScriptedResponse::sse(responses_api_script_exact("done", "test")),
);
let sampling_cfg = xai_grok_sampler::SamplerConfig {
api_key: Some("test-key".to_string()),
base_url: server.url(),
model: "test".to_string(),
api_backend: xai_grok_sampler::ApiBackend::Responses,
context_window: 256_000,
max_retries: Some(0),
idle_timeout_secs: Some(30),
..Default::default()
};
let (sampler_event_tx, sampler_event_rx) =
tokio::sync::mpsc::unbounded_channel::<xai_grok_sampler::SamplingEvent>();
let sampler_handle = xai_grok_sampler::SamplerActor::spawn(
sampling_cfg,
xai_grok_sampler::RetryPolicy {
max_retries: 0,
rate_limit_retry_threshold: 0,
..Default::default()
},
sampler_event_tx,
);
let (gateway_tx, gateway_rx) =
tokio::sync::mpsc::unbounded_channel::<xai_acp_lib::AcpClientMessage>();
drain_gateway(gateway_rx);
let (persistence_tx, persistence_rx) =
tokio::sync::mpsc::unbounded_channel::<PersistenceMsg>();
drain_persistence(persistence_rx);
let mut actor = create_test_actor(0, 256_000, 85, gateway_tx, persistence_tx).await;
actor.sampler_handle = sampler_handle;
*actor.agent.borrow_mut() = test_grok_build_agent_with_todo().await;
let mut cfg = actor
.chat_state_handle
.get_sampling_config()
.await
.expect("test actor has sampling config");
cfg.base_url = server.url();
cfg.api_backend = xai_grok_sampling_types::ApiBackend::Responses;
cfg.model = "test".to_string();
actor.chat_state_handle.update_sampling_config(cfg);
let mut creds = actor.chat_state_handle.get_credentials().await;
creds.api_key = Some("test-key".to_string());
actor.chat_state_handle.update_credentials(creds);
actor
.workspace_ops
.bind_local_session(
&actor.session_id_string(),
actor.tool_context.cwd.as_path().to_path_buf(),
actor.tool_context.hunk_tracker_handle.clone(),
actor.agent.borrow().tool_bridge().toolset(),
None,
)
.expect("bind_local_session");
let actor = Arc::new(actor);
{
let drainer = actor.clone();
let mut sampler_event_rx = sampler_event_rx;
tokio::task::spawn_local(async move {
while let Some(event) = sampler_event_rx.recv().await {
drainer.handle_sampling_event(event).await;
}
});
}
let prompt_blocks = vec![acp::ContentBlock::Text(acp::TextContent::new(
"keep polling the same todo".to_string(),
))];
let outcome = tokio::time::timeout(
Duration::from_secs(60),
actor.handle_prompt(
"chat-history-integrity",
prompt_blocks,
PromptMode::Agent,
None,
None,
None,
None,
true,
None,
None,
None,
),
)
.await
.expect("turn must finish within timeout");
assert!(
outcome.is_ok(),
"turn must not error: {outcome:?}"
);
let conv = actor.chat_state_handle.get_conversation().await;
let by_id = tool_results_by_call_id(&conv);
assert!(
by_id.len() >= IDENTICAL_CALLS_TO_TRIP_NUDGE,
"expected at least {IDENTICAL_CALLS_TO_TRIP_NUDGE} executed tool calls to trip the nudge; got {} distinct tool_call_ids. conversation={conv:#?}",
by_id.len()
);
for (tool_call_id, results) in &by_id {
let has_cancel = results.iter().any(|r| r.contains(CANCEL_MARKER));
let has_real = results.iter().any(|r| !r.contains(CANCEL_MARKER));
assert!(
!(has_cancel && has_real),
"tool_use_id `{tool_call_id}` has both a fabricated `{CANCEL_MARKER}` \
tool_result and a real execution result. The tool ran; integrity repair \
must not claim it was cancelled. A mid-turn user message was pushed while \
the tool_use was unanswered. results={results:?}"
);
assert_eq!(
results.len(),
1,
"tool_use_id `{tool_call_id}` has {} tool_results. \
Duplicate tool_results for one tool_use_id brick the session: the \
Anthropic Messages API (and siblings) reject the history with HTTP 400 \
on every subsequent request, and there is no in-band recovery. \
Mid-turn user messages (nudges, reminders, interjections) must not be \
pushed while a tool_use is unanswered integrity repair fabricates a \
cancel result, then the live tool appends a second result. results={results:?}",
results.len()
);
}
assert!(
conv.iter()
.any(|item| item.text_content().contains(STATIONARITY_NUDGE_MARKER)),
"action-stationarity nudge must still be delivered after the identical-call \
run; deleting the nudge is not a valid fix for chat-history corruption. \
conversation={conv:#?}"
);
})
.await;
}

View file

@ -51,11 +51,24 @@ pub(crate) fn git_bin() -> OsString {
}
}
/// A `sh -c <script>` command: the portable shell escape hatch shared by the
/// identity and auth providers.
pub(crate) fn sh_c(script: &str) -> Command {
let mut cmd = Command::new("sh");
cmd.args(["-c", script]);
/// Run a config-provided command string through the platform shell: `sh -c`
/// on unix, `cmd /C` on Windows. The escape hatch shared by the auth
/// providers and the identity command.
///
/// Windows has no `sh` on `PATH` in a default install, so hardcoding it made
/// every one of those call sites fail to spawn — and where Git Bash *is*
/// installed, `sh` eats the backslashes in a native path such as
/// `C:\corp\auth.exe`. `cmd /C` runs `.exe` / `.cmd` / `.bat` directly and
/// propagates the child's exit code, which the auth providers' "exit 0 =
/// success" contract depends on (PowerShell's `-Command` does not).
pub(crate) fn shell_c(script: &str) -> Command {
let (shell, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("sh", "-c")
};
let mut cmd = Command::new(shell);
cmd.args([flag, script]);
cmd
}
@ -246,7 +259,7 @@ mod tests {
use super::*;
fn sh(script: &str) -> Command {
sh_c(script)
shell_c(script)
}
fn opts(label: &str) -> RunOptions<'_> {
@ -258,6 +271,19 @@ mod tests {
const TIMEOUT: Duration = Duration::from_secs(10);
/// The command-string escape hatch must spawn on the host platform. A
/// hardcoded `sh` fails here on Windows, which silently downgraded
/// `auth_provider_command` to the built-in login. `echo hi` is valid in
/// both `sh -c` and `cmd /C`.
#[tokio::test]
async fn shell_c_spawns_on_this_platform() {
let out = run_detached_with_timeout(shell_c("echo hi"), TIMEOUT, opts("test shell_c"))
.await
.expect("the platform shell must be spawnable");
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi");
}
#[tokio::test]
async fn large_stderr_is_streamed_and_capped() {
let out = run_detached_with_timeout(

View file

@ -6,7 +6,7 @@ use crate::util::subprocess::CommandLog;
use crate::util::subprocess::RunOptions;
use crate::util::subprocess::git_bin;
use crate::util::subprocess::run_detached_with_timeout;
use crate::util::subprocess::sh_c;
use crate::util::subprocess::shell_c;
use std::env;
use std::time::Duration;
use std::time::Instant;
@ -151,7 +151,7 @@ async fn git_global_email() -> Option<String> {
/// `None` on any failure; callers fall back to the declarative sources.
async fn run_identity_command(command: &str) -> Option<ResolvedUserIdentity> {
let cmd = sh_c(command);
let cmd = shell_c(command);
let output = run_detached_with_timeout(
cmd,
COMMAND_TIMEOUT,

View file

@ -0,0 +1,98 @@
//! End-to-end guard for `auth_provider_command`: a configured external auth
//! provider must actually mint the session credential on the host platform.
//!
//! Regression cover. The provider used to be spawned through a hardcoded
//! `sh -c`. On Windows that either fails to spawn (no `sh` in a default
//! install) or, where Git Bash is present, silently eats the backslashes in a
//! native path — `C:\Windows\System32\whoami.exe` reaches the shell as
//! `C:WindowsSystem32whoami.exe` and exits 127. Either way the auth flow fell
//! through to the built-in browser login, so a configured provider looked like
//! it had been ignored.
//!
//! The test drives the public entry point (`try_ensure_fresh_auth` →
//! `AuthManager::auth` → external refresher → platform shell) and is hermetic:
//! a throwaway `GROK_HOME`, no network, and a provider command that needs no
//! binary beyond what the platform shell already provides.
use std::collections::BTreeMap;
use std::path::Path;
use chrono::Utc;
use xai_grok_shell::auth::{AuthMode, GrokAuth, GrokComConfig, try_ensure_fresh_auth};
const SEED_TOKEN: &str = "stale-token-that-must-be-replaced";
/// Point the process at a throwaway grok home. `grok_home()` memoizes into a
/// `OnceLock`, so every phase below shares this one directory — which is why
/// they live in a single test rather than racing each other as separate ones.
fn use_temp_grok_home(dir: &Path) {
// SAFETY: single-threaded test entry, before any thread that reads the
// environment is spawned.
unsafe {
std::env::set_var("GROK_HOME", dir);
}
}
/// Seed an expired credential so `auth()` takes the refresh path; a cold home
/// returns `NotLoggedIn` without ever consulting the provider.
fn seed_expired_credential(home: &Path, scope: &str) {
let expired = GrokAuth {
key: SEED_TOKEN.to_owned(),
auth_mode: AuthMode::External,
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
..GrokAuth::default()
};
let store: BTreeMap<String, GrokAuth> = [(scope.to_owned(), expired)].into_iter().collect();
std::fs::write(
home.join("auth.json"),
serde_json::to_string(&store).expect("serialize auth store"),
)
.expect("write auth.json");
}
/// Run one provider command through the real auth path and return the token.
async fn mint_with_provider(home: &Path, command: &str) -> String {
let config = GrokComConfig {
auth_provider_command: Some(command.to_owned()),
..GrokComConfig::default()
};
seed_expired_credential(home, &config.auth_scope());
let auth = try_ensure_fresh_auth(&config).await.unwrap_or_else(|| {
panic!("auth_provider_command `{command}` was configured but no credential was minted")
});
assert_eq!(
auth.auth_mode,
AuthMode::External,
"credential must come from the provider, not a cached or built-in path"
);
assert_ne!(
auth.key, SEED_TOKEN,
"the expired seed must have been replaced by the provider's output"
);
auth.key
}
#[tokio::test]
async fn auth_provider_command_mints_the_session_credential() {
let home = tempfile::tempdir().expect("tempdir");
use_temp_grok_home(home.path());
// `echo <token>` is valid in both `sh -c` and `cmd /C`, so this phase needs
// no external binary and runs identically on every platform.
let token = mint_with_provider(home.path(), "echo grok-ext-token").await;
assert_eq!(token, "grok-ext-token");
// Windows only: an absolute native path, the form an operator actually
// writes in config.toml, and the exact shape a POSIX shell mangles. Run
// after the portable phase so a failure here is unambiguously about
// backslash handling rather than the provider path in general.
#[cfg(windows)]
{
let token = mint_with_provider(home.path(), r"C:\Windows\System32\whoami.exe").await;
assert!(
!token.trim().is_empty(),
"a native Windows path must reach the provider intact"
);
}
}

View file

@ -1237,6 +1237,93 @@ async fn test_stdio_xcode_escaped_slash_methods_get_responses() {
);
}
/// `grok agent stdio` must initiate shutdown and exit when its client closes
/// stdin (EOF) — a dead parent means closed pipes, so this is the primary
/// orphan guard on every platform (the Linux `PR_SET_PDEATHSIG` binding in
/// `run_stdio_agent` additionally covers an agent wedged mid-turn that never
/// reads stdin again). Guards the `spawn_stdin_line_reader` → stdin_closed →
/// simplex-shutdown → `handle_io` completion chain end to end.
#[tokio::test]
#[ignore] // requires pre-built binary; run with --ignored
async fn test_stdio_agent_exits_on_stdin_eof() {
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
let server = MockInferenceServer::start()
.await
.expect("start mock server");
let mut sandbox = TestSandbox::builder().git().build();
sandbox.set_mock_url(server.url());
let mut cmd = tokio::process::Command::new(grok_binary());
cmd.args(["agent", "stdio"])
.current_dir(sandbox.workspace());
let mut process = TestProcess::spawn(
cmd,
&sandbox,
TestProcessConfig::new()
.label("grok agent stdio (eof)")
.stdin(TestStdin::Piped)
.stdout(TestOutput::Piped),
)
.expect("spawn grok agent stdio");
// Prove the agent is up and serving before the EOF (an exit during
// startup would trivially pass the wait below).
let mut stdin = process.take_stdin().expect("child stdin missing");
let stdout = process.take_stdout().expect("child stdout missing");
let mut reader = tokio::io::BufReader::new(stdout);
stdin
.write_all(
concat!(
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"#,
r#""clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false},"#,
r#""_meta":{"startupHints":{"nonInteractive":true,"skipGitStatus":true,"skipProjectLayout":true},"#,
r#""clientType":"eof-test","clientVersion":"0.0.0"}}}"#,
"\n"
)
.as_bytes(),
)
.await
.expect("write initialize");
stdin.flush().await.expect("flush initialize");
let mut line = String::new();
tokio::time::timeout(scaled(Duration::from_secs(20)), reader.read_line(&mut line))
.await
.unwrap_or_else(|_| {
panic!(
"no initialize response before EOF\nstderr:\n{}",
stderr_tail(&process.stderr_tail().text, 1200)
)
})
.expect("read initialize response");
assert!(
line.contains("\"result\""),
"initialize must respond with a result, got: {line}"
);
// Close the write end: the agent sees stdin EOF, exactly as when its
// parent dies and the inherited pipe closes.
drop(stdin);
// Exit path includes a bounded teardown (100ms simplex flush + 2s upload
// queue grace), so allow comfortably more than that.
let status = process
.wait_with_deadline(scaled(Duration::from_secs(30)))
.await
.expect("wait for agent exit")
.unwrap_or_else(|| {
panic!(
"grok agent stdio did not exit after stdin EOF\n{}",
process.diagnostic_summary()
)
});
assert!(
status.success(),
"agent should exit cleanly on stdin EOF, got {status:?}\nstderr:\n{}",
stderr_tail(&process.stderr_tail().text, 1200)
);
}
// ── Config test harness ─────────────────────────────────────────────────────
/// Isolated headless run with a custom `~/.grok/`. Clean env (no leaked

View file

@ -1,15 +1,10 @@
//! Leader soak: an in-process leader server fronting a REAL `MvpAgent`, hammered
//! by churning `LeaderClient`s until a time budget expires. Asserts the leader
//! neither leaks memory nor accumulates zombie clients, and that no response is
//! ever dropped on a live-client send (`leader.response.send_failed`).
//!
//! Duration is bounded by `LEADER_SOAK_SECS` (default 10s so an ad-hoc
//! `--ignored` run stays quick). RSS growth is bounded by
//! `LEADER_SOAK_MAX_RSS_GROWTH_MB` (default 1024). On-demand today — no CI
//! lane runs it; a real soak is the long form:
//! Leader soak: a real `MvpAgent` behind an in-process leader, churned by
//! clients until `LEADER_SOAK_SECS` expires. Each cycle closes its sessions,
//! so the bounds measure what teardown reclaims.
//!
//! ```bash
//! LEADER_SOAK_SECS=1200 cargo test -p xai-grok-shell --test test_leader_soak -- --ignored --nocapture
//! LEADER_SOAK_SECS=1200 cargo test -p xai-grok-shell --features test-support \
//! --test test_leader_soak -- --ignored --nocapture
//! ```
#![cfg(unix)]
@ -17,25 +12,14 @@
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol as acp;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use tokio_util::sync::CancellationToken;
use xai_acp_lib::{
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
LineBufferedRead,
};
use xai_grok_shell::agent::config::Config as AgentConfig;
use xai_grok_shell::agent::mvp_agent::MvpAgent;
use xai_grok_shell::leader::{
ClientCapabilities, ClientMode, LeaderClient, LeaderServerControlState, LeaderServerMetadata,
run_leader_server,
};
use xai_grok_test_support::resources::ResourceSnapshot;
const SIMPLEX_BUF: usize = 8 * 1024 * 1024;
fn env_u64(key: &str, default: u64) -> u64 {
std::env::var(key)
.ok()
@ -113,13 +97,13 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
let sock_path = grok_home.path().join("leader-soak.sock");
let soak_secs = env_u64("LEADER_SOAK_SECS", 10);
let max_growth_mb = env_u64("LEADER_SOAK_MAX_RSS_GROWTH_MB", 1024);
let max_thread_growth = env_u64("LEADER_SOAK_MAX_THREAD_GROWTH", 64) as usize;
let send_failed_before = send_failed_count();
let local = tokio::task::LocalSet::new();
local
.run_until(async {
// ── Leader server (survives client churn) ────────────────────
let (acp_tx, mut acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (acp_tx, acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let cancel = CancellationToken::new();
let client_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
@ -152,70 +136,9 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
.await;
});
// ── Real agent behind it ──────────────────────────────────────
// Copied from `run_leader`'s agent-spawn + IPC/stdout bridge
// blocks in src/agent/app.rs (inside its LocalSet body); kept as
// a deliberate copy so production stays untouched. Second copy of
// the same wiring: xai-grok-pager/src/app/leader_cluster/mod.rs
// (`spawn_leader_generation`) — keep the two copies behaviorally
// identical.
let (agent_in_read, agent_in_write) = tokio::io::simplex(SIMPLEX_BUF);
let (agent_out_read, agent_out_write) = tokio::io::simplex(SIMPLEX_BUF);
tokio::task::spawn_local(async move {
let agent_config = AgentConfig::default();
let auth_manager = Arc::new(agent_config.create_auth_manager());
let (gw_tx, gw_rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(gw_tx);
let agent = MvpAgent::new(gateway, &agent_config, auth_manager, None)
.expect("valid agent config");
let incoming = LineBufferedRead::spawn_local(agent_in_read.compat());
let (conn, handle_io) = acp::AgentSideConnection::new(
agent,
agent_out_write.compat_write(),
incoming,
|fut| {
tokio::task::spawn_local(fut);
},
);
tokio::task::spawn_local(
GatewayReceiver::new(gw_rx, conn)
.with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent)
.run(),
);
let _ = handle_io.await;
});
// Leader → agent stdin.
tokio::task::spawn_local(async move {
let mut agent_in_write = agent_in_write;
while let Some(msg) = acp_rx.recv().await {
if agent_in_write.write_all(msg.as_bytes()).await.is_err()
|| agent_in_write.write_all(b"\n").await.is_err()
{
break;
}
}
});
// Agent stdout → leader responses.
let response_tx_for_agent = response_tx.clone();
tokio::task::spawn_local(async move {
let mut reader = BufReader::new(agent_out_read);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let msg = line.trim_end_matches(['\r', '\n']).to_string();
if !msg.is_empty() {
let _ = response_tx_for_agent.send(msg);
}
}
Err(_) => break,
}
}
});
// Hold a sender for the whole soak: the leader's response channel
// must not close when the agent's output ends.
xai_grok_shell::leader::in_process::spawn_agent(acp_rx, response_tx.clone());
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while !sock_path.exists() && tokio::time::Instant::now() < deadline {
@ -223,7 +146,6 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
}
assert!(sock_path.exists(), "leader socket never bound");
// ── One-time initialize + authenticate through the leader ────
let mut bootstrap = LeaderClient::connect(
sock_path.clone(),
"soak-bootstrap",
@ -247,14 +169,17 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
)
.await;
eprintln!(
"[soak] budgets: {soak_secs}s, rss {max_growth_mb} MB, threads {max_thread_growth}"
);
let rss_before = ResourceSnapshot::capture();
let soak_deadline = tokio::time::Instant::now() + Duration::from_secs(soak_secs);
let workdir_str = workdir.path().to_string_lossy().to_string();
let mut cycles: u64 = 0;
let mut turns: u64 = 0;
// ── Churn: 10 fresh clients per cycle, 2 sessions each, one
// scripted turn per session, then all disconnect ───────────────
// Each cycle: 10 fresh clients, 2 sessions each, one scripted
// turn per session, then all disconnect.
while tokio::time::Instant::now() < soak_deadline {
cycles += 1;
let mut clients = Vec::new();
@ -298,10 +223,22 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
)
.await;
turns += 1;
// Disconnecting leaves sessions resident for a
// reconnect; `_` is the wire form for a custom method.
let close_id = 300 + s;
rpc(
client,
format!(
r#"{{"jsonrpc":"2.0","id":{close_id},"method":"_x.ai/session/close","params":{{"sessionId":"{sid}"}}}}"#
),
close_id,
"x.ai/session/close",
)
.await;
}
}
// Churn: everyone disconnects; the roster must drain fully.
for client in clients {
client.cancel();
}
@ -314,13 +251,39 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
// Linear in cycles is a leak; flattening is the allocator.
if let Some(rss) = ResourceSnapshot::capture().rss {
eprintln!(
"[soak] cycle {cycles}: rss {:.1} MB",
rss as f64 / (1024.0 * 1024.0)
);
}
if cycles == 1 {
let snap = rpc(
&mut bootstrap,
r#"{"jsonrpc":"2.0","id":901,"method":"_x.ai/debug/agent","params":{}}"#
.to_string(),
901,
"x.ai/debug/agent",
)
.await;
eprintln!("[soak] registries after cycle 1: {}", snap["result"]["registries"]);
}
}
let snap = rpc(
&mut bootstrap,
r#"{"jsonrpc":"2.0","id":902,"method":"_x.ai/debug/agent","params":{}}"#
.to_string(),
902,
"x.ai/debug/agent",
)
.await;
eprintln!("[soak] registries at end: {}", snap["result"]["registries"]);
eprintln!("[soak] {cycles} cycles, {turns} turns in {soak_secs}s budget");
assert!(cycles > 0, "soak budget too small to complete one cycle");
// ── Convergence: only the bootstrap client remains, and the
// leader still serves a healthy round-trip ────────────────────
assert_eq!(
client_count.load(std::sync::atomic::Ordering::Relaxed),
1,
@ -337,14 +300,12 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
.await;
assert!(resp["result"]["sessionId"].is_string());
// ── No response was ever dropped on a live-client send ────────
assert_eq!(
send_failed_count(),
send_failed_before,
"leader.response.send_failed must not occur during the soak"
);
// ── RSS bound ─────────────────────────────────────────────────
let rss_after = ResourceSnapshot::capture();
let growth = rss_after.growth_from(&rss_before);
if let (Some(before), Some(after), Some(growth_bytes)) =
@ -357,11 +318,28 @@ async fn leader_soak_churning_clients_no_leaks_no_zombies() {
after as f64 / (1024.0 * 1024.0),
);
assert!(
growth_mb < max_growth_mb as f64,
growth_mb <= max_growth_mb as f64,
"leader RSS grew {growth_mb:.1} MB over the soak (bound {max_growth_mb} MB)"
);
} else {
eprintln!("[soak] rss measurement unavailable on this platform; bound skipped");
panic!("memory sample unavailable; the soak cannot bound it");
}
// A missing sample means the probe failed, which would silently
// retire the nightly budget. Threads are Linux-only.
match growth.threads {
Some(thread_growth) => {
eprintln!("[soak] threads: growth {thread_growth}");
assert!(
thread_growth <= max_thread_growth,
"leader threads grew by {thread_growth} over the soak \
(bound {max_thread_growth})"
);
}
None if cfg!(target_os = "linux") => {
panic!("thread growth sample unavailable; the soak cannot bound it")
}
None => {}
}
bootstrap.cancel();

View file

@ -32,6 +32,8 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(60);
struct Counts {
sessions: usize,
session_threads: usize,
resident_resources: usize,
retained_resources: usize,
dispatch_locks: usize,
session_turn_numbers: usize,
permission_event_receivers: usize,
@ -73,11 +75,11 @@ async fn ext_method(
method: &str,
params: serde_json::Value,
) -> serde_json::Value {
let raw =
let params_json =
serde_json::value::RawValue::from_string(params.to_string()).expect("serialize ext params");
let resp = tokio::time::timeout(
RPC_TIMEOUT,
conn.ext_method(acp::ExtRequest::new(method, Arc::from(raw))),
conn.ext_method(acp::ExtRequest::new(method, Arc::from(params_json))),
)
.await
.unwrap_or_else(|_| panic!("{method} timed out"))
@ -255,6 +257,12 @@ fn session_churn_returns_registry_snapshot_to_baseline() {
baseline.sessions, 0,
"warmup session must be fully removed before baseline"
);
assert_eq!(
(baseline.resident_resources, baseline.retained_resources),
(0, 0),
"warmup must leave no per-session resource entries, including \
entries holding no resources"
);
assert_eq!(
baseline.workspace_bindings,
Some(0),

View file

@ -3,7 +3,7 @@
//! Resuming a large session once OOM-killed the process under a cgroup cap.
//!
//! Run:
//! cargo test -p xai-grok-shell --features dhat-heap --test test_session_load_memory \
//! cargo test -p xai-grok-shell --features dhat-heap,test-support --test test_session_load_memory \
//! session_load_dhat_bounded_and_freed -- --ignored --nocapture
#![cfg(unix)]

View file

@ -34,6 +34,16 @@ fn event_value(event_name: &str) -> &str {
event_name
}
/// Product-analytics `$insert_id`: unique per emit, ≤36 bytes, `[A-Za-z0-9-]`.
///
/// Do not put the event name in this field. The analytics sink truncates to 36
/// chars and rejects most other characters; a name-prefixed id either collapses
/// to a constant (long names → per-user same-second dedup) or is dropped and
/// regenerated (shorter names with `:`). A bare UUID always validates.
fn product_analytics_insert_id() -> String {
uuid::Uuid::new_v4().simple().to_string()
}
#[derive(Clone)]
pub struct TelemetryClient {
mode: TelemetryMode,
@ -114,6 +124,7 @@ impl TelemetryClient {
fn normalize_tier(tier: &str) -> String {
match tier {
"SuperGrok Heavy" | "supergrok_heavy" => "supergrok_heavy",
"SuperGrok Plus" | "supergrok_plus" => "supergrok_plus",
"SuperGrok" | "supergrok" => "supergrok",
"SuperGrok Lite" | "supergrok_lite" => "supergrok_lite",
"X Premium+" | "x_premium_plus" => "x_premium_plus",
@ -236,7 +247,7 @@ pub async fn track(event_name: &str, request_id: &str, ctx: &UserContext, mut me
// Mixpanel path
if let Some(ref mixpanel) = client.mixpanel {
let time_secs = chrono::Utc::now().timestamp();
let insert_id = format!("{event_name}:{request_id}:{time_secs}");
let insert_id = product_analytics_insert_id();
// Convert serde_json::Map to HashMap for mixpanel
let mut props: std::collections::HashMap<String, serde_json::Value> =
@ -495,6 +506,8 @@ mod tests {
assert_eq!(normalize_tier("X Premium+"), "x_premium_plus");
assert_eq!(normalize_tier("X Premium"), "x_premium");
assert_eq!(normalize_tier("SuperGrok Lite"), "supergrok_lite");
assert_eq!(normalize_tier("SuperGrok Plus"), "supergrok_plus");
assert_eq!(normalize_tier("supergrok_plus"), "supergrok_plus");
// API key is a dedicated Mixpanel segment — never free.
assert_eq!(normalize_tier("API Key"), "api_key");
assert_eq!(normalize_tier("api_key"), "api_key");

View file

@ -1205,6 +1205,34 @@ pub struct AnnouncementCtaClicked {
pub source: AnnouncementCtaSurface,
}
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodingDataConsentSource {
PrivacyBanner,
Settings,
}
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CodingDataConsentChoice {
OptIn,
OptOut,
}
impl CodingDataConsentChoice {
pub fn from_opted_in(opted_in: bool) -> Self {
if opted_in { Self::OptIn } else { Self::OptOut }
}
}
#[derive(Serialize)]
pub struct CodingDataConsentSelected {
pub source: CodingDataConsentSource,
pub choice: CodingDataConsentChoice,
pub previous_choice: CodingDataConsentChoice,
pub changed: bool,
}
/// Flat snapshot of the terminal environment for telemetry.
///
/// Shared across pager events so terminal fields are typed once.
@ -1749,6 +1777,7 @@ telemetry_event!(SuperGrokUpsellShown, "supergrok_upsell_shown");
telemetry_event!(SuperGrokUpsellClicked, "supergrok_upsell_clicked");
telemetry_event!(AnnouncementCtaShown, "announcement_cta_shown");
telemetry_event!(AnnouncementCtaClicked, "announcement_cta_clicked");
telemetry_event!(CodingDataConsentSelected, "coding_data_consent_selected");
telemetry_event!(TerminalTelemetry, "terminal_context");
telemetry_event!(DisplayRefreshProbe, "display_refresh_probe");
telemetry_event!(BackspaceNoEffect, "backspace_no_effect");
@ -2024,6 +2053,30 @@ mod tests {
assert_eq!(AnnouncementCtaClicked::NAME, "announcement_cta_clicked");
}
#[test]
fn coding_data_consent_selected_name_and_shape() {
assert_eq!(
CodingDataConsentSelected::NAME,
"coding_data_consent_selected"
);
let event = serde_json::to_value(CodingDataConsentSelected {
source: CodingDataConsentSource::Settings,
choice: CodingDataConsentChoice::OptIn,
previous_choice: CodingDataConsentChoice::OptIn,
changed: false,
})
.unwrap();
assert_eq!(
event,
serde_json::json!({
"source": "settings",
"choice": "opt_in",
"previous_choice": "opt_in",
"changed": false,
})
);
}
#[test]
fn compaction_retry_degraded_name_and_shape() {
assert_eq!(CompactionRetryDegraded::NAME, "compaction_retry_degraded");

View file

@ -41,20 +41,43 @@ impl std::fmt::Debug for DedicatedRuntime {
}
impl DedicatedRuntime {
fn new() -> Self {
fn new() -> Option<Self> {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<RuntimeCommand>();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<bool>();
let pump = move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("external OTEL gRPC runtime");
{
Ok(rt) => {
let _ = ready_tx.send(true);
rt
}
Err(e) => {
tracing::error!(error = %e, "external OTEL gRPC runtime build failed; external telemetry disabled");
let _ = ready_tx.send(false);
return;
}
};
rt.block_on(async move {
while let Some(future) = rx.recv().await {
tokio::spawn(future);
}
});
});
Self { tx }
};
let spawned = std::thread::Builder::new()
.name("otel-external-rt".into())
.spawn(pump);
if let Err(e) = spawned {
tracing::error!(error = %e, "external OTEL gRPC runtime thread spawn failed; external telemetry disabled");
return None;
}
// Bounded: this runs on the startup path, and the host that refuses
// threads is the one least likely to schedule this one promptly.
match ready_rx.recv_timeout(Duration::from_secs(2)) {
Ok(true) => Some(Self { tx }),
_ => None,
}
}
fn run<T: Send + 'static>(
@ -347,7 +370,9 @@ fn build_log_otlp_provider(
)
}
OtlpTransport::Grpc => {
let runtime = DedicatedRuntime::new();
let Some(runtime) = DedicatedRuntime::new() else {
return Err(opentelemetry_otlp::ExporterBuildError::ThreadSpawnFailed);
};
let exporter = exporter_builder.export(OtlpExportTransport::Grpc(&runtime))?;
builder.with_log_processor(
RuntimeBatchLogProcessor::builder(
@ -383,7 +408,9 @@ fn build_metric_otlp_provider(
)
}
OtlpTransport::Grpc => {
let runtime = DedicatedRuntime::new();
let Some(runtime) = DedicatedRuntime::new() else {
return Err(opentelemetry_otlp::ExporterBuildError::ThreadSpawnFailed);
};
let exporter = exporter_builder.export(OtlpExportTransport::Grpc(&runtime))?;
builder.with_reader(
RuntimePeriodicReader::builder(

View file

@ -5,9 +5,10 @@
//! [`ingest_client_entries()`] and writes on their behalf.
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::io::{Read, Seek, Write};
use std::path::PathBuf;
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::{Duration, Instant};
use chrono::Utc;
use serde::{Deserialize, Serialize};
@ -130,12 +131,40 @@ pub struct ClientLogEntry {
// Writer
// ---------------------------------------------------------------------------
/// How often a writer re-checks that its handle still refers to the file at
/// `path`, and that the file is still under [`MAX_SIZE`].
///
/// Time-based rather than byte-based so a low-volume process detects a stale
/// handle just as fast as a chatty one — a process logging one line a minute
/// is precisely the one that would otherwise write into an unlinked inode for
/// hours without noticing.
const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(2);
struct LogWriter {
file: File,
path: PathBuf,
written: u64,
/// Identity of the inode this handle refers to, re-checked against the
/// path on the maintenance cadence. `None` on platforms with no cheap
/// stable file id, where only disappearance is detectable.
identity: Option<FileIdentity>,
last_maintenance: Instant,
/// Set when `path` stopped resolving to our inode **and** reopening it
/// failed. Writes are dropped while it is set.
///
/// Continuing to append to the old descriptor would be the exact failure
/// this module was changed to end: bytes land in a file no reader can
/// find and no process will ever trim. Dropping them is not a loss —
/// those bytes were already unreadable — and it avoids growing an
/// invisible file on a disk that is quite possibly full, which is one of
/// the few ways the reopen fails in the first place. Cleared by the next
/// successful reopen, retried on the maintenance cadence.
detached: bool,
}
/// `(dev, ino)` on Unix. Enough to notice that the path now resolves to a
/// different inode than the one we hold open.
type FileIdentity = (u64, u64);
static WRITER: LazyLock<Mutex<Option<LogWriter>>> = LazyLock::new(|| Mutex::new(open_writer()));
fn log_path() -> PathBuf {
@ -146,8 +175,34 @@ pub fn file_size(path: &std::path::Path) -> u64 {
fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
/// Identity of whatever file currently lives at `path`, or `None` if nothing
/// does. Compared against the identity captured at open time to detect that
/// our descriptor has been orphaned by a rename or an unlink.
#[cfg(unix)]
fn path_identity(path: &std::path::Path) -> Option<FileIdentity> {
use std::os::unix::fs::MetadataExt;
let meta = fs::metadata(path).ok()?;
Some((meta.dev(), meta.ino()))
}
/// Windows has no comparably cheap stable id from a path stat, so this
/// degrades to presence detection: a deleted log is still healed, a replaced
/// one is not.
#[cfg(not(unix))]
fn path_identity(path: &std::path::Path) -> Option<FileIdentity> {
fs::metadata(path).ok().map(|_| (0, 0))
}
fn open_writer() -> Option<LogWriter> {
let path = log_path();
open_writer_at(log_path())
}
/// Open (creating if needed) a writer for an explicit path.
///
/// Split from [`open_writer`] so a writer re-points at **its own** path when
/// healing a stale handle rather than re-resolving `$GROK_HOME` — which also
/// makes the healing path testable against a temp directory.
fn open_writer_at(path: PathBuf) -> Option<LogWriter> {
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
@ -161,9 +216,11 @@ fn open_writer() -> Option<LogWriter> {
match OpenOptions::new().create(true).append(true).open(&path) {
Ok(file) => Some(LogWriter {
written: file_size(&path),
file,
identity: path_identity(&path),
path,
last_maintenance: Instant::now(),
detached: false,
}),
Err(e) => {
tracing::warn!("[unified_log] failed to open log file: {e}");
@ -172,34 +229,68 @@ fn open_writer() -> Option<LogWriter> {
}
}
impl LogWriter {
/// Re-point at the live file if ours was replaced or removed, and trim if
/// the file has grown past [`MAX_SIZE`].
///
/// The size check reads the **real** file rather than a per-process byte
/// counter. A counter only sees this process's own writes, so several
/// writers sharing one log each believed they were far below the cap while
/// the file sailed past it — orphaned writers observed at 8.5 MB against a
/// 5 MB cap.
///
/// Returns whether the handle is safe to write to: `false` once the file
/// has been replaced or removed and reopening it did not work, so the
/// caller drops the entry instead of appending it somewhere unreadable.
fn maintain(&mut self) -> bool {
if self.last_maintenance.elapsed() < MAINTENANCE_INTERVAL {
return !self.detached;
}
self.last_maintenance = Instant::now();
if path_identity(&self.path) != self.identity {
let Some(reopened) = open_writer_at(self.path.clone()) else {
// Warn on entering the state, not once per tick: a broken log
// directory would otherwise flood the diagnostic output an
// operator is trying to read.
if !self.detached {
tracing::warn!(
path = %self.path.display(),
"[unified_log] log file replaced or removed and reopen failed; \
dropping entries until it can be reopened"
);
self.detached = true;
}
return false;
};
*self = reopened;
return true;
}
// The path resolves to our inode again — either it always did, or a
// transient stat failure cleared.
self.detached = false;
if file_size(&self.path) >= MAX_SIZE {
let _ = self.file.flush();
trim_file(&self.path);
}
true
}
}
fn write_lines(lines: &[u8]) {
let Ok(mut guard) = WRITER.lock() else { return };
let writer = match guard.as_mut() {
Some(w) => w,
None => return,
};
let len = lines.len() as u64;
if let Err(e) = writer.file.write_all(lines) {
tracing::warn!("[unified_log] write failed: {e}");
if !writer.maintain() {
return;
}
writer.written += len;
// Trim under the lock to avoid a race where concurrent writers see stale
// state between drop + re-acquire. Trim is fast (~2.5 MB read+write) and
// this is a low-volume diagnostic log.
if writer.written >= MAX_SIZE {
let _ = writer.file.flush();
trim_file(&writer.path);
if let Ok(new_file) = OpenOptions::new()
.create(true)
.append(true)
.open(&writer.path)
{
writer.file = new_file;
writer.written = file_size(&writer.path);
}
if let Err(e) = writer.file.write_all(lines) {
tracing::warn!("[unified_log] write failed: {e}");
}
}
@ -211,21 +302,89 @@ fn write_entry(entry: &LogEntry) {
write_lines(&line);
}
/// Drop the oldest lines from the file, keeping roughly the last half.
/// Drop the oldest lines from the file, keeping roughly the last half,
/// **preserving the inode**.
///
/// Uses write-to-temp + rename so a crash mid-trim cannot lose the entire log.
/// Rewrites the retained tail at offset 0 and truncates to match. This must
/// not go through temp + rename: every other process holds an `O_APPEND`
/// descriptor on this inode, and swapping a fresh file in underneath them
/// leaves each one appending to an unlinked inode that nothing can read and
/// nothing will ever trim. That failure was silent and unbounded — a single
/// developer machine accumulated roughly 26 MB across six orphaned inodes,
/// several of them past the 5 MB cap, while the visible log held only what
/// the most recent trimming process happened to write. The unified log was
/// therefore blind during the incident it exists to explain.
///
/// Truncating in place trades the rename's crash-atomicity for the far more
/// valuable property that concurrent writers keep working. A crash between
/// the write and the `set_len` leaves the tail followed by stale bytes; for a
/// line-delimited diagnostic log that costs at most a few garbled lines,
/// against losing every sibling's output indefinitely.
///
/// A sibling appending *during* the rewrite may lose that one line to the
/// truncation. The previous implementation lost every line written after the
/// rename, forever.
///
/// The whole read-modify-write is held under an exclusive advisory lock on
/// the log itself, because trimming in place is only safe for one process at
/// a time — see the comment in the body.
///
/// Known limitation: a single line longer than half the file leaves no
/// newline to cut at, and the trim is skipped rather than split that line.
/// The log then stays over its cap until a shorter line arrives.
pub fn trim_file(path: &std::path::Path) {
let Ok(data) = fs::read(path) else { return };
// One trimmer at a time, across processes. Writers decide on the real
// on-disk size, so when the log crosses the cap every process reaches
// this function inside the same maintenance window. Two of them
// interleaving a multi-megabyte rewrite at offset 0 would splice one
// tail into the other; worse, a trimmer that reads while another is
// mid-rewrite sees new-tail-over-old-head and computes its own tail from
// that. Temp + rename was no safer — every process used the same
// `unified.jsonl.tmp` — it was just rarer, because the old per-process
// byte counter meant one process did essentially all the trimming.
//
// `try_lock`, not `lock`: a contended trim is one somebody else is
// already doing, so there is nothing to wait for, and waiting would park
// this process's writer mutex on a foreign process's I/O.
//
// A trimmer that decided to trim just before another one finished will
// find a freshly halved file and halve it again. Losing another half of
// an over-budget diagnostic log is a far cheaper outcome than interleaved
// rewrites, so the size is deliberately not re-checked here: callers
// trim on their own terms and the unit tests trim small files directly.
let Ok(mut file) = OpenOptions::new().read(true).write(true).open(path) else {
return;
};
if file.try_lock().is_err() {
return;
}
let mut data = Vec::new();
if let Err(e) = file.read_to_end(&mut data) {
tracing::warn!("[unified_log] trim read failed: {e}");
return;
}
let half = data.len() / 2;
// Find the first newline after the halfway point so we don't split a line.
let start = match data[half..].iter().position(|&b| b == b'\n') {
Some(pos) => half + pos + 1,
None => return,
};
let tmp = path.with_extension("jsonl.tmp");
if fs::write(&tmp, &data[start..]).is_ok() {
let _ = fs::rename(&tmp, path);
let tail = &data[start..];
// Rewind rather than truncate-on-open: the tail is laid down over the
// head first, and only then is the file shortened, so the retained bytes
// are never absent from disk.
if file.rewind().is_err() {
return;
}
if let Err(e) = file.write_all(tail) {
tracing::warn!("[unified_log] trim rewrite failed: {e}");
return;
}
let _ = file.set_len(tail.len() as u64);
let _ = file.flush();
// The lock is released when `file` drops.
}
// ---------------------------------------------------------------------------
@ -405,6 +564,288 @@ mod tests {
assert!(entry.ctx.is_none());
}
/// The reason this incident was undiagnosable: `trim_file` used to
/// temp+rename, which swaps the inode out from under every other process
/// holding an `O_APPEND` descriptor. Their writes then land in an
/// unlinked inode that no reader can ever see.
#[cfg(unix)]
#[test]
fn trim_file_preserves_the_inode_so_open_handles_survive() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
let mut content = String::new();
for i in 0..10 {
content.push_str(&format!("line {i}\n"));
}
fs::write(&path, &content).unwrap();
let before = fs::metadata(&path).unwrap().ino();
trim_file(&path);
assert_eq!(
fs::metadata(&path).unwrap().ino(),
before,
"trim must rewrite in place; replacing the inode strands every \
sibling process's open log handle",
);
}
/// End-to-end version of the same property: a writer that opened the file
/// *before* a trim must still be able to append to the file a reader sees
/// afterwards.
#[cfg(unix)]
#[test]
fn writes_from_a_handle_opened_before_trim_remain_visible() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
let mut content = String::new();
for i in 0..10 {
content.push_str(&format!("line {i}\n"));
}
fs::write(&path, &content).unwrap();
// A sibling process's writer, opened before the trim happens.
let mut sibling = OpenOptions::new().append(true).open(&path).unwrap();
trim_file(&path);
sibling.write_all(b"after trim\n").unwrap();
sibling.flush().unwrap();
let visible = fs::read_to_string(&path).unwrap();
assert!(
visible.contains("after trim"),
"a handle opened before the trim must keep writing to the live \
file, got: {visible:?}",
);
}
/// `maintain` heals a writer whose file was replaced or deleted behind its
/// back — an older binary still doing temp+rename, an external `rm`, or a
/// `$TMPDIR` reaper.
#[cfg(unix)]
#[test]
fn maintain_reopens_after_the_file_is_replaced() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
fs::write(&path, b"original\n").unwrap();
let mut writer = LogWriter {
file: OpenOptions::new().append(true).open(&path).unwrap(),
identity: path_identity(&path),
path: path.clone(),
// Force the maintenance cadence to fire on the next call.
last_maintenance: Instant::now() - MAINTENANCE_INTERVAL,
detached: false,
};
let original_identity = writer.identity;
// Simulate an older binary's rename-based trim from another process.
let replacement = dir.path().join("replacement.jsonl");
fs::write(&replacement, b"replaced\n").unwrap();
fs::rename(&replacement, &path).unwrap();
assert_ne!(
path_identity(&path),
original_identity,
"test setup: the path must now resolve to a new inode",
);
assert!(
writer.maintain(),
"a writer that successfully re-pointed at the live file is writable",
);
writer.file.write_all(b"after replacement\n").unwrap();
writer.file.flush().unwrap();
let visible = fs::read_to_string(&path).unwrap();
assert!(
visible.contains("after replacement"),
"a writer whose file was replaced must re-point at the live file \
instead of writing into the orphaned inode, got: {visible:?}",
);
assert_eq!(
writer.identity,
path_identity(&path),
"the healed writer must track the new inode",
);
}
/// The same healing path for outright deletion, which is how a
/// `$TMPDIR` reaper (or a stray `rm`) silences a long-lived agent.
#[cfg(unix)]
#[test]
fn maintain_reopens_after_the_file_is_deleted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
fs::write(&path, b"original\n").unwrap();
let mut writer = LogWriter {
file: OpenOptions::new().append(true).open(&path).unwrap(),
identity: path_identity(&path),
path: path.clone(),
last_maintenance: Instant::now() - MAINTENANCE_INTERVAL,
detached: false,
};
fs::remove_file(&path).unwrap();
assert!(
writer.maintain(),
"a writer that successfully re-pointed at the live file is writable",
);
writer.file.write_all(b"after deletion\n").unwrap();
writer.file.flush().unwrap();
let visible = fs::read_to_string(&path).expect("log must be recreated");
assert!(
visible.contains("after deletion"),
"a deleted log must be recreated rather than written into the \
void, got: {visible:?}",
);
}
/// The trim decision must read the real file, not a per-process counter:
/// with several writers sharing one log, each one's own byte count stays
/// far below the cap while the file sails past it.
#[cfg(unix)]
#[test]
fn maintain_trims_growth_this_process_did_not_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
let mut writer = LogWriter {
file: OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap(),
identity: path_identity(&path),
path: path.clone(),
last_maintenance: Instant::now() - MAINTENANCE_INTERVAL,
detached: false,
};
// Someone else fills the log past the cap; this writer wrote nothing.
let line = "x".repeat(1023);
let mut bulk = String::new();
while bulk.len() as u64 <= MAX_SIZE {
bulk.push_str(&line);
bulk.push('\n');
}
fs::write(&path, &bulk).unwrap();
// Rewriting the path in place keeps the inode, so the handle is fine.
assert_eq!(path_identity(&path), writer.identity);
assert!(file_size(&path) >= MAX_SIZE);
assert!(
writer.maintain(),
"trimming does not detach the writer; its handle stays usable",
);
assert!(
file_size(&path) < MAX_SIZE,
"a writer must trim on observed file size, not on its own \
write counter; size is now {}",
file_size(&path),
);
}
/// Trimming in place is only safe for one process at a time, and deciding
/// on the real file size means every writer reaches [`trim_file`] in the
/// same maintenance window once the log crosses the cap. A trimmer that
/// finds the log already being rewritten must leave it alone rather than
/// interleave a second rewrite at offset 0.
#[test]
fn trim_file_yields_to_a_concurrent_trimmer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
let mut content = String::new();
for i in 0..10 {
content.push_str(&format!("line {i}\n"));
}
fs::write(&path, &content).unwrap();
// Stand in for another process midway through its own trim.
let holder = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
holder.lock().expect("test setup: exclusive lock");
trim_file(&path);
// Release before reading: the lock is mandatory on Windows.
drop(holder);
assert_eq!(
fs::read_to_string(&path).unwrap(),
content,
"a contended trim must be skipped, not interleaved with the \
rewrite already in progress",
);
// And it is only deferred, not lost: the next trim proceeds.
trim_file(&path);
assert!(fs::read_to_string(&path).unwrap().len() < content.len());
}
/// The reopen can itself fail — a log directory replaced by a file, a full
/// disk, exhausted descriptors. Appending to the old handle anyway would
/// reproduce the orphaning this module was changed to end, so the writer
/// drops entries until it can reach the real file again.
#[cfg(unix)]
#[test]
fn maintain_stops_writing_when_the_file_cannot_be_reopened() {
let dir = tempfile::tempdir().unwrap();
let log_dir = dir.path().join("logs");
fs::create_dir_all(&log_dir).unwrap();
let path = log_dir.join("test.jsonl");
fs::write(&path, b"original\n").unwrap();
let mut writer = LogWriter {
file: OpenOptions::new().append(true).open(&path).unwrap(),
identity: path_identity(&path),
path: path.clone(),
last_maintenance: Instant::now() - MAINTENANCE_INTERVAL,
detached: false,
};
// Wipe the log's directory and put a regular file in its place, so
// the path no longer resolves to our inode *and* cannot be reopened.
fs::remove_dir_all(&log_dir).unwrap();
fs::write(&log_dir, b"not a directory\n").unwrap();
assert!(
!writer.maintain(),
"a writer that cannot reach the real log must report itself \
unwritable instead of appending into the orphaned inode",
);
assert!(
!writer.maintain(),
"and must stay unwritable between maintenance ticks, not just on \
the tick that discovered the problem",
);
// Healing: once the directory is back, the next tick reopens.
fs::remove_file(&log_dir).unwrap();
writer.last_maintenance = Instant::now() - MAINTENANCE_INTERVAL;
assert!(
writer.maintain(),
"the writer must recover as soon as the path is usable again",
);
writer.file.write_all(b"after recovery\n").unwrap();
writer.file.flush().unwrap();
let visible = fs::read_to_string(&path).unwrap();
assert!(
visible.contains("after recovery"),
"the recovered writer must be attached to the visible file, \
got: {visible:?}",
);
}
#[test]
fn trim_file_keeps_recent_half() {
let dir = tempfile::tempdir().unwrap();

View file

@ -476,6 +476,12 @@ struct LocalTerminalActor {
/// their own to avoid latching the global.
scope: crate::util::ProcessScope,
/// Additional owner: the scope of the session that started this backend, so
/// closing the session reaps its commands without waiting for process exit.
/// Enrolling in both means whichever reaper fires first wins and the other
/// finds a dead group.
session_scope: Option<crate::util::ProcessScope>,
/// Active processes: task_id -> ProcessState
processes: HashMap<String, ProcessState>,
@ -549,12 +555,14 @@ impl LocalTerminalActor {
foreground_block_budget: Duration,
output_file_cap: u64,
scope: crate::util::ProcessScope,
session_scope: Option<crate::util::ProcessScope>,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
) -> Self {
Self {
cmd_rx,
cancel_token,
scope,
session_scope,
shell_env_policy,
processes: HashMap::new(),
completion_waiters: HashMap::new(),
@ -1010,6 +1018,11 @@ impl LocalTerminalActor {
) -> std::sync::Arc<crate::util::ProcessGroup> {
let group = std::sync::Arc::new(group);
self.scope.register(&group);
if let Some(session_scope) = &self.session_scope {
// A closed session scope kills the group here, which is the point:
// a command racing session teardown must not survive it.
session_scope.register(&group);
}
group
}
@ -1304,13 +1317,13 @@ impl LocalTerminalActor {
// Register as a completion waiter and return control to the actor loop.
let timeout = timeout.unwrap_or(Duration::from_secs(30));
let deadline = Instant::now()
.checked_add(timeout)
.unwrap_or_else(Instant::now);
self.completion_waiters
.entry(task_id)
.or_default()
.push(CompletionWaiter {
reply,
deadline: Instant::now() + timeout,
});
.push(CompletionWaiter { reply, deadline });
// Return immediately — actor loop resumes processing other commands.
}
@ -2144,6 +2157,7 @@ struct LocalTerminalConfig {
login_shell_capture: bool,
search_shadows: SearchShadowConfig,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
process_scope: Option<crate::util::ProcessScope>,
}
impl Default for LocalTerminalConfig {
@ -2155,6 +2169,7 @@ impl Default for LocalTerminalConfig {
login_shell_capture: true,
search_shadows: SearchShadowConfig::default(),
shell_env_policy: None,
process_scope: None,
}
}
}
@ -2191,15 +2206,6 @@ impl LocalTerminalBackend {
})
}
/// Create a new LocalTerminalBackend with both memory limits and persistent shell.
pub fn with_memory_limit_and_persistent_shell(config: CgroupMemoryConfig) -> Self {
Self::new_inner(LocalTerminalConfig {
memory_config: Some(config),
persistent_shell: true,
..Default::default()
})
}
/// Create a new LocalTerminalBackend using spawn_local (for single-threaded runtimes).
///
/// `search_shadows` is the host-resolved `find`→`bfs` / `grep`→`ugrep` enable
@ -2216,12 +2222,14 @@ impl LocalTerminalBackend {
search_shadows: SearchShadowConfig,
login_shell_capture: bool,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
process_scope: Option<crate::util::ProcessScope>,
) -> Self {
Self::new_inner(LocalTerminalConfig {
use_spawn_local: true,
login_shell_capture,
search_shadows,
shell_env_policy,
process_scope,
..Default::default()
})
}
@ -2233,21 +2241,14 @@ impl LocalTerminalBackend {
pub fn new_local_with_persistent_shell(
search_shadows: SearchShadowConfig,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
process_scope: Option<crate::util::ProcessScope>,
) -> Self {
Self::new_inner(LocalTerminalConfig {
use_spawn_local: true,
persistent_shell: true,
search_shadows,
shell_env_policy,
..Default::default()
})
}
/// Create a new LocalTerminalBackend using spawn_local with memory limits.
pub fn new_local_with_memory_limit(config: CgroupMemoryConfig) -> Self {
Self::new_inner(LocalTerminalConfig {
memory_config: Some(config),
use_spawn_local: true,
process_scope,
..Default::default()
})
}
@ -2259,6 +2260,7 @@ impl LocalTerminalBackend {
pub(crate) fn new_local_with_scope(
search_shadows: SearchShadowConfig,
scope: crate::util::ProcessScope,
session_scope: Option<crate::util::ProcessScope>,
) -> Self {
Self::new_with_ttl(
None,
@ -2270,6 +2272,7 @@ impl LocalTerminalBackend {
FOREGROUND_BLOCK_BUDGET,
MAX_OUTPUT_FILE_BYTES,
scope,
session_scope,
None,
)
}
@ -2288,6 +2291,7 @@ impl LocalTerminalBackend {
MAX_OUTPUT_FILE_BYTES,
crate::util::global_process_scope().clone(),
None,
None,
)
}
@ -2305,6 +2309,7 @@ impl LocalTerminalBackend {
MAX_OUTPUT_FILE_BYTES,
crate::util::global_process_scope().clone(),
None,
None,
)
}
@ -2322,6 +2327,7 @@ impl LocalTerminalBackend {
output_file_cap,
crate::util::global_process_scope().clone(),
None,
None,
)
}
@ -2333,6 +2339,7 @@ impl LocalTerminalBackend {
login_shell_capture,
search_shadows,
shell_env_policy,
process_scope,
} = config;
Self::new_with_ttl(
memory_config,
@ -2344,6 +2351,7 @@ impl LocalTerminalBackend {
foreground_block_budget_from_env(),
output_file_cap_from_env(),
crate::util::global_process_scope().clone(),
process_scope,
shell_env_policy,
)
}
@ -2358,6 +2366,7 @@ impl LocalTerminalBackend {
foreground_block_budget: Duration,
output_file_cap: u64,
scope: crate::util::ProcessScope,
session_scope: Option<crate::util::ProcessScope>,
shell_env_policy: Option<crate::util::ShellEnvironmentPolicy>,
) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(COMMAND_CHANNEL_SIZE);
@ -2385,6 +2394,7 @@ impl LocalTerminalBackend {
foreground_block_budget,
output_file_cap,
scope,
session_scope,
shell_env_policy,
);
actor.run().await;
@ -4564,6 +4574,7 @@ mod tests {
let backend = LocalTerminalBackend::new_local_with_scope(
SearchShadowConfig::default(),
scope.clone(),
None,
);
let mut bg_req = make_request("sleep 120");
@ -4595,6 +4606,41 @@ mod tests {
});
}
/// A session-scoped command stays enrolled in the base scope too, so the TUI
/// exit paths (which `kill_all()` only the process-global scope, and reach
/// `process::exit` without running `Drop`) still reap it.
#[test]
fn session_scoped_child_is_still_reaped_via_base_scope() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async {
let base = crate::util::ProcessScope::new();
let session = crate::util::ProcessScope::new();
let backend = LocalTerminalBackend::new_local_with_scope(
SearchShadowConfig::default(),
base.clone(),
Some(session),
);
let mut bg_req = make_request("sleep 120");
bg_req.tool_call_id = "bg-dual-scope".to_string();
let bg = backend
.run_background(bg_req)
.await
.expect("background spawn should succeed");
base.kill_all();
assert!(
poll_until_task_completed(&backend, &bg.task_id, Duration::from_secs(10)).await,
"base-scope kill_all did not reap a session-scoped child"
);
});
}
/// Once a background child is reaped, the actor must drop its
/// `Arc<ProcessGroup>` so the scope's `Weak` dies. The completed task lingers
/// in `self.processes` for `COMPLETED_TASK_TTL`; if the actor kept the `Arc`
@ -4612,6 +4658,7 @@ mod tests {
let backend = LocalTerminalBackend::new_local_with_scope(
SearchShadowConfig::default(),
scope.clone(),
None,
);
// A brief sleep (not `true`): it must still be running when we read

View file

@ -333,6 +333,11 @@ pub trait TerminalBackend: Send + Sync {
}
/// Wait for a background task to complete, with optional timeout.
///
/// # Panics / overflow
/// Implementations may add `timeout` to `Instant::now()`. Callers must
/// bound `timeout` (e.g. via `capped_wait_timeout`) so the sum stays
/// representable; unbounded model `timeout_ms` can overflow.
async fn wait_for_completion(
&self,
task_id: &str,

View file

@ -29,7 +29,7 @@ impl crate::types::tool_metadata::ToolMetadata for MonitorTool {
fn description_template(&self) -> &str {
r#"Start a background monitor that streams events from a long-running script. Each stdout line is an event${%- if system_reminders_enabled %} - you can keep working and notifications arrive in the chat${%- endif %}. Exit ends the watch.
**Output volume**: Every stdout line becomes a message in the conversation, so write selective filters. In pipes use `grep --line-buffered` (plain `grep` buffers and delays events by minutes).
**Output volume**: Every stdout line is a main-agent wake. Print only `DONE`/`FAILED`/`CANCELLED`. No progress or CHANGE lines. Use `grep --line-buffered` in pipes (plain `grep` buffers and delays events by minutes).
Set `persistent: true` for session-length watches (PR monitoring, log tails) -- the monitor runs${%- if tools.by_kind.kill_task_action %} until you call ${{ tools.by_kind.kill_task_action }} or${%- endif %} until the session ends. Otherwise it stops at `timeout_ms` (default 10h)."#
}

View file

@ -56,6 +56,86 @@ pub(crate) fn capped_wait_timeout(timeout_ms: Option<u64>) -> Duration {
base.min(max_wait_block())
}
/// The caller's requested wait before capping, or the default when omitted.
fn requested_wait_timeout(timeout_ms: Option<u64>) -> Duration {
timeout_ms
.map(Duration::from_millis)
.unwrap_or(DEFAULT_WAIT_TIMEOUT)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum WaitHint {
NotRequested,
Elapsed {
requested: Duration,
waited: Duration,
},
ReturnedEarly,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum WaitSubject {
Task,
Subagent,
}
impl WaitSubject {
fn noun(self) -> &'static str {
match self {
WaitSubject::Task => "task",
WaitSubject::Subagent => "subagent",
}
}
}
fn still_running_wait_hint(hint: WaitHint, subject: WaitSubject) -> String {
let noun = subject.noun();
let lead = match hint {
WaitHint::Elapsed { requested, waited } => {
let waited_label = format_waited_duration(waited);
if requested > waited {
let requested_label = format_waited_duration(requested);
format!(
"Waited {waited_label}, the per-call maximum, of the {requested_label} you requested; \
the {noun} is still running. You do not need to call this again."
)
} else {
format!("Waited the requested {waited_label}; the {noun} is still running.")
}
}
WaitHint::ReturnedEarly => {
format!("Wait returned early because another finished; this {noun} is still running.")
}
WaitHint::NotRequested => "Use timeout_ms to wait for completion.".to_string(),
};
format!("{lead} You will be notified automatically when the {noun} completes.")
}
fn format_waited_duration(d: Duration) -> String {
let ms = d.as_millis();
if ms < 1000 {
format!("{ms}ms")
} else {
format!("{}s", ms / 1000)
}
}
fn with_still_running_wait_hint(body: String, hint: WaitHint, subject: WaitSubject) -> String {
format!("{body}\n\n{}", still_running_wait_hint(hint, subject))
}
fn apply_running_wait_hint(
mut result: TaskOutputResult,
hint: WaitHint,
subject: WaitSubject,
) -> TaskOutputResult {
if result.status == "running" {
result.output =
with_still_running_wait_hint(std::mem::take(&mut result.output), hint, subject);
}
result
}
pub(crate) fn background_bash_requires_exprs() -> Vec<Expr<ToolRequirement>> {
use crate::types::tool_metadata::ToolMetadata;
let grok_build_bash = Expr::Value(ToolRequirement::Tool {
@ -121,6 +201,14 @@ impl TaskOutputTool {
}
let waits = xai_tool_types::task_output_waits(timeout_ms);
let wait_hint = if waits {
WaitHint::Elapsed {
requested: requested_wait_timeout(timeout_ms),
waited: capped_wait_timeout(timeout_ms),
}
} else {
WaitHint::NotRequested
};
let snapshot = if waits {
// Cap the blocking wait so a large `timeout_ms` can't wedge the turn;
// the model is pinged on completion regardless (see `capped_wait_timeout`).
@ -150,10 +238,10 @@ impl TaskOutputTool {
)
})
.unwrap_or(DEFAULT_TOOL_OUTPUT_BYTES);
return Ok(TaskOutputOutput::Result(snapshot_to_result(
snapshot,
&read_file_name,
max_output_bytes,
return Ok(TaskOutputOutput::Result(apply_running_wait_hint(
snapshot_to_result(snapshot, &read_file_name, max_output_bytes),
wait_hint,
WaitSubject::Task,
)));
}
@ -177,7 +265,7 @@ impl TaskOutputTool {
.query(task_id, waits, query_timeout_ms)
.await
{
return Ok(format_subagent_snapshot(&snapshot));
return Ok(format_subagent_snapshot(&snapshot, wait_hint));
}
// Neither found
@ -209,6 +297,7 @@ impl TaskOutputTool {
tool_name_for_truncation: &str,
) -> Result<TaskOutputOutput, xai_tool_runtime::ToolError> {
let waits = xai_tool_types::task_output_waits(timeout_ms);
let requested = requested_wait_timeout(timeout_ms);
let timeout = capped_wait_timeout(timeout_ms);
let (terminal, backend, read_file_name, max_output_bytes) = {
@ -235,6 +324,7 @@ impl TaskOutputTool {
&backend,
&read_file_name,
max_output_bytes,
WaitHint::NotRequested,
)
.await;
@ -242,20 +332,22 @@ impl TaskOutputTool {
&& (!initial.pending_bash_ids.is_empty() || !initial.pending_subagent_ids.is_empty())
{
let deadline = tokio::time::Instant::now() + timeout;
wait_all_event_driven(
let wait_hint = wait_all_event_driven(
&terminal,
&backend,
&initial.pending_bash_ids,
&initial.pending_subagent_ids,
deadline,
)
.await;
.await
.hint(requested, timeout);
resolve_tasks(
task_ids,
&terminal,
&backend,
&read_file_name,
max_output_bytes,
wait_hint,
)
.await
.results
@ -317,6 +409,7 @@ pub(crate) async fn resolve_tasks(
backend: &Option<SubagentBackendResource>,
read_file_name: &str,
max_output_bytes: usize,
wait_hint: WaitHint,
) -> ResolveResult {
let mut results = Vec::with_capacity(task_ids.len());
let mut pending_bash_ids = Vec::new();
@ -325,7 +418,11 @@ pub(crate) async fn resolve_tasks(
for id in task_ids {
if let Some(snap) = terminal.get_task(id).await {
let is_pending = !snap.completed;
results.push(snapshot_to_result(snap, read_file_name, max_output_bytes));
results.push(apply_running_wait_hint(
snapshot_to_result(snap, read_file_name, max_output_bytes),
wait_hint,
WaitSubject::Task,
));
if is_pending {
pending_bash_ids.push(id.clone());
}
@ -336,7 +433,7 @@ pub(crate) async fn resolve_tasks(
&& let Some(snap) = be.backend().query(id, false, None).await
{
let is_terminal = snap.status.is_terminal();
if let TaskOutputOutput::Result(r) = format_subagent_snapshot(&snap) {
if let TaskOutputOutput::Result(r) = format_subagent_snapshot(&snap, wait_hint) {
if !is_terminal {
pending_subagent_ids.push(id.clone());
}
@ -381,6 +478,32 @@ impl Drop for AbortWaitsOnDrop {
}
}
/// Whether a multi-task wait returned because the deadline was hit or because
/// the wait condition (any/all) completed first.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum WaitOutcome {
DeadlineElapsed,
CompletedEarly,
}
impl WaitOutcome {
pub(crate) fn hint(self, requested: Duration, waited: Duration) -> WaitHint {
match self {
WaitOutcome::DeadlineElapsed => WaitHint::Elapsed { requested, waited },
WaitOutcome::CompletedEarly => WaitHint::ReturnedEarly,
}
}
}
fn finalize_wait_outcome(outcome: WaitOutcome, deadline: tokio::time::Instant) -> WaitOutcome {
// select! is non-deterministic when both arms are ready at the deadline.
if tokio::time::Instant::now() >= deadline {
WaitOutcome::DeadlineElapsed
} else {
outcome
}
}
/// Wait until any one task (bash or subagent) completes, or deadline is reached.
pub(crate) async fn wait_any_event_driven(
terminal: &std::sync::Arc<dyn crate::computer::types::TerminalBackend>,
@ -388,10 +511,10 @@ pub(crate) async fn wait_any_event_driven(
bash_ids: &[String],
subagent_ids: &[String],
deadline: tokio::time::Instant,
) {
) -> WaitOutcome {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return;
return WaitOutcome::DeadlineElapsed;
}
// Register waiter BEFORE spawns to avoid race: a spawned task could complete
@ -436,10 +559,11 @@ pub(crate) async fn wait_any_event_driven(
// deadline, or cancellation of this future.
let _guard = AbortWaitsOnDrop(waits);
tokio::select! {
_ = notified => {}
_ = tokio::time::sleep_until(deadline) => {}
}
let outcome = tokio::select! {
_ = notified => WaitOutcome::CompletedEarly,
_ = tokio::time::sleep_until(deadline) => WaitOutcome::DeadlineElapsed,
};
finalize_wait_outcome(outcome, deadline)
}
/// Wait until all tasks (bash and subagent) complete, or deadline is reached.
@ -449,10 +573,10 @@ pub(crate) async fn wait_all_event_driven(
bash_ids: &[String],
subagent_ids: &[String],
deadline: tokio::time::Instant,
) {
) -> WaitOutcome {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return;
return WaitOutcome::DeadlineElapsed;
}
let mut handles: Vec<tokio::task::JoinHandle<()>> = Vec::new();
@ -482,10 +606,11 @@ pub(crate) async fn wait_all_event_driven(
let _guard = AbortWaitsOnDrop(handles.iter().map(|h| h.abort_handle()).collect());
let all_fut = futures_util::future::join_all(handles);
tokio::select! {
_ = all_fut => {}
_ = tokio::time::sleep_until(deadline) => {}
}
let outcome = tokio::select! {
_ = all_fut => WaitOutcome::CompletedEarly,
_ = tokio::time::sleep_until(deadline) => WaitOutcome::DeadlineElapsed,
};
finalize_wait_outcome(outcome, deadline)
}
//
@ -502,20 +627,21 @@ fn render_legacy_task_output_not_found(task_id: &str) -> String {
format!("Task {} not found", task_id)
}
fn format_subagent_snapshot(snap: &SubagentSnapshot) -> TaskOutputOutput {
fn format_subagent_snapshot(snap: &SubagentSnapshot, wait_hint: WaitHint) -> TaskOutputOutput {
let started = format_epoch_ms_as_rfc3339(snap.started_at_epoch_ms);
match &snap.status {
SubagentSnapshotStatus::Initializing => {
let duration_secs = snap.duration_ms as f64 / 1000.0;
let output = format!(
// Measure body only — wait-hint is harness advisory, not task output.
let body = format!(
"Subagent is initializing (creating worktree, resolving config).\n\
Type: {}\n\
Description: {}\n\
Elapsed: {duration_secs:.1}s\n\n\
Use timeout_ms to wait for completion.",
Elapsed: {duration_secs:.1}s",
snap.subagent_type, snap.description,
);
let raw_output_bytes = output.len();
let raw_output_bytes = body.len();
let output = with_still_running_wait_hint(body, wait_hint, WaitSubject::Subagent);
TaskOutputOutput::Result(TaskOutputResult {
task_id: snap.subagent_id.clone(),
command: format!("[subagent:{}] {}", snap.subagent_type, snap.description),
@ -547,7 +673,8 @@ fn format_subagent_snapshot(snap: &SubagentSnapshot) -> TaskOutputOutput {
};
let tokens_k = tokens_used / 1000;
let capacity_k = context_window_tokens / 1000;
let output = format!(
// Measure body only — wait-hint is harness advisory, not task output.
let body = format!(
"Subagent is still running.\n\
Type: {}\n\
Description: {}\n\
@ -555,13 +682,13 @@ fn format_subagent_snapshot(snap: &SubagentSnapshot) -> TaskOutputOutput {
Progress: turn {turn_count}, {tool_call_count} tool calls, \
{tokens_k}K/{capacity_k}K tokens ({context_usage_pct}% context)\n\
Tools used: {tools_str}\n\
Errors: {error_count}\n\n\
Use timeout_ms to wait for completion.",
Errors: {error_count}",
snap.subagent_type,
snap.description,
snap.duration_ms as f64 / 1000.0,
);
let raw_output_bytes = output.len();
let raw_output_bytes = body.len();
let output = with_still_running_wait_hint(body, wait_hint, WaitSubject::Subagent);
TaskOutputOutput::Result(TaskOutputResult {
task_id: snap.subagent_id.clone(),
command: format!("[subagent:{}] {}", snap.subagent_type, snap.description),
@ -960,19 +1087,79 @@ mod tests {
// unbounded blocking wait wedged the turn for hours).
#[test]
fn capped_wait_timeout_clamps_and_defaults() {
// Omitted -> default 30s.
assert_eq!(capped_wait_timeout(None), DEFAULT_WAIT_TIMEOUT);
// Small value -> unchanged.
assert_eq!(
capped_wait_timeout(Some(5_000)),
Duration::from_millis(5_000)
);
// Huge value (10h) -> clamped to the cap.
assert_eq!(capped_wait_timeout(Some(36_000_000)), MAX_WAIT_BLOCK);
// Exactly at the cap (10m) -> unchanged.
assert_eq!(capped_wait_timeout(Some(600_000)), MAX_WAIT_BLOCK);
}
#[test]
fn still_running_wait_hint_omitted_invites_timeout_ms() {
assert_eq!(
still_running_wait_hint(WaitHint::NotRequested, WaitSubject::Task),
"Use timeout_ms to wait for completion. You will be notified automatically when the task completes."
);
assert_eq!(
still_running_wait_hint(WaitHint::NotRequested, WaitSubject::Subagent),
"Use timeout_ms to wait for completion. You will be notified automatically when the subagent completes."
);
}
#[test]
fn still_running_wait_hint_within_cap_reports_auto_wake() {
let hint = WaitHint::Elapsed {
requested: Duration::from_secs(30),
waited: Duration::from_secs(30),
};
assert_eq!(
still_running_wait_hint(hint, WaitSubject::Task),
"Waited the requested 30s; the task is still running. \
You will be notified automatically when the task completes."
);
assert_eq!(
still_running_wait_hint(hint, WaitSubject::Subagent),
"Waited the requested 30s; the subagent is still running. \
You will be notified automatically when the subagent completes."
);
}
#[test]
fn still_running_wait_hint_clamped_reports_cap_and_auto_wake() {
let hint = WaitHint::Elapsed {
requested: Duration::from_secs(2_400),
waited: Duration::from_secs(600),
};
assert_eq!(
still_running_wait_hint(hint, WaitSubject::Task),
"Waited 600s, the per-call maximum, of the 2400s you requested; \
the task is still running. You do not need to call this again. \
You will be notified automatically when the task completes."
);
assert_eq!(
still_running_wait_hint(hint, WaitSubject::Subagent),
"Waited 600s, the per-call maximum, of the 2400s you requested; \
the subagent is still running. You do not need to call this again. \
You will be notified automatically when the subagent completes."
);
}
#[test]
fn still_running_wait_hint_returned_early_is_honest() {
assert_eq!(
still_running_wait_hint(WaitHint::ReturnedEarly, WaitSubject::Task),
"Wait returned early because another finished; this task is still running. \
You will be notified automatically when the task completes."
);
assert_eq!(
still_running_wait_hint(WaitHint::ReturnedEarly, WaitSubject::Subagent),
"Wait returned early because another finished; this subagent is still running. \
You will be notified automatically when the subagent completes."
);
}
#[test]
fn tool_name_and_description() {
let tool = TaskOutputTool;
@ -1661,7 +1848,7 @@ mod tests {
started_at_epoch_ms: 1_700_000_000_000,
duration_ms: 8_500,
};
let result = format_subagent_snapshot(&snap);
let result = format_subagent_snapshot(&snap, WaitHint::NotRequested);
match result {
TaskOutputOutput::Result(r) => {
assert_eq!(r.task_id, "sub-init");
@ -1711,7 +1898,7 @@ mod tests {
started_at_epoch_ms: 1_700_000_000_000,
duration_ms: 12_500,
};
let result = format_subagent_snapshot(&snap);
let result = format_subagent_snapshot(&snap, WaitHint::NotRequested);
match result {
TaskOutputOutput::Result(r) => {
assert_eq!(r.task_id, "sub-abc");
@ -1756,6 +1943,65 @@ mod tests {
}
}
// raw_output_bytes is body-only so identical Running state is stable across WaitHints.
#[test]
fn format_running_subagent_raw_output_bytes_stable_across_wait_hints() {
let snap = SubagentSnapshot {
subagent_id: "sub-stable".to_string(),
description: "stable body".to_string(),
subagent_type: "explore".to_string(),
persona: None,
status: SubagentSnapshotStatus::Running {
turn_count: 1,
tool_call_count: 2,
tokens_used: 3_000,
context_window_tokens: 128_000,
context_usage_pct: 2,
tools_used: vec!["bash".to_string()],
error_count: 0,
},
started_at_epoch_ms: 1_700_000_000_000,
duration_ms: 1_000,
};
let expected_body = format!(
"Subagent is still running.\n\
Type: {}\n\
Description: {}\n\
Elapsed: {:.1}s\n\
Progress: turn 1, 2 tool calls, \
3K/128K tokens (2% context)\n\
Tools used: bash\n\
Errors: 0",
snap.subagent_type,
snap.description,
snap.duration_ms as f64 / 1000.0,
);
let not_requested = match format_subagent_snapshot(&snap, WaitHint::NotRequested) {
TaskOutputOutput::Result(r) => r,
other => panic!("Expected Result, got {:?}", other),
};
let clamped = match format_subagent_snapshot(
&snap,
WaitHint::Elapsed {
requested: Duration::from_secs(2_400),
waited: Duration::from_secs(600),
},
) {
TaskOutputOutput::Result(r) => r,
other => panic!("Expected Result, got {:?}", other),
};
assert_eq!(not_requested.raw_output_bytes, expected_body.len());
assert_eq!(clamped.raw_output_bytes, expected_body.len());
assert_eq!(not_requested.raw_output_bytes, clamped.raw_output_bytes);
assert!(not_requested.output.starts_with(&expected_body));
assert!(clamped.output.starts_with(&expected_body));
assert_ne!(
not_requested.output.len(),
clamped.output.len(),
"hint variants must still produce different formatted output"
);
}
#[test]
fn format_running_subagent_with_no_tools_shows_none_yet() {
let snap = SubagentSnapshot {
@ -1775,7 +2021,7 @@ mod tests {
started_at_epoch_ms: 1_700_000_000_000,
duration_ms: 500,
};
let result = format_subagent_snapshot(&snap);
let result = format_subagent_snapshot(&snap, WaitHint::NotRequested);
match result {
TaskOutputOutput::Result(r) => {
assert!(

View file

@ -10,7 +10,7 @@
use crate::DEFAULT_TOOL_OUTPUT_BYTES;
use crate::implementations::grok_build::task::backend::SubagentBackendResource;
use crate::implementations::grok_build::task_output::{
MAX_MULTI_WAIT_IDS, TaskOutputTool, resolve_tasks, wait_any_event_driven,
MAX_MULTI_WAIT_IDS, TaskOutputTool, WaitHint, resolve_tasks, wait_any_event_driven,
};
use crate::types::requirements::{Expr, ToolRequirement};
use crate::types::resources::{Terminal, TruncationCfg};
@ -169,6 +169,10 @@ impl xai_tool_runtime::Tool for WaitTasksTool {
}
// wait_any: keep legacy event-driven path (not exposed on get_task_output).
let requested = input
.timeout_ms
.map(std::time::Duration::from_millis)
.unwrap_or(super::DEFAULT_WAIT_TIMEOUT);
let timeout =
crate::implementations::grok_build::task_output::capped_wait_timeout(input.timeout_ms);
@ -198,6 +202,7 @@ impl xai_tool_runtime::Tool for WaitTasksTool {
&backend,
&read_file_name,
max_output_bytes,
WaitHint::NotRequested,
)
.await;
@ -206,20 +211,22 @@ impl xai_tool_runtime::Tool for WaitTasksTool {
let results = if has_pending {
let deadline = tokio::time::Instant::now() + timeout;
wait_any_event_driven(
let wait_hint = wait_any_event_driven(
&terminal,
&backend,
&initial.pending_bash_ids,
&initial.pending_subagent_ids,
deadline,
)
.await;
.await
.hint(requested, timeout);
resolve_tasks(
&input.task_ids,
&terminal,
&backend,
&read_file_name,
max_output_bytes,
wait_hint,
)
.await
.results

View file

@ -1,7 +1,7 @@
[package]
license = "Apache-2.0"
name = "xai-grok-version"
version = "0.2.112"
version = "0.2.114"
edition.workspace = true
description = "Lockstepped grok CLI version."

View file

@ -140,6 +140,143 @@ pub fn detach_std_command(cmd: &mut std::process::Command) {
}
}
// ---------------------------------------------------------------------------
// Parent-death binding — Linux PR_SET_PDEATHSIG
// ---------------------------------------------------------------------------
/// The `pre_exec` body for [`kill_on_parent_death_std`]: arm `PR_SET_PDEATHSIG`
/// and close the classic pdeathsig race (parent died between `fork` and
/// `prctl`, so the signal will never fire) by comparing `getppid()` against
/// the pid captured at spawn time.
///
/// In debug builds this also enforces that the command was **armed on the
/// thread that spawns it**: pdeathsig binds to the death of the spawning
/// thread, so a cross-thread arm+spawn would silently bind the child to a
/// different thread's lifetime than the arming site reasoned about. The
/// guard returns `Err(EINVAL)` — surfaced by `spawn()` as an
/// `InvalidInput` error — rather than panicking, because this closure runs
/// post-fork where unwinding is not async-signal-safe;
/// `io::Error::from_raw_os_error` is allocation-free.
///
/// # Safety
///
/// Must only be called inside a `pre_exec` hook (between `fork` and `exec`):
/// it calls only async-signal-safe libc functions (`prctl`, `getppid`,
/// `_exit`) and its error paths build errors via `from_raw_os_error` /
/// `last_os_error` — never `io::Error::new`/`other`, which allocate. The
/// debug-only thread guard reads `std::thread::current().id()` from the
/// fork-copied TLS of the spawning thread; that handle is lazily created,
/// so in the (rare) case the spawning thread never materialized it this
/// can allocate — accepted for a debug-only misuse guard.
#[cfg(target_os = "linux")]
fn bind_to_parent_death(parent_pid: u32, armed_thread: std::thread::ThreadId) -> io::Result<()> {
// Post-fork, TLS is a copy of the SPAWNING thread's, so this observes
// which thread called `spawn()`.
if cfg!(debug_assertions) && std::thread::current().id() != armed_thread {
return Err(io::Error::from_raw_os_error(libc::EINVAL));
}
// SAFETY: prctl(PR_SET_PDEATHSIG, …) only sets the calling process's
// parent-death signal; it reads/writes no caller memory.
if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM as libc::c_ulong) } == -1 {
return Err(io::Error::last_os_error());
}
// Parent already gone (pdeathsig can no longer fire): exit instead of
// orphaning. A reparented child sees a ppid different from the pid the
// spawn site captured.
// SAFETY: getppid/_exit are async-signal-safe and take no pointers.
if unsafe { libc::getppid() } as u32 != parent_pid {
unsafe { libc::_exit(0) };
}
Ok(())
}
/// Bind the child's lifetime to the spawning process: on Linux the kernel
/// delivers `SIGTERM` to the child when the parent dies
/// (`PR_SET_PDEATHSIG`), so helper processes cannot outlive a crashed or
/// killed grok and pile up on shared hosts. No-op on non-Linux platforms
/// (macOS and Windows have no pdeathsig equivalent).
///
/// **Caveat: pdeathsig binds to the death of the spawning *thread*, not
/// the process — arm and `spawn()` on a thread that lives as long as the
/// parent process.** Debug builds enforce arm-thread == spawn-thread: a
/// mismatch fails the `spawn()` with `InvalidInput` (`EINVAL`).
///
/// **Opt-in.** Only use this for helpers that are useless without their
/// parent (idle inhibitors, protocol children speaking over inherited
/// pipes). Never apply it to processes designed to outlive the client —
/// leader daemons, workspace servers, backgrounded user tasks.
///
/// Composable with [`detach_std_command`]: `pre_exec` hooks run in
/// registration order, and `setsid`/`setpgid` do not clear the parent-death
/// signal, so this can be applied before or after a `detach_*` helper.
///
/// Further Linux caveat: the kernel clears the setting across a
/// setuid/setcap `execve`.
pub fn kill_on_parent_death_std(cmd: &mut std::process::Command) {
#[cfg(target_os = "linux")]
{
use std::os::unix::process::CommandExt;
let parent_pid = std::process::id();
let armed_thread = std::thread::current().id();
// SAFETY: bind_to_parent_death calls only async-signal-safe libc
// functions and builds errors without allocating (see its docs for
// the debug-only TLS read). Satisfies the pre_exec contract.
unsafe {
cmd.pre_exec(move || bind_to_parent_death(parent_pid, armed_thread));
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = cmd;
}
}
/// Bind the *current* process's lifetime to its parent: on Linux, arm
/// `PR_SET_PDEATHSIG(SIGTERM)` so this process is terminated when the
/// process that spawned it dies. No-op elsewhere.
///
/// This is the child-side variant of [`kill_on_parent_death_std`] for protocol
/// servers whose parents are not spawned from this workspace (IDE clients,
/// the agent SDKs, `grok-desktop` all spawn `grok agent … stdio`): the
/// child arms the binding itself at startup instead of relying on every
/// external spawner to.
///
/// Unlike the spawn-time helper there is no ppid race check: a direct
/// parent at pid 1 is legitimate here (containers where the client is PID
/// 1), so an already-dead parent is indistinguishable from that case. The
/// caller's stdin-EOF handling covers the parent-died-before-arm race —
/// dead parent means closed pipes.
///
/// The binding keys off the death of the **parent's thread that spawned
/// this process** — a property of the spawner that the child can neither
/// inspect nor enforce (unlike [`kill_on_parent_death_std`], whose debug guard
/// runs in the spawner). External spawners that fork protocol children
/// from short-lived worker threads will see the signal early; for the
/// stdio entrypoints this is equivalent to the parent closing the pipes.
///
/// # Errors
///
/// Returns the `prctl` errno on Linux when the arm fails; the process then
/// keeps its previous lifetime semantics (stdin-EOF only), so callers
/// should log the failure. This crate stays logging-free by design —
/// surfacing the result is the observable seam. Always `Ok(())` on
/// non-Linux platforms (no-op).
///
/// **Opt-in.** Only call from entrypoints that are useless without the
/// process that spawned them (e.g. stdio transports over inherited pipes).
/// Never from daemons designed to outlive their spawner.
pub fn kill_current_process_on_parent_death() -> io::Result<()> {
#[cfg(target_os = "linux")]
{
// SAFETY: prctl(PR_SET_PDEATHSIG, …) only sets the calling process's
// parent-death signal; it reads/writes no caller memory.
if unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM as libc::c_ulong) } == -1 {
return Err(io::Error::last_os_error());
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Process group lifecycle
// ---------------------------------------------------------------------------
@ -644,6 +781,197 @@ mod tests {
new_process_group(&mut cmd);
}
#[test]
fn kill_on_parent_death_std_does_not_panic() {
let mut cmd = std::process::Command::new("echo");
kill_on_parent_death_std(&mut cmd);
}
/// Debug builds enforce the top-of-doc caveat that arming and spawning
/// happen on the same (long-lived) thread — pdeathsig binds to the
/// spawning thread's lifetime, so a cross-thread arm+spawn must fail
/// the spawn with `InvalidInput` (`EINVAL` from the pre_exec guard)
/// instead of silently binding to the wrong thread. The same-thread
/// happy path is covered by `armed_child_survives_while_parent_lives`.
#[cfg(all(target_os = "linux", debug_assertions))]
#[test]
fn cross_thread_arming_fails_spawn_in_debug_builds() {
let mut cmd = std::thread::spawn(|| {
let mut cmd = std::process::Command::new("true");
kill_on_parent_death_std(&mut cmd);
cmd
})
.join()
.expect("arming thread");
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let error = cmd
.spawn()
.expect_err("cross-thread arm+spawn must fail in debug builds");
assert_eq!(
error.kind(),
std::io::ErrorKind::InvalidInput,
"expected the EINVAL thread guard, got: {error}"
);
}
// ── parent-death binding integration tests (Linux) ──────────
//
// The scenario needs a real intermediate parent process, so the test
// binary re-execs itself (the `stderr_redirect_roundtrip_subprocess`
// pattern): the driver spawns `pdeathsig_intermediate_entry`, which
// spawns a long-sleeping grandchild armed with the helper and exits;
// the driver then asserts the grandchild dies with it.
/// Env marker dispatching the re-exec'd test binary into the
/// intermediate-parent logic.
#[cfg(target_os = "linux")]
const PDEATHSIG_INTERMEDIATE_ENV: &str = "__XAI_TTY_UTILS_PDEATHSIG_INTERMEDIATE";
/// Intermediate parent: spawn the armed grandchild, report its pid on
/// stdout, linger briefly so the driver can observe it alive, then exit
/// (which must take the grandchild down via pdeathsig).
#[cfg(target_os = "linux")]
#[test]
fn pdeathsig_intermediate_entry() {
if std::env::var_os(PDEATHSIG_INTERMEDIATE_ENV).is_none() {
return; // skip when not invoked as the re-exec'd intermediate
}
let mut cmd = std::process::Command::new("sleep");
cmd.arg("300")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// Composability under test: detach (setsid) plus parent-death
// binding on the same command, in the documented order.
detach_std_command(&mut cmd);
kill_on_parent_death_std(&mut cmd);
let child = cmd.spawn().expect("spawn armed grandchild");
println!("grandchild:{}", child.id());
// Do not reap: the grandchild must outlive this handle and die only
// via pdeathsig when this process exits.
std::mem::forget(child);
std::thread::sleep(std::time::Duration::from_millis(1000));
}
/// A child spawned with [`kill_on_parent_death_std`] must not outlive
/// its parent: the kernel SIGTERMs it when the parent exits.
#[cfg(target_os = "linux")]
#[test]
fn armed_child_dies_when_parent_exits() {
let exe = std::env::current_exe().expect("current_exe");
let mut cmd = std::process::Command::new(exe);
cmd.arg("--exact")
.arg("tests::pdeathsig_intermediate_entry")
.arg("--nocapture")
.arg("--test-threads=1")
.env(PDEATHSIG_INTERMEDIATE_ENV, "1")
// The intermediate is a fresh libtest run of exactly one filtered
// test. Strip Bazel's per-shard env so a `shard_count` build can't
// partition that single test into another shard (running zero
// tests), and drop any inherited filter.
.env_remove("TEST_SHARD_INDEX")
.env_remove("TEST_TOTAL_SHARDS")
.env_remove("TEST_SHARD_STATUS_FILE")
.env_remove("TESTBRIDGE_TEST_ONLY")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
let mut intermediate = cmd.spawn().expect("spawn intermediate test process");
// Grandchild pid from the intermediate's stdout. Substring-match, not
// line-prefix parsing: with `--nocapture` libtest prints the
// `test tests::… ... ` header WITHOUT a trailing newline, so the
// reported pid shares its line with harness chrome.
let stdout = intermediate.stdout.take().expect("piped stdout");
let mut reader = std::io::BufReader::new(stdout);
let mut seen: Vec<String> = Vec::new();
let grandchild_pid = loop {
use std::io::BufRead as _;
let mut line = String::new();
let n = reader
.read_line(&mut line)
.expect("read intermediate stdout");
assert_ne!(
n, 0,
"intermediate exited without reporting a grandchild; stdout seen: {seen:?}"
);
if let Some(idx) = line.find("grandchild:") {
let digits: String = line[idx + "grandchild:".len()..]
.chars()
.take_while(char::is_ascii_digit)
.collect();
break digits.parse::<i32>().unwrap_or_else(|e| {
panic!("parse grandchild pid from {line:?}: {e}");
});
}
seen.push(line);
};
// `kill(pid, 0)` succeeds on zombies, and under a non-reaping
// subreaper (e.g. a test process-wrapper) the orphaned grandchild can
// linger as a zombie after the SIGTERM. Treat zombie as dead: the
// signal did its job.
let alive = |pid: i32| match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Err(_) => false,
Ok(stat) => {
// Field 3 (state) is the first token after the last ')' —
// comm can itself contain ')'.
let state = stat
.rsplit_once(')')
.and_then(|(_, rest)| rest.trim_start().chars().next());
state != Some('Z')
}
};
assert!(
alive(grandchild_pid),
"grandchild should be running while its parent is alive"
);
let status = intermediate.wait().expect("wait intermediate");
assert!(status.success(), "intermediate test run failed: {status:?}");
// Parent gone — the armed grandchild must be SIGTERMed by the kernel
// (orphan → reparent → reap → ESRCH). Poll with a deadline.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while alive(grandchild_pid) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(
!alive(grandchild_pid),
"grandchild pid {grandchild_pid} outlived its parent despite \
kill_on_parent_death_std (PR_SET_PDEATHSIG not effective)"
);
}
/// The binding must be one-directional: a live parent keeps its armed
/// child alive (no false-positive from the ppid race check or from
/// composing with `detach_std_command`).
#[cfg(target_os = "linux")]
#[test]
fn armed_child_survives_while_parent_lives() {
let mut cmd = std::process::Command::new("sleep");
cmd.arg("60")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
detach_std_command(&mut cmd);
kill_on_parent_death_std(&mut cmd);
let mut child = cmd.spawn().expect("spawn armed child");
// The binding must not kill a child whose parent (this process) is
// alive and whose ppid matches the captured pid.
std::thread::sleep(std::time::Duration::from_millis(300));
assert!(
child.try_wait().expect("try_wait").is_none(),
"armed child died even though its parent is still alive"
);
child.kill().expect("kill child");
child.wait().expect("reap child");
}
fn wsl_env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()