Synced from monorepo

Synced from monorepo

Changes:
- Temporarily disable session share link creation in the TUI
- Do not approve plan on empty Enter from the revise prompt
- Expose chat product Skills via ACP available_commands_update
- Return immediately from a blocking wait on an already-completed ACP task
- Split headless pager module for clearer structure
- Stop git worktree prune from removing user registrations on resume
- Use compaction sampler tokenizer for item token counts
- Opt-in extra root CAs via GROK_EXTRA_CA_BUNDLE
- Cancel all session subagents when the user stops
- Let the session persistence actor exit when its session ends
- Make fullscreen terminal resize much cheaper on long sessions
- Report honestly from kill_task when an ACP task does not exist
- Hide /usage for external-auth deployments
- Forward the history-load trailer’s computer_reason to the client
- Remove ineffective no-op tool reminder
- Declare slash-command screen-mode support in one place
- Keep settings enum picker on the committed value until Enter
- Reap a PTY’s full process tree
- Stream tool calls from headless mode over ACP
- Bridge gateway task lifecycle to ACP for chat session background tasks
- Don’t warn about truncated history on a suppressed replay
- Fit full-replace summarizer input and recover on context-length errors
- Stop dropping agents over an unrecognized frontmatter color
- Add /undo as a slash alias for /rewind
- Harden sleep/wake token-refresh paths against forced re-login
- Add session/list ACP method
- Give each sampling backend its own conversion module
- Treat an unenrolled child process as a lint error
- Suppress the cancelled marker on send-now wake turns
- Stop tearing down Roslyn on every edit, and read C# diagnostics

Source-Revision: 2a28b4a86cfc4a4c133c35b7fc2a6a9964387c39
This commit is contained in:
grokkybara[bot] 2026-07-30 19:07:40 +00:00
commit dd04f397b1
367 changed files with 29489 additions and 10051 deletions

View file

