Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion FORK_PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5459,14 +5459,16 @@ New module: `false`.

## `crates/buzz-acp/src/process_tree.rs`

Inherited integration inventory: bounded process snapshots and resident-helper classification keep live task children out of idle-pool teardown.
Bounded process snapshots and resident-helper classification; macOS executable confirmation releases completed Codex turns while preserving actual child work.

New module: `true`.

- Required symbol: `pub fn snapshot()`
- Required symbol: `pub fn blocking_pids(`
- Required symbol: `pub fn is_resident_helper(`
- Invocation: `crates/buzz-acp/src/acp.rs` → `snapshot`; exact call `let tree = crate::process_tree::snapshot()?;`; behavior test `prompt_work_is_tracked_under_worker_and_resident_provider`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp prompt_work_is_tracked_under_worker_and_resident_provider -- --test-threads=1`
- Invocation: `crates/buzz-acp/src/acp.rs` → `blocking_pids`; exact call `blocking.extend(tree.blocking_pids(parent, &self.resident_pids));`; behavior test `work_under_late_codex_host_holds_slot_until_child_exits`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1`
- Invocation: `crates/buzz-acp/src/acp.rs` → `is_resident_helper`; exact call `self.resident_pids.contains(&child.pid) || tree.is_resident_helper(child)`; behavior test `work_under_late_codex_host_holds_slot_until_child_exits`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1`
- Verify: `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp process_tree -- --test-threads=1`

## `crates/buzz-acp/src/process_tree/snapshot_tests.rs`
Expand Down Expand Up @@ -5808,3 +5810,59 @@ New module: `false`.
- Required symbol: `tokio::pin!(receive);`
- Required symbol: `demo_echo_keeps_inflight_read_across_ticks_and_still_drains`
- Verify: `cargo test -p buzz-relay -- --test-threads=1`

## `crates/buzz-acp/src/process_tree/macos_helpers.rs`

Confirm late Codex code-mode host executable identity on macOS with one bounded ps call; arguments, missing identity, and truncated names cannot free slots.

New module: `true`.

- Required symbol: `pub(super) fn classify(`
- Required symbol: `fn confirmed_hosts(`
- Invocation: `crates/buzz-acp/src/process_tree.rs` → `classify`; exact call `let tree = macos_helpers::classify(tree);`; behavior test `six_finished_threads_reuse_five_slots_with_late_codex_hosts`; verify `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1`
- Verify: `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1`

## `crates/buzz-acp/src/pool/resident_helper_tests.rs`

Real local ACP/process regressions for sixth-thread admission, late resident host, nested child exit and work mentioning host in arguments on macOS.

New module: `true`.

- Required symbol: `six_finished_threads_reuse_five_slots_with_late_codex_hosts`
- Required symbol: `work_under_late_codex_host_holds_slot_until_child_exits`
- Required symbol: `work_mentioning_codex_host_in_arguments_still_holds_slot`
- Verify: `env -u BUZZ_ACP_SESSION_POLICY -u BUZZ_ACP_ALLOWED_RESPOND_TO -u BUZZ_ACP_LAZY_POOL -u BUZZ_ACP_IDLE_POOL_SLEEP cargo test -p buzz-acp resident_helper_tests -- --test-threads=1`

## `desktop/src/features/messages/lib/useComposerAutofocus.ts`

Preserve focus inside an open dialog/popover when late composer readiness or draft changes trigger autofocus; fixes the reproduced huddle smoke CI blocker.

New module: `false`.

- Required symbol: `useComposerAutofocus`
- Required symbol: `active.closest('[role="dialog"]')`
- Invocation: `desktop/src/features/messages/ui/MessageComposer.tsx` → `useComposerAutofocus`; exact call ` useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled);`; behavior test `late composer readiness preserves focus on a dialog switch; existing huddle assigns distinct agent voices E2E`; verify `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/useComposerAutofocus.test.mjs`
- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/useComposerAutofocus.test.mjs`
- Verify: `just desktop-test desktop-typecheck`

## `desktop/src/features/messages/lib/useComposerAutofocus.test.mjs`

Production-hook regression for late readiness and draft-switch focus retention in dialogs, preserving ordinary navigation autofocus.

New module: `true`.

- Required symbol: `late composer readiness preserves focus on a dialog switch`
- Required symbol: `a draft change preserves focus on an open dialog button`
- Required symbol: `initial mount and navigation from an ordinary button still focus the editor`
- Verify: `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/messages/lib/useComposerAutofocus.test.mjs`

## `desktop/tests/e2e/voice-note.spec.ts`

Bind duplicate voice-note playback assertions to stable emitted message IDs through live reordering; loop the short media fixture so natural completion cannot impersonate mutual pause.

New module: `false`.

- Required symbol: `const messageIds = await page.evaluate(`
- Required symbol: `const firstCard = page.locator(`
- Required symbol: `(player as HTMLAudioElement).loop = true;`
- Verify: `cd desktop && pnpm exec playwright test --project=smoke tests/e2e/voice-note.spec.ts --retries=0`
21 changes: 21 additions & 0 deletions crates/buzz-acp/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,27 @@ collection guarantee; successful ACP completion alone does not confirm publicati
Process tracking follows children under the worker and resident providers;
detached/reparented processes are outside that topology.

### macOS Codex service processes and completed slots

The Codex code-mode host may start after `session/new` and stay alive after a
turn. On macOS, a bounded executable-name lookup recognizes this service,
including installation paths containing spaces. Its real child processes still
protect the worker. Other platforms retain their existing classification; Linux's
truncated `comm` value is not accepted as executable identity.

The `resident_helper_tests` regressions run real local ACP adapters and shell
processes through pool return/claim. They cover six finished threads with five
slots, child exit beneath a live host, and ordinary shell work mentioning the
host path in arguments. The fixtures require no provider credentials or relay.

For native acceptance, run the patched harness with Codex, `--agents 5` and
`--session-policy thread` (or the equivalent desktop settings).
Complete a short task using code-mode tools in each of six distinct threads.
The sixth must start without restarting the agent. Then leave a real background
child running under code mode: its original worker must remain protected until
exit, and collection must still return to the original thread. This check needs
the rebuilt harness; source tests do not update the installed desktop app.

## Git bootstrap

The credential fixture requires Git 2.46 or newer for the `authtype` credential
Expand Down
9 changes: 2 additions & 7 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,14 +677,9 @@ impl AcpClient {
continue;
}
let children = tree.children(parent);
blocking.extend(crate::process_tree::blocking_pids(
children,
&self.resident_pids,
));
blocking.extend(tree.blocking_pids(parent, &self.resident_pids));
for child in children {
if self.resident_pids.contains(&child.pid)
|| crate::process_tree::is_resident_helper(&child.command)
{
if self.resident_pids.contains(&child.pid) || tree.is_resident_helper(child) {
self.resident_pids.insert(child.pid);
pending.push(child.pid);
}
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-acp/src/pool/background_work_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ use crate::queue::{EventQueue, QueuedEvent};
use nostr::{EventBuilder, Keys, Kind};
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[cfg(target_os = "macos")]
#[path = "resident_helper_tests.rs"]
mod resident_helper_tests;

async fn worker(index: usize) -> OwnedAgent {
OwnedAgent {
index,
Expand Down
164 changes: 164 additions & 0 deletions crates/buzz-acp/src/pool/resident_helper_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
//! Reproduce late Codex service residency through real ACP and pool return.
use super::*;

const ADAPTER: &str = r#"
import json, subprocess, sys
host = None
child = None
for line in sys.stdin:
req = json.loads(line)
method = req['method']
if method == 'initialize':
result = {'protocolVersion': 1, 'agentCapabilities': {}}
elif method == 'session/new':
result = {'sessionId': 'helper-session'}
elif method == 'session/prompt':
if host is None:
host = subprocess.Popen([sys.argv[1]], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
host.stdin.write('echo ready\n')
host.stdin.flush()
assert host.stdout.readline().strip() == 'ready'
text = req['params']['prompt'][0]['text']
if text == 'work':
host.stdin.write('sleep 60 & echo $!\n')
host.stdin.flush()
child = int(host.stdout.readline())
elif text == 'argument':
child = subprocess.Popen(['/bin/bash', '-c', 'read -r line', sys.argv[1]], stdin=subprocess.PIPE)
elif text == 'stop':
host.stdin.write('kill %d; wait %d; echo stopped\n' % (child, child))
host.stdin.flush()
assert host.stdout.readline().strip() == 'stopped'
result = {'stopReason': 'end_turn'}
else:
continue
print(json.dumps({'jsonrpc': '2.0', 'id': req['id'], 'result': result}), flush=True)
"#;

async fn helper_worker(index: usize, executable: &std::path::Path) -> OwnedAgent {
let mut agent = worker(index).await;
agent.acp = AcpClient::spawn(
"python3",
&[
"-u".into(),
"-c".into(),
ADAPTER.into(),
executable.to_string_lossy().into_owned(),
],
&[],
false,
)
.await
.unwrap();
agent.acp.initialize().await.unwrap();
agent.acp.capture_startup_children();
agent
.acp
.session_new("/tmp", vec![], None, None)
.await
.unwrap();
agent
}

fn helper_executable(dir: &tempfile::TempDir) -> std::path::PathBuf {
let path = dir
.path()
.join("Application Support")
.join("codex-code-mode-host");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
// Launch a real executable under this name; no argv-only impersonation.
std::os::unix::fs::symlink("/bin/bash", &path).unwrap();
path
}

async fn helper_prompt(agent: &mut OwnedAgent, text: &str) {
assert_eq!(
agent
.acp
.session_prompt_with_idle_timeout(
"helper-session",
text,
Duration::from_secs(2),
Duration::from_secs(5)
)
.await
.unwrap(),
StopReason::EndTurn,
);
}

fn return_after_turn(pool: &mut AgentPool, mut agent: OwnedAgent, scope: SessionScope) {
agent.background_context = Some(RuntimeHold {
scope,
blocking_pids: HashSet::new(),
continuation: None,
collection_pending: false,
});
pool.return_agent(agent);
}

#[tokio::test]
async fn six_finished_threads_reuse_five_slots_with_late_codex_hosts() {
let dir = tempfile::tempdir().unwrap();
let executable = helper_executable(&dir);
let mut slots = Vec::new();
for index in 0..5 {
slots.push(Some(helper_worker(index, &executable).await));
}
let mut pool = AgentPool::from_slots(slots);
for turn in 0..6 {
let scope = scope();
let mut agent = pool.try_claim(Some(&scope)).unwrap_or_else(|| {
panic!(
"finished threads exhausted the pool before turn {}",
turn + 1
)
});
helper_prompt(&mut agent, "idle").await;
return_after_turn(&mut pool, agent, scope);
}
assert!(
!pool.has_runtime_hold(),
"service processes must not retain completed turns"
);
}

#[tokio::test]
async fn work_under_late_codex_host_holds_slot_until_child_exits() {
let dir = tempfile::tempdir().unwrap();
let executable = helper_executable(&dir);
let agent = helper_worker(0, &executable).await;
let mut pool = AgentPool::from_slots(vec![Some(agent)]);
let original = scope();
let mut agent = pool.try_claim(Some(&original)).unwrap();
helper_prompt(&mut agent, "work").await;
return_after_turn(&mut pool, agent, original);
assert!(
pool.try_claim(Some(&scope())).is_none(),
"real child must retain the slot"
);
let agent = pool.agents[0].as_mut().unwrap();
helper_prompt(agent, "stop").await;
pool.queue_finished_background_work(&mut EventQueue::new(DedupMode::Queue));
assert!(
pool.try_claim(Some(&scope())).is_some(),
"child exit must release the slot while host stays alive"
);
}

#[tokio::test]
async fn work_mentioning_codex_host_in_arguments_still_holds_slot() {
let dir = tempfile::tempdir().unwrap();
let executable = helper_executable(&dir);
let mut agent = helper_worker(0, &executable).await;
helper_prompt(&mut agent, "argument").await;
let blocking = agent.acp.background_child_pids().unwrap();
assert_eq!(
blocking.len(),
1,
"ignore the host, but retain shell work mentioning its path"
);
let mut pool = AgentPool::from_slots(vec![None]);
return_after_turn(&mut pool, agent, scope());
assert!(pool.try_claim(Some(&scope())).is_none());
}
42 changes: 27 additions & 15 deletions crates/buzz-acp/src/process_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,28 @@ pub fn parse_ps_line(line: &str) -> Option<(u32, u32, String)> {
Some((pid, ppid, command))
}

/// Pids that should keep the worker out of idle-pool sleep.
///
/// `resident` holds children captured at worker start and helpers adopted
/// since (MCP). Everything else still parented by the worker is background
/// work: a subagent or a shell the turn left running.
pub fn blocking_pids(children: &[ChildProcess], resident: &HashSet<u32>) -> HashSet<u32> {
children
.iter()
.filter(|child| !resident.contains(&child.pid) && !is_resident_helper(&child.command))
.map(|child| child.pid)
.collect()
}

/// A single process snapshot, shared across a complete classification pass.
#[derive(Default)]
pub struct ProcessTree {
children: HashMap<u32, Vec<ChildProcess>>,
resident_helpers: HashSet<u32>,
}

impl ProcessTree {
/// Direct children that are work, excluding startup residents and helpers.
pub fn blocking_pids(&self, parent: u32, resident: &HashSet<u32>) -> HashSet<u32> {
self.children(parent)
.iter()
.filter(|child| !resident.contains(&child.pid) && !self.is_resident_helper(child))
.map(|child| child.pid)
.collect()
}

/// Helpers confirmed by executable identity, or the existing MCP classifier.
pub fn is_resident_helper(&self, child: &ChildProcess) -> bool {
self.resident_helpers.contains(&child.pid) || is_resident_helper(&child.command)
}

/// Direct children in this snapshot.
pub fn children(&self, parent: u32) -> &[ChildProcess] {
self.children.get(&parent).map(Vec::as_slice).unwrap_or(&[])
Expand Down Expand Up @@ -116,9 +118,15 @@ pub fn snapshot() -> Option<ProcessTree> {
std::time::Duration::from_millis(250),
4 * 1024 * 1024,
)?;
parse_snapshot(&output)
let tree = parse_snapshot(&output)?;
#[cfg(target_os = "macos")]
let tree = macos_helpers::classify(tree);
Some(tree)
}

#[cfg(target_os = "macos")]
mod macos_helpers;

#[cfg(unix)]
fn parse_snapshot(output: &str) -> Option<ProcessTree> {
let mut tree = ProcessTree::default();
Expand Down Expand Up @@ -259,7 +267,11 @@ mod tests {
];
let mut resident = HashSet::new();
resident.insert(4);
let blocking = blocking_pids(&children, &resident);
let tree = ProcessTree {
children: HashMap::from([(99, children)]),
..Default::default()
};
let blocking = tree.blocking_pids(99, &resident);
assert_eq!(blocking.len(), 2);
assert!(blocking.contains(&2));
assert!(blocking.contains(&3));
Expand Down
Loading
Loading