Synced from monorepo

Synced from monorepo

Changes:
- Workspace task snapshots only list incomplete backgrounded tasks
- Quiet auth, LSP, and config warnings in the shell
- Fix observability attributes for warm store errors, restore setup, remote tools, and preview denials
- Fail closed when soak metrics are missing
- Run plan-mode exit last in mixed tool batches
- Allow /loop to store prompts that can terminate the loop
- Make subagent maximum nesting depth configurable
- Security: apply sandbox profile to the leader process that executes tools

Source-Revision: 1adcd1f477870e4a97bacbd6be78c8a3bfbac46d
This commit is contained in:
grokkybara[bot] 2026-07-27 17:54:34 +00:00
commit 02d9359435
96 changed files with 2346 additions and 351 deletions

View file

@ -304,6 +304,13 @@ pub(super) fn handle_settings_update(notif: &acp::ExtNotification, app: &mut App
}
}
// `scheduler_background_loops` is deliberately absent from this handler,
// unlike the flags above. A live session's scheduled fires keep the mode
// the shell pinned when the session's actor spawned, so applying a pushed
// flip here would make `/loop` promise a runtime those fires never get.
// The per-session value arrives on the `session/new` / `session/load`
// response instead (`AgentView::scheduler_background_loops`).
// Re-resolve tips from config layers + the updated remote tips.
if let Some(remote_tips) = update.tips {
use xai_grok_shell::util::config::resolve_tips;

View file

@ -1817,6 +1817,7 @@ pub(super) fn task_completed_notif(
explicitly_killed: false,
owner_session_id: None,
description: None,
is_backgrounded: false,
},
will_wake,
},

View file