@ -42,6 +42,9 @@ use std::io;
mod process_scope;
pub use process_scope::{ProcessScope, global_process_scope};
/// How long a shell gets to forward a hangup to its jobs before it is killed.
pub const HANGUP_GRACE: std::time::Duration = std::time::Duration::from_millis(200);
pub mod runtime;
// ---------------------------------------------------------------------------
@ -367,6 +370,9 @@ pub struct ProcessGroup {
leader: Option<ProcessGroupId>,
#[cfg(windows)]
job: windows::Win32::Foundation::HANDLE,
/// Set for a shell that owns a terminal, whose job-control children only
/// die if the shell is asked to hang up first.
hangup_first: bool,
}
#[cfg(windows)]
@ -378,7 +384,10 @@ impl ProcessGroup {
pub fn new() -> io::Result<Self> {
#[cfg(unix)]
{
Ok(Self { leader: None })
Ok(Self {
leader: None,
hangup_first: false,
})
}
#[cfg(windows)]
{
@ -410,7 +419,10 @@ impl ProcessGroup {
return Err(io::Error::other(format!("SetInformationJobObject: {e}")));
}
Ok(Self { job })
Ok(Self {
job,
hangup_first: false,
})
}
}
@ -479,6 +491,33 @@ impl ProcessGroup {
}
}
/// Ask an interactive shell to hang up. Its job-control children each live
/// in their own process group, which no `killpg` here reaches, but a shell
/// forwards the hangup to them before it exits.
pub fn hangup(&self) -> io::Result<()> {
#[cfg(unix)]
{
self.killpg_unix(nix::sys::signal::Signal::SIGHUP)?;
// A stopped shell cannot forward the hangup until it resumes.
self.killpg_unix(nix::sys::signal::Signal::SIGCONT)
}
#[cfg(windows)]
{
Ok(())
}
}
/// Whether teardown should hang this group up before killing it. Never on
/// Windows, where the hangup is a no-op and the Job Object takes the tree.
pub fn wants_hangup(&self) -> bool {
cfg!(unix) && self.hangup_first
}
/// Mark this group as a terminal-owning shell. See [`Self::hangup`].
pub fn hang_up_before_kill(&mut self) {
self.hangup_first = true;
}
#[cfg(unix)]
fn killpg_unix(&self, signal: nix::sys::signal::Signal) -> io::Result<()> {
// `leader` is `None` until a child is enrolled, and a `ProcessGroupId`
@ -806,6 +845,7 @@ mod tests {
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
let error = cmd
.spawn()
.expect_err("cross-thread arm+spawn must fail in debug builds");
@ -848,6 +888,7 @@ mod tests {
// binding on the same command, in the documented order.
detach_std_command(&mut cmd);
kill_on_parent_death_std(&mut cmd);
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
let child = cmd.spawn().expect("spawn armed grandchild");
println!("grandchild:{}", child.id());
// Do not reap: the grandchild must outlive this handle and die only
@ -879,6 +920,7 @@ mod tests {
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
let mut intermediate = cmd.spawn().expect("spawn intermediate test process");
// Grandchild pid from the intermediate's stdout. Substring-match, not
@ -959,6 +1001,7 @@ mod tests {
.stderr(std::process::Stdio::null());
detach_std_command(&mut cmd);
kill_on_parent_death_std(&mut cmd);
#[allow(clippy::disallowed_methods)] // test fixture; the test kills it
let mut child = cmd.spawn().expect("spawn armed child");
// The binding must not kill a child whose parent (this process) is

View file

@ -22,7 +22,7 @@ use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};
use crate::{ProcessGroup, new_process_group};
use crate::{HANGUP_GRACE, ProcessGroup, new_process_group};
/// A `Send + Sync` kill-handle for one unit's child-process trees. Cheap to
/// clone (shares one inner via `Arc`). See the [module docs](self) for the
@ -88,9 +88,16 @@ impl ProcessScope {
/// what keeps [`kill_all`] PID-reuse-safe.
///
/// Returns `true` if the group was enrolled. Returns `false` if the scope
/// was already closed ([`kill_all`] latched): the group is killed on the
/// spot, and the caller should treat the child as dead — fail its start
/// rather than proceed against a killed process.
/// was already closed ([`kill_all`] latched): the caller should treat the
/// child as dead and fail its start rather than proceed against it.
///
/// A group is killed here unless it wants a hangup first, which needs a
/// grace this lock cannot afford; [`enroll_terminal_pid`] is the only way to
/// mark one and reaps it itself. Do not pair
/// [`ProcessGroup::hang_up_before_kill`] with a bare `register`: nothing
/// would reap the child.
///
/// [`enroll_terminal_pid`]: Self::enroll_terminal_pid
///
/// For spawn sites (e.g. the MCP child handle, the bash terminal) that
/// already build their own [`ProcessGroup`] and own its lifecycle; sites that
@ -106,8 +113,11 @@ impl ProcessScope {
// `Weak` that would leak. Closes the close/spawn race where a
// (possibly wedged) actor's spawn lands after teardown. `killpg` is
// non-blocking, so killing under the lock is fine and serializes
// with a concurrent `kill_all`.
let _ = group.kill();
// with a concurrent `kill_all`. A shell that needs a hangup first is
// left for the caller, which can afford the grace outside this lock.
if !group.wants_hangup() {
let _ = group.kill();
}
return false;
}
groups.retain(|w| w.strong_count() > 0);
@ -136,6 +146,25 @@ impl ProcessScope {
Ok(group)
}
/// Enroll a shell this process did not spawn through a
/// [`tokio::process::Command`]. Teardown gives it [`HANGUP_GRACE`] to hang
/// up before the kill.
#[must_use = "the returned Arc<ProcessGroup> must be kept alive or the scope cannot reap the child"]
pub fn enroll_terminal_pid(&self, pid: u32) -> io::Result<Arc<ProcessGroup>> {
let mut group = ProcessGroup::new()?;
group.attach_pid(pid)?;
group.hang_up_before_kill();
let group = Arc::new(group);
if !self.register(&group) {
// Lost the close/spawn race; reap in the same order teardown uses.
reap_groups(&[Arc::downgrade(&group)]);
return Err(io::Error::other(
"process scope already closed; child killed",
));
}
Ok(group)
}
/// Convenience for simple sites: [`prepare`] + spawn + [`enroll`]. Returns
/// the child together with the owning `Arc<ProcessGroup>`, which the caller
/// must keep alive for the scope to be able to reap the child.
@ -160,21 +189,16 @@ impl ProcessScope {
/// Groups whose owner already reaped+dropped them upgrade to `None` and are
/// skipped — so this never `killpg`s a reused PID.
pub fn kill_all(&self) {
let mut groups = self.lock();
for weak in groups.iter() {
if let Some(group) = weak.upgrade() {
// A failed kill means the group already exited (ESRCH) — benign,
// and there is nothing actionable to log at this primitive layer.
// Callers that care about the wedge-reclaim event log it there.
let _ = group.kill();
}
}
// Every weak has been handled above; clear the set.
groups.clear();
// Latch closed under the lock: a concurrent `register` either already
// pushed (its group was just killed in the loop) or now sees `closed`
// and kills its own child — nothing slips through after teardown.
self.inner.closed.store(true, Ordering::Relaxed);
let enrolled = {
let mut groups = self.lock();
let enrolled = std::mem::take(&mut *groups);
// Latch closed under the lock: a concurrent `register` either already
// pushed (its group is in `enrolled` and dies below) or now sees
// `closed` and kills its own child — nothing slips past teardown.
self.inner.closed.store(true, Ordering::Relaxed);
enrolled
};
reap_groups(&enrolled);
}
/// Lock the group set, tolerating a poisoned mutex: the critical sections
@ -216,16 +240,34 @@ pub fn global_process_scope() -> &'static ProcessScope {
impl Drop for ScopeInner {
fn drop(&mut self) {
// RAII backstop: if the last scope handle drops without an explicit
// `kill_all`, still reap any group whose owner is alive (a wedged
// unit). Dead weaks (clean teardown) upgrade to None and are skipped,
// so this stays PID-reuse-safe.
// `kill_all`, still reap any group whose owner is alive (a wedged unit),
// in the same order.
let groups = self.groups.lock().unwrap_or_else(PoisonError::into_inner);
for weak in groups.iter() {
if let Some(group) = weak.upgrade() {
let _ = group.kill();
}
reap_groups(&groups);
}
}
/// Hang up the groups that asked for it, one shared grace, then kill.
///
/// A failed signal means the group already exited (ESRCH), which is benign and
/// not actionable at this layer.
fn reap_groups(enrolled: &[Weak<ProcessGroup>]) {
let mut hung_up = false;
for group in enrolled.iter().filter_map(Weak::upgrade) {
if group.wants_hangup() {
let _ = group.hangup();
hung_up = true;
}
}
if hung_up {
std::thread::sleep(HANGUP_GRACE);
}
// Upgrade again rather than holding the `Arc`s across the grace: an owner
// that reaped its child meanwhile has dropped its group, and its pgid may
// already belong to someone else.
for group in enrolled.iter().filter_map(Weak::upgrade) {
let _ = group.kill();
}
}
#[cfg(all(test, unix))]
@ -310,6 +352,21 @@ mod tests {
/// Close/spawn race: a child enrolled *after* `kill_all` must be killed on
/// the spot, not leaked — `kill_all` won't run again to catch it — and the
/// caller must be told (enroll errors) so it doesn't proceed with a dead child.
#[tokio::test]
async fn only_a_terminal_enrollment_asks_for_a_hangup() {
let scope = ProcessScope::new();
let mut cmd = sleeper();
scope.prepare(&mut cmd);
let (child, group) = scope.spawn(cmd).unwrap();
let pid = child.id().expect("pid");
assert!(!group.wants_hangup());
let terminal = scope.enroll_terminal_pid(pid).unwrap();
assert!(terminal.wants_hangup());
scope.kill_all();
}
#[tokio::test]
async fn register_after_kill_all_reaps_immediately() {
let scope = ProcessScope::new();