@ -1042,6 +1042,7 @@
restore_summary: None,
restore_degree: None,
running_prompt_id: Some("p-run".to_string()),
scheduler_background_loops: None,
}),
&mut app,
);
@ -1167,6 +1168,7 @@
restore_summary: None,
restore_degree: None,
running_prompt_id: Some("task-completed-abc-123".to_string()),
scheduler_background_loops: None,
}),
&mut app,
);
@ -1207,6 +1209,7 @@
restore_summary: None,
restore_degree: None,
running_prompt_id: Some(pid.to_string()),
scheduler_background_loops: None,
}),
&mut app,
);
@ -1244,6 +1247,7 @@
restore_summary: None,
restore_degree: None,
running_prompt_id: Some("p-run".to_string()),
scheduler_background_loops: None,
}),
&mut app,
);
@ -1277,6 +1281,7 @@
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -2526,6 +2531,7 @@
restore_summary: None,
restore_degree: None,
running_prompt_id: Some("p-run".to_string()),
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -134,6 +134,134 @@
assert!(!app.voice_mode_enabled);
}
/// Build an `x.ai/settings/update` carrying only the scheduler flag.
fn scheduler_background_loops_update(value: bool) -> acp::ExtNotification {
acp::ExtNotification::new(
"x.ai/settings/update",
std::sync::Arc::from(
serde_json::value::to_raw_value(&serde_json::json!({
"scheduler_background_loops": value
}))
.unwrap(),
),
)
}
/// Drain the `/loop` instruction the pager actually stored for a session.
fn loop_instruction(app: &mut AppView, args: &str) -> String {
use crate::app::actions::Action;
// `/loop` is `required_tools()`-gated and the registry fails closed
// until the toolset is advertised, so a bare test agent never reaches
// the command.
if let Some(agent) = app.agents.get_mut(&AgentId(0)) {
agent
.prompt
.slash_controller
.registry_mut()
.set_available_tools(
[xai_grok_tools::implementations::grok_build::SCHEDULER_CREATE_TOOL_NAME]
.into_iter()
.map(str::to_string)
.collect(),
);
}
let effects =
crate::app::dispatch::dispatch(Action::SendPrompt(format!("/loop {args}")), app);
let blocks = effects
.iter()
.find_map(|e| match e {
Effect::SendPromptBlocks { blocks, .. } => Some(blocks),
_ => None,
})
.expect("/loop must enqueue an instruction and drain it");
match &blocks[0] {
acp::ContentBlock::Text(text) => text.text.clone(),
other => panic!("expected a text prompt block, got {other:?}"),
}
}
/// `/loop`'s wording must describe THIS session's fires. The shell pins the
/// fire mode when a session's actor spawns, so a mid-session settings push
/// carrying the opposite value must not change the instruction: describing
/// detached fires as in-session drops the self-contained state those fires
/// need.
#[test]
fn loop_fire_mode_follows_session_not_later_settings_push() {
use crate::app::actions::{Action, TaskResult};
use xai_grok_tools::implementations::grok_build::{
LoopFireMode, loop_schedule_instruction,
};
let mut app = make_app_with_agent("sess-loop");
// Seed says detached; only the session's own answer can produce the
// in-session wording asserted below.
app.scheduler_background_loops_seed = true;
crate::app::dispatch::dispatch(
Action::TaskComplete(TaskResult::SessionCreated {
agent_id: AgentId(0),
session_id: acp::SessionId::new("sess-loop"),
models: None,
scheduler_background_loops: Some(false),
}),
&mut app,
);
assert!(handle_ext_notification(
&scheduler_background_loops_update(true),
&mut app
));
assert_eq!(
loop_instruction(&mut app, "5m check ci"),
loop_schedule_instruction("5m check ci", LoopFireMode::InSession),
"a pushed flip must not re-describe fires this session already pinned"
);
}
/// The value is session-scoped, not frozen for the process: resuming a
/// session adopts the mode that resume's spawn pinned.
#[test]
fn loop_fire_mode_adopts_the_loaded_session_value() {
use crate::app::actions::{Action, TaskResult};
use xai_grok_tools::implementations::grok_build::{
LoopFireMode, loop_schedule_instruction,
};
let mut app = make_app_with_agent("sess-loop-load");
// Opposite of both the seed and the pre-resume value, so only the load
// response can produce the detached wording asserted below.
app.scheduler_background_loops_seed = false;
crate::app::dispatch::dispatch(
Action::TaskComplete(TaskResult::SessionCreated {
agent_id: AgentId(0),
session_id: acp::SessionId::new("sess-loop-load"),
models: None,
scheduler_background_loops: Some(false),
}),
&mut app,
);
crate::app::dispatch::dispatch(
Action::TaskComplete(TaskResult::SessionLoaded {
agent_id: AgentId(0),
session_id: acp::SessionId::new("sess-loop-load"),
models: None,
code_restored: false,
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: Some(true),
}),
&mut app,
);
assert_eq!(
loop_instruction(&mut app, "5m check ci"),
loop_schedule_instruction("5m check ci", LoopFireMode::Detached),
"resume must adopt the value its own spawn pinned"
);
}
#[test]
fn settings_update_clearing_group_tool_verbs_reverts_to_default() {
// Expected values come from the same chain the handler resolves, so the

View file

@ -1277,6 +1277,7 @@
restore_summary: None,
restore_degree: None,
running_prompt_id: Some("p-run".to_string()),
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -2163,6 +2163,12 @@ pub enum TaskResult {
agent_id: AgentId,
session_id: acp::SessionId,
models: Option<acp::SessionModelState>,
/// Whether this session's scheduled fires run detached, as the shell
/// resolved it at spawn (response
/// `_meta["x.ai/schedulerBackgroundLoops"]`). `None` from a shell that
/// predates the key. See
/// [`crate::app::effects::parse_session_scheduler_background_loops`].
scheduler_background_loops: Option<bool>,
},
/// Session creation failed.
SessionFailed {
@ -2178,6 +2184,8 @@ pub enum TaskResult {
/// Effective cwd inside the worktree (preserves subdirectory offset).
session_cwd: std::path::PathBuf,
models: Option<acp::SessionModelState>,
/// See [`TaskResult::SessionCreated::scheduler_background_loops`].
scheduler_background_loops: Option<bool>,
},
/// Worktree created and session forked, but not yet loaded.
/// The dispatch handler sets session_id eagerly, then emits LoadSession.
@ -2209,6 +2217,10 @@ pub enum TaskResult {
/// pass the live `session/update` gate without re-rendering the user
/// block (replay already rendered it).
running_prompt_id: Option<String>,
/// See [`TaskResult::SessionCreated::scheduler_background_loops`]. A
/// resumed session re-spawns its actor, so the load response carries
/// the value that spawn just pinned.
scheduler_background_loops: Option<bool>,
},
/// Session load (resume) failed.
SessionLoadFailed {

View file

@ -1385,6 +1385,15 @@ pub struct AgentView {
/// Whether the `/share` slash command is available (mirrors
/// `AppView::sharing_enabled`). Used to gate palette entries.
pub sharing_enabled: bool,
/// Whether THIS session's scheduled fires run as detached background
/// subagents, as resolved by the shell when the session's actor spawned and
/// delivered on the `session/new` / `session/load` response. `/loop` reads
/// it to describe the runtime a fire will get. `None` until that response
/// lands (or against a shell that predates the key), where readers fall
/// back to `AppView::scheduler_background_loops_seed`. Deliberately NOT
/// refreshed by `x.ai/settings/update`: the fire side is pinned for the
/// session's lifetime, so a live mirror would drift out of agreement.
pub scheduler_background_loops: Option<bool>,
/// Mirrors `AppView::usage_visible` (credit warning + `/usage manage`).
pub billing_surface_visible: bool,
/// Input flight recorder — rolling buffer of recent key events.

View file

@ -297,6 +297,7 @@ impl AgentView {
is_subagent_view: false,
hit_subagent_frame_close: Default::default(),
sharing_enabled: false,
scheduler_background_loops: None,
billing_surface_visible: false,
input_log: crate::input_log::InputRingBuffer::new(),
esc_pressed_at: None,

View file

@ -755,6 +755,13 @@ pub struct AppView {
/// non-selectable headers. Gated by `GROK_SESSION_PICKER_GROUPED` env var
/// or remote settings `session_picker_grouped`; defaults to `false`.
pub session_picker_grouped: bool,
/// Startup-only seed for `AgentView::scheduler_background_loops`, resolved
/// once from the config layers plus the remote tier known at connect.
/// Read only until a session's own value arrives on its `session/new` /
/// `session/load` response, and by the session-less dashboard. Never
/// refreshed afterwards — the authoritative value is per session, pinned by
/// the shell when that session's actor spawned.
pub scheduler_background_loops_seed: bool,
/// Whether Ctrl+C before first server activity rewinds the prompt
/// back into the input box. Gated by `GROK_CANCEL_REWIND` env /
/// `[features] cancel_rewind` config / remote settings flag.
@ -1555,6 +1562,7 @@ impl AppView {
optimistic_prompt_echoes: std::collections::HashMap::new(),
pending_running_adoptions: std::collections::HashMap::new(),
session_picker_grouped: false,
scheduler_background_loops_seed: true,
cancel_rewind_enabled: true,
session_recap_available: false,
tutorial: None,
@ -5827,6 +5835,7 @@ pub(crate) mod tests {
optimistic_prompt_echoes: std::collections::HashMap::new(),
pending_running_adoptions: std::collections::HashMap::new(),
session_picker_grouped: false,
scheduler_background_loops_seed: true,
cancel_rewind_enabled: true,
session_recap_available: false,
tutorial: None,

View file

@ -1293,6 +1293,9 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
let auto_mode_gate_from_app = app.auto_mode_gate;
let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
let voice_stt_language_from_app = app.voice_config.language.clone();
// Dashboard commands run before any session exists, so the startup seed is
// the only answer available here.
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
// Build the execution context from app-wide state. The dashboard
// is session-less, so `session_id` is `None`. Offered session-less
@ -1401,6 +1404,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
auto_mode_gate: auto_mode_gate_from_app,
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
voice_stt_language: voice_stt_language_from_app,
scheduler_background_loops: scheduler_background_loops_seed,
},
};
command.run(&mut ctx, invocation.args)

View file

@ -477,6 +477,7 @@ pub(super) fn dispatch_send_prompt_inner(
// shown after the agent borrow ends so we can re-enter via the tip helper.
let mut tip_send_now_after_queue = false;
let voice_stt_language_from_app = app.voice_config.language.clone();
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
let login_method_id_from_app = app.login_method_id.as_ref().map(|id| id.0.to_string());
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
@ -575,6 +576,11 @@ pub(super) fn dispatch_send_prompt_inner(
auto_mode_gate: auto_mode_gate_from_app,
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
voice_stt_language: voice_stt_language_from_app,
// This session's own value (what its fires will actually
// do), seed only until the session response lands.
scheduler_background_loops: agent
.scheduler_background_loops
.unwrap_or(scheduler_background_loops_seed),
},
};

View file

@ -801,6 +801,7 @@ pub(in crate::app::dispatch) fn handle_session_created(
agent_id: AgentId,
session_id: acp::SessionId,
new_models: Option<acp::SessionModelState>,
scheduler_background_loops: Option<bool>,
) -> Vec<Effect> {
let agent_count = app.agents.len();
let switch_hint =
@ -822,6 +823,7 @@ pub(in crate::app::dispatch) fn handle_session_created(
)));
}
agent.bind_session_id(session_id);
agent.scheduler_background_loops = scheduler_background_loops;
if let Some(m) = new_models {
app.models = Some(m).into();
agent.session.models = app.models.clone();
@ -910,12 +912,14 @@ pub(in crate::app::dispatch) fn handle_worktree_session_created(
worktree_path: std::path::PathBuf,
session_cwd: std::path::PathBuf,
new_models: Option<acp::SessionModelState>,
scheduler_background_loops: Option<bool>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.session.finish_command();
agent.mark_turn_finished();
let session_id_clone = session_id.clone();
agent.bind_session_id(session_id);
agent.scheduler_background_loops = scheduler_background_loops;
agent.session.cwd = session_cwd.clone();
agent.session.is_worktree = true;
if let Some(m) = new_models {

View file

@ -879,6 +879,7 @@ pub(in crate::app::dispatch) fn handle_session_loaded(
restore_summary: Option<String>,
restore_degree: Option<xai_grok_workspace::session::git::RestoreDegree>,
running_prompt_id: Option<String>,
scheduler_background_loops: Option<bool>,
) -> Vec<Effect> {
tracing::info!(
"Session loaded for agent {:?} session {:?}",
@ -891,6 +892,7 @@ pub(in crate::app::dispatch) fn handle_session_loaded(
}
let hydrate_sid = session_id.clone();
agent.bind_session_id(session_id);
agent.scheduler_background_loops = scheduler_background_loops;
agent.scrollback.end_batch();
agent.session.loading_replay = false;
agent.session.restore_degree = restore_degree;

View file

@ -54,6 +54,7 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
let auto_mode_gate_from_app = app.auto_mode_gate;
let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
let voice_stt_language_from_app = app.voice_config.language.clone();
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
for agent in app.agents.values_mut() {
// Walk both `Settings` and `ResetSettingsConfirm` — the
// confirm dialog embeds settings state that must stay fresh
@ -92,6 +93,9 @@ pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
auto_mode_gate: auto_mode_gate_from_app,
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
voice_stt_language: voice_stt_language_from_app.clone(),
scheduler_background_loops: agent
.scheduler_background_loops
.unwrap_or(scheduler_background_loops_seed),
};
}
}
@ -191,6 +195,7 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
let auto_mode_gate_from_app = app.auto_mode_gate;
let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
let voice_stt_language_from_app = app.voice_config.language.clone();
let scheduler_background_loops_seed = app.scheduler_background_loops_seed;
let Some(agent) = app.agents.get_mut(&id) else {
return effects;
@ -238,6 +243,9 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(
auto_mode_gate: auto_mode_gate_from_app,
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
voice_stt_language: voice_stt_language_from_app,
scheduler_background_loops: agent
.scheduler_background_loops
.unwrap_or(scheduler_background_loops_seed),
};
let mut state = Box::new(SettingsModalState::new(
registry,
@ -650,6 +658,20 @@ fn agent_auto_mode(app: &AppView) -> bool {
false
}
/// Effective `scheduler_background_loops` for the active agent: the value the
/// shell pinned for that session, falling back to the startup seed while the
/// session response is still in flight (or with no agent at all). See
/// [`agent_multiline_mode`] for the no-agent fallback rationale.
fn agent_scheduler_background_loops(app: &AppView) -> bool {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get(&id)
&& let Some(value) = agent.scheduler_background_loops
{
return value;
}
app.scheduler_background_loops_seed
}
/// Effective `plan_mode` for the active agent
/// (`pending.unwrap_or(active)`).
fn agent_plan_mode(app: &AppView) -> bool {
@ -716,6 +738,7 @@ pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocal
auto_mode_gate: app.auto_mode_gate,
ask_user_question_timeout_enabled: app.ask_user_question_timeout_enabled,
voice_stt_language: app.voice_config.language.clone(),
scheduler_background_loops: agent_scheduler_background_loops(app),
}
}

View file

@ -244,7 +244,14 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
agent_id,
session_id,
models: new_models,
} => handle_session_created(app, agent_id, session_id, new_models),
scheduler_background_loops,
} => handle_session_created(
app,
agent_id,
session_id,
new_models,
scheduler_background_loops,
),
TaskResult::SessionFailed { agent_id, error } => {
handle_session_failed(app, agent_id, error)
}
@ -254,6 +261,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
worktree_path,
session_cwd,
models: new_models,
scheduler_background_loops,
} => handle_worktree_session_created(
app,
agent_id,
@ -261,6 +269,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
worktree_path,
session_cwd,
new_models,
scheduler_background_loops,
),
TaskResult::WorktreeForked {
agent_id,
@ -326,6 +335,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
restore_summary,
restore_degree,
running_prompt_id,
scheduler_background_loops,
} => handle_session_loaded(
app,
agent_id,
@ -335,6 +345,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
restore_summary,
restore_degree,
running_prompt_id,
scheduler_background_loops,
),
TaskResult::SessionTitleFromDisk { agent_id, title } => {
if let Some(agent) = app.agents.get_mut(&agent_id)

View file

@ -2071,6 +2071,7 @@ fn dashboard_deferred_plan_mode_applied_on_session_created() {
agent_id: id,
session_id: session_id.clone(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -5234,6 +5235,7 @@ fn dashboard_attach_roster_focuses_existing_local_agent() {
agent_id: id,
session_id: "local-owned".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -260,6 +260,7 @@ fn test_app() -> AppView {
optimistic_prompt_echoes: std::collections::HashMap::new(),
pending_running_adoptions: std::collections::HashMap::new(),
session_picker_grouped: false,
scheduler_background_loops_seed: true,
cancel_rewind_enabled: true,
session_recap_available: false,
tutorial: None,

View file

@ -229,6 +229,7 @@ fn fork_initiation_supersedes_open_reload_window() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -132,6 +132,7 @@ fn session_created_sets_session_id() {
agent_id: id,
session_id: "new-session-123".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -175,6 +176,7 @@ fn session_created_omits_cta_catalog_when_disabled() {
agent_id: id,
session_id: "new-session-123".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -219,6 +221,7 @@ fn session_created_banner_advertises_resume_in_minimal_mode() {
agent_id: id,
session_id: "new-session-123".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -300,6 +303,7 @@ fn worktree_session_created_sets_session_and_cwd() {
worktree_path: worktree_path.clone(),
session_cwd: session_cwd.clone(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -358,6 +362,7 @@ fn worktree_session_preserves_subdirectory_offset() {
worktree_path: worktree_root.clone(),
session_cwd: session_cwd.clone(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -486,6 +491,7 @@ fn worktree_session_created_drains_queued_prompts() {
worktree_path,
session_cwd: PathBuf::from("/tmp/grok-worktrees/pager-abc"),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -519,6 +525,7 @@ fn session_created_drains_queued_prompts() {
agent_id: id,
session_id: acp::SessionId::new("sess-drain-1"),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -555,6 +562,7 @@ fn session_created_with_flag_emits_five_fetches_and_clears_flag() {
agent_id: id,
session_id: acp::SessionId::new("s"),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -577,6 +585,7 @@ fn session_created_without_flag_emits_no_extension_fetches() {
agent_id: id,
session_id: acp::SessionId::new("s"),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -940,6 +949,7 @@ fn deferred_model_switch_applied_on_session_created() {
agent_id: id,
session_id: session_id.clone(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -980,6 +990,7 @@ fn deferred_model_switch_applied_on_worktree_session_created() {
worktree_path: PathBuf::from("/tmp/worktree"),
session_cwd: PathBuf::from("/tmp/worktree"),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -64,6 +64,7 @@ fn session_loaded_with_restore_shows_summary_in_scrollback() {
),
restore_degree: Some(xai_grok_workspace::session::git::RestoreDegree::Full),
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -206,6 +207,7 @@ fn session_loaded_without_adoption_finishes_replayed_running_entries() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -273,6 +275,7 @@ fn session_loaded_purges_replay_transient() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -297,6 +300,7 @@ fn session_loaded_during_open_reload_window_defers_to_window() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -406,6 +410,7 @@ fn session_loaded_with_restore_failure_shows_warning_banner() {
),
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -447,6 +452,7 @@ fn session_loaded_without_restore_no_summary() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -489,6 +495,7 @@ fn session_loaded_without_restore_resets_restore_degree() {
restore_summary: Some("checked out abc".into()),
restore_degree: Some(xai_grok_workspace::session::git::RestoreDegree::Full),
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -505,6 +512,7 @@ fn session_loaded_without_restore_resets_restore_degree() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -533,6 +541,7 @@ fn session_loaded_with_flag_emits_five_fetches_and_clears_flag() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -725,6 +734,7 @@ fn session_loaded_drains_pending_first_prompt_to_front() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -754,6 +764,7 @@ fn session_loaded_with_no_pending_first_prompt_does_not_enqueue() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -859,6 +870,7 @@ fn session_loaded_clears_stale_running_entries() {
restore_summary: None,
restore_degree: None,
running_prompt_id: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -898,6 +910,7 @@ fn resume_focuses_existing_agent_for_open_session() {
agent_id: agent_0,
session_id: "wt-sess-1".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -915,6 +928,7 @@ fn resume_focuses_existing_agent_for_open_session() {
agent_id: agent_1,
session_id: "new-sess-2".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -945,6 +959,7 @@ fn resume_unknown_session_still_creates_new_agent() {
agent_id: AgentId(0),
session_id: "sess-aaa".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -975,6 +990,7 @@ fn resume_open_session_does_not_rearm_stale_overlay() {
agent_id: agent_0,
session_id: "sess-a".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -985,6 +1001,7 @@ fn resume_open_session_does_not_rearm_stale_overlay() {
agent_id: agent_1,
session_id: "sess-b".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -1010,6 +1027,7 @@ fn resume_conversation_does_not_focus_build_id_collision() {
agent_id: agent_0,
session_id: "shared-id".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -1041,6 +1059,7 @@ fn duplicate_load_unbind_invalidates_old_minimal_btw_response() {
agent_id: old_owner,
session_id: "shared-id".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -1084,6 +1103,7 @@ fn resume_under_chat_mode_focuses_despite_entry_false() {
agent_id: agent_0,
session_id: "chat-mode-sess".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -1095,6 +1115,7 @@ fn resume_under_chat_mode_focuses_despite_entry_false() {
agent_id: agent_1,
session_id: "other".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -1120,6 +1141,7 @@ fn resume_stale_attached_target_focuses_dashboard_row() {
agent_id: agent_0,
session_id: "sess-a".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -1130,6 +1152,7 @@ fn resume_stale_attached_target_focuses_dashboard_row() {
agent_id: agent_1,
session_id: "sess-b".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);
@ -1203,6 +1226,7 @@ fn session_restored_clears_stale_session_id() {
agent_id: AgentId(0),
session_id: "remote-sess".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -69,6 +69,7 @@ fn session_created_with_flag_but_modal_closed_clears_flag_no_fetches() {
agent_id: id,
session_id: acp::SessionId::new("s"),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -1489,6 +1489,7 @@ fn no_deferred_switch_means_no_extra_effect() {
agent_id: id,
session_id: "new-session".into(),
models: None,
scheduler_background_loops: None,
}),
&mut app,
);

View file

@ -168,6 +168,25 @@ pub(crate) fn parse_session_load_running_prompt_id(
.and_then(|v| v.as_str())
.map(String::from)
}
/// CANONICAL wire parser for the `session/new` / `session/load` response
/// `_meta[SCHEDULER_BACKGROUND_LOOPS_META_KEY]`.
///
/// Carries whether THIS session's scheduled fires run as detached background
/// subagents, as the shell resolved it when the session's actor spawned. The
/// pager stores it per session and must not re-resolve the setting: a
/// mid-session flip would then make `/loop`'s wording describe a runtime the
/// already-spawned session will never use. `None` when the shell predates the
/// key (or for gateway chat sessions, which have no local fires), leaving the
/// reader on the startup seed.
pub(crate) fn parse_session_scheduler_background_loops(
resp_meta: Option<&acp::Meta>,
) -> Option<bool> {
resp_meta
.and_then(|m| {
m.get(xai_grok_shell::session::SCHEDULER_BACKGROUND_LOOPS_META_KEY)
})
.and_then(|v| v.as_bool())
}
/// Whether `raw` is (or wraps) a disk-full / ENOSPC failure.
fn is_disk_full_error(raw: &str) -> bool {
raw.contains(xai_fast_worktree::OUT_OF_DISK_CONTEXT)

View file

@ -11,7 +11,9 @@ use super::session_title_resolve::worktree_resume_failure_message;
#[allow(unused_imports)]
use super::{agent, dispatch};
pub use helpers::ConversationsPartial;
pub(super) use helpers::parse_session_load_running_prompt_id;
pub(super) use helpers::{
parse_session_load_running_prompt_id, parse_session_scheduler_background_loops,
};
pub(crate) use helpers::{
EffectMeta, RestoreProgressMsg, SessionFlags, persist_permission_mode_and_notify,
persist_setting, sanitize_user_error,
@ -199,6 +201,9 @@ pub(crate) fn execute(
agent_id,
session_id: resp.session_id,
models: resp.models,
scheduler_background_loops: parse_session_scheduler_background_loops(
resp.meta.as_ref(),
),
}
}
Err(e) => {
@ -493,6 +498,9 @@ pub(crate) fn execute(
worktree_path: worktree_root,
session_cwd,
models: resp.models,
scheduler_background_loops: parse_session_scheduler_background_loops(
resp.meta.as_ref(),
),
}
}
Err(e) => {
@ -574,6 +582,9 @@ pub(crate) fn execute(
restore_summary,
restore_degree,
running_prompt_id,
scheduler_background_loops: parse_session_scheduler_background_loops(
resp.meta.as_ref(),
),
}
}
Err(e) => {

View file

@ -94,6 +94,11 @@ struct AgentLoadOutcome {
/// client is driving mid-reconnect, adopted at finalize (mirrors the
/// `SessionLoaded` adoption in `dispatch.rs`).
running_prompt_id: Option<String>,
/// `x.ai/schedulerBackgroundLoops` from the reload response. A reconnect
/// re-spawns the session actor, which re-pins the fire mode, so the
/// pre-reconnect value can be stale — adopt the reloaded one or `/loop`
/// describes a runtime the new actor will not use.
scheduler_background_loops: Option<bool>,
}
/// Fields of the reconnect `session/load`, derived from the agent being
@ -165,10 +170,11 @@ fn plan_reconnect_load(
fn reconnect_restore_outcome(
init_ok: bool,
pending_agent_ids: &[super::agent::AgentId],
loads: &std::collections::HashMap<super::agent::AgentId, (bool, Option<String>)>,
loads: &std::collections::HashMap<super::agent::AgentId, (bool, Option<String>, Option<bool>)>,
active_agent_id: Option<super::agent::AgentId>,
) -> (bool, bool) {
let load_ok = |id: &super::agent::AgentId| -> bool { loads.get(id).is_some_and(|(ok, _)| *ok) };
let load_ok =
|id: &super::agent::AgentId| -> bool { loads.get(id).is_some_and(|(ok, ..)| *ok) };
let all_restored = init_ok && pending_agent_ids.iter().all(load_ok);
let active_restored = init_ok
&& active_agent_id.is_some_and(|aid| pending_agent_ids.contains(&aid) && load_ok(&aid));
@ -1123,6 +1129,18 @@ pub(crate) async fn run(
.value,
);
// Pre-arrival seed only. The authoritative per-session value rides the
// `session/new` / `session/load` response, but `/loop` can be reached from
// the session-less dashboard and from a session whose response has not
// landed yet; both need an answer now, and this is the same resolver the
// shell runs at spawn, so the seed agrees with the flag as it stands today.
app.scheduler_background_loops_seed =
xai_grok_shell::util::config::resolve_scheduler_background_loops(
remote_settings
.as_ref()
.and_then(|s| s.scheduler_background_loops),
);
app.usage_billing_redirect_url = remote_settings
.as_ref()
.and_then(|s| s.usage_billing_redirect_url.clone());
@ -2571,6 +2589,10 @@ pub(crate) async fn run(
effects::parse_session_load_running_prompt_id(
resp.meta.as_ref(),
),
scheduler_background_loops:
effects::parse_session_scheduler_background_loops(
resp.meta.as_ref(),
),
});
}
Err(e) => {
@ -2581,6 +2603,7 @@ pub(crate) async fn run(
agent_id,
success: false,
running_prompt_id: None,
scheduler_background_loops: None,
});
}
}
@ -2656,7 +2679,12 @@ pub(crate) async fn run(
let mut loads: std::collections::HashMap<_, _> = outcome
.loads
.into_iter()
.map(|l| (l.agent_id, (l.success, l.running_prompt_id)))
.map(|l| {
(
l.agent_id,
(l.success, l.running_prompt_id, l.scheduler_background_loops),
)
})
.collect();
// Resolved BEFORE the finalize loop drains `loads` via `remove`
// (see `reconnect_restore_outcome`).
@ -2672,8 +2700,14 @@ pub(crate) async fn run(
);
restore_dashboard_peek_before_reload(&mut app.dashboard, &mut app.agents);
for id in &pending.agent_ids {
let (ok, running_prompt_id) = loads.remove(id).unwrap_or((false, None));
let (ok, running_prompt_id, scheduler_background_loops) =
loads.remove(id).unwrap_or((false, None, None));
if let Some(agent) = app.agents.get_mut(id) {
// The reloaded actor re-pinned the fire mode; a failed
// load leaves the previous value rather than guessing.
if let Some(mode) = scheduler_background_loops {
agent.scheduler_background_loops = Some(mode);
}
agent.finalize_reload_and_maybe_adopt(
pending.generation,
ok,
@ -3908,8 +3942,8 @@ mod tests {
let active = AgentId(0);
let background = AgentId(1);
let mut loads = std::collections::HashMap::new();
loads.insert(active, (true, None));
loads.insert(background, (false, None));
loads.insert(active, (true, None, None));
loads.insert(background, (false, None, None));
let pending = vec![active, background];
let (all_restored, active_restored) =
@ -3932,8 +3966,8 @@ mod tests {
let active = AgentId(0);
let background = AgentId(1);
let mut loads = std::collections::HashMap::new();
loads.insert(active, (false, None));
loads.insert(background, (true, None));
loads.insert(active, (false, None, None));
loads.insert(background, (true, None, None));
let pending = vec![active, background];
let (all_restored, active_restored) =
@ -3952,7 +3986,7 @@ mod tests {
use super::super::agent::AgentId;
let active = AgentId(0);
let mut loads = std::collections::HashMap::new();
loads.insert(active, (true, None));
loads.insert(active, (true, None, None));
let pending = vec![active];
let (all_restored, active_restored) =
@ -3982,7 +4016,7 @@ mod tests {
use super::super::agent::AgentId;
let background = AgentId(1);
let mut loads = std::collections::HashMap::new();
loads.insert(background, (true, None));
loads.insert(background, (true, None, None));
let pending = vec![background];
let (all_restored, active_restored) =

View file

@ -389,37 +389,117 @@ pub(crate) struct ExitSummary {
/// `None` when the newest prompt is still unanswered.
pub last_response: Option<String>,
}
/// Resolve leader mode → `(use_leader, policy_disable_reason)`.
/// Resolve leader mode, reporting both why it is off and what turned it off.
///
/// Precedence (highest first): `--no-leader` → `--leader` → eligibility → local
/// config `use_leader` → remote `leader_mode` (release-dist) → default off.
/// `requested_confinement` then vetoes leader use when `Some` (in-process tools
/// stay under the OS sandbox) without reclaiming a shared leader on its own.
///
/// `policy_disable_reason` is `Some("config"|"remote")` only when leader mode is
/// *definitively* off by policy (local `use_leader = false`, or remote
/// `leader_mode` fetched as `false`). Unknown remote state (`None` / prefetch
/// timeout), the default, `--no-leader`, and ineligibility are `None` — never
/// reclaim a leader on an unknown signal.
pub fn resolve_use_leader(
pub fn resolve_leader_mode<'p>(
leader_flag: bool,
no_leader_flag: bool,
raw_config: &toml::Value,
_remote_settings: Option<&xai_grok_shell::util::config::RemoteSettings>,
eligible: bool,
requested_confinement: Option<&'p str>,
) -> LeaderMode<'p> {
let (use_leader, policy_disable_reason) = 'policy: {
if no_leader_flag {
break 'policy (false, None);
}
if leader_flag {
break 'policy (true, None);
}
if !eligible {
break 'policy (false, None);
}
if let Some(v) = config::use_leader_from_toml_opt(raw_config) {
break 'policy (v, (!v).then_some("config"));
}
#[cfg(feature = "release-dist")]
if let Some(remote_val) = _remote_settings.and_then(|s| s.leader_mode) {
break 'policy (remote_val, (!remote_val).then_some("remote"));
}
(false, None)
};
if let Some(profile) = requested_confinement {
return LeaderMode {
use_leader: false,
policy_disable_reason,
disabled_by_confinement: use_leader.then_some(profile),
};
}
LeaderMode {
use_leader,
policy_disable_reason,
disabled_by_confinement: None,
}
}
/// Leader mode as resolved, plus the sandbox profile that overrode it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LeaderMode<'p> {
pub use_leader: bool,
/// `Some` only when leader mode is *definitively* off by policy, which is
/// what licenses reclaiming a leftover leader.
pub policy_disable_reason: Option<&'static str>,
/// The profile that turned leader mode off, set only when leader mode was
/// otherwise on — the case worth telling the user about.
pub disabled_by_confinement: Option<&'p str>,
}
/// The leader-mode decision alone, for callers with nothing to report.
///
/// See [`resolve_leader_mode`] for the precedence chain and the
/// `policy_disable_reason` contract.
pub fn resolve_use_leader(
leader_flag: bool,
no_leader_flag: bool,
raw_config: &toml::Value,
remote_settings: Option<&xai_grok_shell::util::config::RemoteSettings>,
eligible: bool,
requested_confinement: Option<&str>,
) -> (bool, Option<&'static str>) {
if no_leader_flag {
return (false, None);
}
if leader_flag {
return (true, None);
}
if !eligible {
return (false, None);
}
if let Some(v) = config::use_leader_from_toml_opt(raw_config) {
return (v, (!v).then_some("config"));
}
#[cfg(feature = "release-dist")]
if let Some(remote_val) = _remote_settings.and_then(|s| s.leader_mode) {
return (remote_val, (!remote_val).then_some("remote"));
}
(false, None)
let resolved = resolve_leader_mode(
leader_flag,
no_leader_flag,
raw_config,
remote_settings,
eligible,
requested_confinement,
);
(resolved.use_leader, resolved.policy_disable_reason)
}
/// How long the sandbox note stays uncovered before a fullscreen TUI opens over
/// it. Paid only when the note was printed and the screen is about to hide it.
const SANDBOX_NOTICE_LINGER: std::time::Duration = std::time::Duration::from_millis(1_200);
/// Tell the user at startup that the sandbox turned leader mode off.
///
/// Writes to the dup'd terminal stderr, which survives the TUI's fd-2 redirect
/// (`redirect_native_stderr`). A fullscreen TUI still paints over it, leaving
/// the line to be read on exit; `leader_disabled_by_sandbox` on the
/// leader-mode decision log is the durable record.
pub fn warn_leader_disabled_by_sandbox(profile: &str) {
xai_grok_shell::util::with_locked_stderr(|stderr| {
print_leader_disabled_by_sandbox(profile, stderr)
});
}
/// Says only that the profile was *requested*: enforcement can still fail
/// (`apply_sandbox` warns and continues) while the leader is refused either way.
///
/// Write errors are dropped — `eprintln!` would panic on a closed stderr.
fn print_leader_disabled_by_sandbox(profile: &str, w: &mut impl Write) {
let _ = writeln!(
w,
"note: sandbox profile '{profile}' was requested, so leader mode is off for this \
session and tool calls stay in this process instead of the shared leader. \
Disable the profile at the source that selected it (CLI, env, config, or a \
managed requirement) to use the leader."
);
}
/// Join early prefetch to get remote settings (with timeout).
///
@ -527,19 +607,32 @@ pub async fn run(
let raw_config = xai_grok_shell::config::load_effective_config()
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let prefetch_elapsed = startup_start.elapsed();
let (use_leader, policy_disable_reason) = resolve_use_leader(
let requested_confinement = xai_grok_sandbox::requested_confinement_profile();
let LeaderMode {
use_leader,
policy_disable_reason,
disabled_by_confinement,
} = resolve_leader_mode(
args.leader,
args.no_leader,
&raw_config,
remote_settings.as_ref(),
true,
requested_confinement,
);
tracing::info!(
use_leader,
?policy_disable_reason,
sandbox_profile = ?requested_confinement,
// The other fields cannot distinguish this from leader mode being off
// already while a sandbox is on.
leader_disabled_by_sandbox = disabled_by_confinement.is_some(),
prefetch_ms = prefetch_elapsed.as_millis() as u64,
"pager TUI leader mode resolved"
);
if let Some(profile) = disabled_by_confinement {
warn_leader_disabled_by_sandbox(profile);
}
if session_startup::chat_mode_conflicts_with_leader(args.chat(), use_leader) {
anyhow::bail!("{}", session_startup::CHAT_MODE_LEADER_CONFLICT);
}
@ -713,6 +806,9 @@ pub async fn run(
multiplexer = ?term_ctx.multiplexer,
"resolved fullscreen policy"
);
if disabled_by_confinement.is_some() && screen_mode.is_fullscreen() {
tokio::time::sleep(SANDBOX_NOTICE_LINGER).await;
}
engage_startup_theme(screen_mode);
let minimal_live_rows = config_watcher.current().minimal_live_rows;
let (frame_tx, writer_sync, writer_event_rx, writer_thread) =
@ -1614,50 +1710,128 @@ mod tests {
#[test]
fn no_leader_flag_wins_over_leader_flag_and_config() {
let cfg = config_with_leader(true);
let (use_leader, reason) = resolve_use_leader(true, true, &cfg, None, true);
let (use_leader, reason) = resolve_use_leader(true, true, &cfg, None, true, None);
assert!(!use_leader);
assert_eq!(reason, None);
}
#[test]
fn leader_flag_enables() {
let (use_leader, reason) = resolve_use_leader(true, false, &empty_config(), None, true);
let (use_leader, reason) =
resolve_use_leader(true, false, &empty_config(), None, true, None);
assert!(use_leader);
assert_eq!(reason, None);
}
#[test]
fn not_eligible_returns_false() {
let cfg = config_with_leader(true);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, false);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, false, None);
assert!(!use_leader);
assert_eq!(reason, None);
}
#[test]
fn config_toml_enables() {
let cfg = config_with_leader(true);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true, None);
assert!(use_leader);
assert_eq!(reason, None);
}
#[test]
fn config_toml_disables() {
let cfg = config_with_leader(false);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, None, true, None);
assert!(!use_leader);
assert_eq!(reason, Some("config"));
}
#[test]
fn default_is_false() {
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), None, true);
let (use_leader, reason) =
resolve_use_leader(false, false, &empty_config(), None, true, None);
assert!(!use_leader);
assert_eq!(reason, None);
}
#[test]
fn cli_flag_overrides_config() {
let cfg = config_with_leader(false);
let (use_leader, reason) = resolve_use_leader(true, false, &cfg, None, true);
let (use_leader, reason) = resolve_use_leader(true, false, &cfg, None, true, None);
assert!(use_leader);
assert_eq!(reason, None);
}
#[test]
fn sandbox_confinement_refuses_leader_even_with_leader_flag_and_config_on() {
let cfg = config_with_leader(true);
let (use_leader, reason) =
resolve_use_leader(true, false, &cfg, None, true, Some("strict"));
assert!(!use_leader);
assert_eq!(reason, None);
}
/// `disabled_by_confinement` for the four leader × sandbox cells, driven by
/// every input that can decide leader mode — not just `[cli] use_leader`.
#[test]
fn matrix_reports_the_profile_only_when_the_sandbox_takes_leader_mode_away() {
let on = config_with_leader(true);
let off = config_with_leader(false);
let sandbox = Some("strict");
for (label, leader_flag, cfg) in [
("config on", false, &on),
("--leader", true, &empty_config()),
("--leader over config off", true, &off),
] {
let resolved = resolve_leader_mode(leader_flag, false, cfg, None, true, sandbox);
assert!(!resolved.use_leader, "{label}: leader must be vetoed");
assert_eq!(
resolved.disabled_by_confinement,
Some("strict"),
"{label}: the profile that took leader mode away must be named"
);
}
for (label, cfg, expect_leader) in [("leader on", &on, true), ("leader off", &off, false)] {
let resolved = resolve_leader_mode(false, false, cfg, None, true, None);
assert_eq!(resolved.use_leader, expect_leader, "{label}");
assert_eq!(resolved.disabled_by_confinement, None, "{label}");
}
for (label, leader_flag, no_leader_flag, cfg, eligible) in [
("config off", false, false, &off, true),
("--no-leader over config on", false, true, &on, true),
("default", false, false, &empty_config(), true),
("ineligible mode with config on", false, false, &on, false),
] {
let resolved =
resolve_leader_mode(leader_flag, no_leader_flag, cfg, None, eligible, sandbox);
assert!(!resolved.use_leader, "{label}");
assert_eq!(
resolved.disabled_by_confinement, None,
"{label}: the sandbox took nothing away, so it must stay silent"
);
}
}
#[test]
fn sandbox_notice_names_the_profile_without_promising_enforcement() {
let mut out = Vec::new();
print_leader_disabled_by_sandbox("strict", &mut out);
let msg = String::from_utf8(out).expect("utf-8");
assert!(msg.contains("'strict'"), "must name the profile: {msg}");
assert!(
msg.contains("was requested"),
"must describe the request, not enforcement: {msg}"
);
assert!(
!msg.contains("is active"),
"must not claim the profile is enforced: {msg}"
);
assert!(
msg.contains("Disable the profile at the source"),
"must say how to get leader mode back: {msg}"
);
assert_eq!(msg.lines().count(), 1, "single line: {msg}");
}
#[test]
fn sandbox_confinement_preserves_config_off_reclaim_reason() {
let cfg = config_with_leader(false);
let (use_leader, reason) =
resolve_use_leader(false, false, &cfg, None, true, Some("strict"));
assert!(!use_leader);
assert_eq!(reason, Some("config"));
}
fn try_parse_pager(args: &[&str]) -> Result<PagerArgs, clap::Error> {
use clap::Parser;
PagerArgs::try_parse_from(args)
@ -1688,7 +1862,7 @@ mod tests {
#[test]
fn no_leader_flag_overrides_config_for_tui_fallback() {
let cfg = config_with_leader(true);
let (use_leader, reason) = resolve_use_leader(false, true, &cfg, None, true);
let (use_leader, reason) = resolve_use_leader(false, true, &cfg, None, true, None);
assert!(!use_leader);
assert_eq!(reason, None);
}
@ -1708,7 +1882,8 @@ mod tests {
}
#[test]
fn remote_settings_none_falls_through_to_default() {
let (use_leader, reason) = resolve_use_leader(false, false, &empty_config(), None, true);
let (use_leader, reason) =
resolve_use_leader(false, false, &empty_config(), None, true, None);
assert!(!use_leader);
assert_eq!(reason, None);
}
@ -1720,7 +1895,7 @@ mod tests {
..Default::default()
};
let (use_leader, reason) =
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
resolve_use_leader(false, false, &empty_config(), Some(&rs), true, None);
assert!(use_leader);
assert_eq!(reason, None);
}
@ -1732,7 +1907,7 @@ mod tests {
..Default::default()
};
let (use_leader, reason) =
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
resolve_use_leader(false, false, &empty_config(), Some(&rs), true, None);
assert!(!use_leader);
assert_eq!(reason, Some("remote"));
}
@ -1744,7 +1919,7 @@ mod tests {
..Default::default()
};
let (use_leader, reason) =
resolve_use_leader(false, false, &empty_config(), Some(&rs), true);
resolve_use_leader(false, false, &empty_config(), Some(&rs), true, None);
assert!(!use_leader);
assert_eq!(reason, None);
}
@ -1756,7 +1931,7 @@ mod tests {
..Default::default()
};
let cfg = config_with_leader(false);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, Some(&rs), true);
let (use_leader, reason) = resolve_use_leader(false, false, &cfg, Some(&rs), true, None);
assert!(!use_leader);
assert_eq!(reason, Some("config"));
}
@ -2089,6 +2264,7 @@ mod tests {
print_exit_resume_hint(&bare_exit_info("sess-abc", true), 80, &mut w);
print_exit_resume_hint(&full_exit_info("sess-abc"), 80, &mut w);
print_relaunch_failure_hint(&"exec failed", "sess-xyz", true, &mut w);
print_leader_disabled_by_sandbox("strict", &mut w);
}
/// Close the *read* end so writes on the write end get EPIPE
/// (SIGPIPE is SIG_IGN → BrokenPipe, not process death).
@ -2107,5 +2283,6 @@ mod tests {
print_exit_resume_hint(&bare_exit_info("pipe-sid", true), 80, &mut writer);
print_exit_resume_hint(&full_exit_info("pipe-sid"), 80, &mut writer);
print_relaunch_failure_hint(&"exec failed", "pipe-sid", false, &mut writer);
print_leader_disabled_by_sandbox("strict", &mut writer);
}
}

View file

@ -299,6 +299,11 @@ pub struct PagerLocalSnapshot {
/// language actually in effect when `[ui].voice_stt_language` is unset but
/// an explicit `[voice].language` applies.
pub voice_stt_language: String,
/// Mirrors `AgentView::scheduler_background_loops` — the value the shell
/// pinned for THIS session — falling back to
/// `AppView::scheduler_background_loops_seed` before the session response
/// lands. `/loop` reads it to describe where a scheduled fire runs.
pub scheduler_background_loops: bool,
}
impl Default for PagerLocalSnapshot {
@ -323,6 +328,8 @@ impl Default for PagerLocalSnapshot {
auto_mode_gate: false,
ask_user_question_timeout_enabled: None,
voice_stt_language: xai_grok_voice::STT_LANGUAGE_DEFAULT.to_string(),
// Matches `resolve_scheduler_background_loops`'s default.
scheduler_background_loops: true,
}
}
}

View file

@ -1,6 +1,6 @@
use agent_client_protocol as acp;
use xai_grok_tools::implementations::grok_build::{
SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
LoopFireMode, SCHEDULER_CREATE_TOOL_NAME, loop_schedule_instruction, loop_usage_message,
};
use crate::slash::command::{CommandExecCtx, CommandResult, ScheduledTaskPreview, SlashCommand};
@ -108,12 +108,17 @@ impl SlashCommand for LoopCommand {
LOOP_REQUIRED_TOOLS
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
fn run(&self, ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
if args.trim().is_empty() {
return CommandResult::Message(loop_usage_message().to_string());
}
let (interval_token, prompt) = parse_loop_args(args);
let fire_mode = if ctx.pager_state.scheduler_background_loops {
LoopFireMode::Detached
} else {
LoopFireMode::InSession
};
// Show a concrete cadence only for an unambiguous leading token;
// otherwise a neutral placeholder, since the authoritative schedule
@ -127,7 +132,7 @@ impl SlashCommand for LoopCommand {
CommandResult::InjectSkill {
display_text: format!("/loop {args}"),
prompt_blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(
loop_schedule_instruction(args),
loop_schedule_instruction(args, fire_mode),
))],
display_as_skill: false,
scheduled_task_preview: Some(ScheduledTaskPreview {
@ -254,6 +259,10 @@ mod tests {
}
fn run_loop(args: &str) -> CommandResult {
run_loop_with_background_loops(args, true)
}
fn run_loop_with_background_loops(args: &str, background_loops: bool) -> CommandResult {
let models = ModelState::default();
let bundle = BundleState::default();
let mut ctx = CommandExecCtx {
@ -262,7 +271,10 @@ mod tests {
bundle_state: &bundle,
screen_mode: crate::app::ScreenMode::Inline,
billing_surface_visible: true,
pager_state: crate::settings::PagerLocalSnapshot::default(),
pager_state: crate::settings::PagerLocalSnapshot {
scheduler_background_loops: background_loops,
..Default::default()
},
};
LoopCommand.run(&mut ctx, args)
}
@ -353,14 +365,19 @@ mod tests {
#[test]
fn run_instruction_matches_shared_helper() {
let args = "2h run tests";
match run_loop(args) {
CommandResult::InjectSkill { prompt_blocks, .. } => {
let acp::ContentBlock::Text(text) = &prompt_blocks[0] else {
panic!("expected a text prompt block");
};
assert_eq!(text.text, loop_schedule_instruction(args));
for (background_loops, mode) in [
(true, LoopFireMode::Detached),
(false, LoopFireMode::InSession),
] {
match run_loop_with_background_loops(args, background_loops) {
CommandResult::InjectSkill { prompt_blocks, .. } => {
let acp::ContentBlock::Text(text) = &prompt_blocks[0] else {
panic!("expected a text prompt block");
};
assert_eq!(text.text, loop_schedule_instruction(args, mode));
}
other => panic!("expected InjectSkill, got {other:?}"),
}
other => panic!("expected InjectSkill, got {other:?}"),
}
}