Count Codex background terminals

This commit is contained in:
iris-ai committed 2026-09-16 01:07:40 -04:00
1 parent cbae7ee8c0
commit 827a30768c
5 files changed
+357 -31

No files matched your search

+5
View File
@@ -277,6 +277,11 @@ written, and the fold uses that same predicate to decide a reply is settled.
subagent's own transcript; auto-resume can only schedule against a session. subagent's own transcript; auto-resume can only schedule against a session.
That is the case where the main agent is idle and a background Task is That is the case where the main agent is idle and a background Task is
still burning quota. still burning quota.
- **Codex's count is two id sets added together.** Open child thread ids come
from the subagent registry; live background command process ids come from
app-server's experimental `thread/backgroundTerminals/list`. The command set
is runtime state, refreshed at lifecycle edges and once a second while
nonempty. Never decrement it from an unmatched completion.
- **The status word and its colour are `sessionStatusWord` / - **The status word and its colour are `sessionStatusWord` /
`sessionStatusColour`**, shared by the list and the session screen. They `sessionStatusColour`**, shared by the list and the session screen. They
were two `when`s, and the second one silently missed `waiting`. were two `when`s, and the second one silently missed `waiting`.
+11 -6
View File
@@ -899,12 +899,17 @@ The ordinary notifications still supply outcomes and summaries, including when
one is ordered after the level already corrected the status. Older CLIs send no one is ordered after the level already corrected the status. Older CLIs send no
level and retain the edge fallback below. level and retain the edge fallback below.
Codex has no equivalent level for the whole session. Its collaboration Codex's collaboration lifecycle supplies the subagent half of the same fact:
lifecycle does have the fact the screen needs: the subagent registry counts the registry counts its open child threads, including ones found on disk after
its open child threads, including ones found on disk after adoption, and emits adoption. App-server's experimental `thread/backgroundTerminals/list` supplies
the same `backgroundTasks` event whenever that count changes. Backgrounded the command half as an authoritative set of process ids. The driver keeps that
Codex commands are not included because app-server exposes no session-level set only in memory, refreshes it at terminal lifecycle boundaries and once a
set for them; the UI does not infer a count from tool cards. second while nonempty, and adds its size to the open-child count. A response
for an unknown request cannot alter the set, and a lifecycle edge never
blindly decrements it. Starting a replacement app-server resets the command set
to zero; adopting one asks it for a fresh snapshot. Every total change emits
the same `backgroundTasks` event, and the UI does not infer a count from tool
cards.
What the notification is still used for is the status: it is what closes a task What the notification is still used for is the status: it is what closes a task
in `Status::Waiting`'s bookkeeping. Handled once, however many of the two in `Status::Waiting`'s bookkeeping. Handled once, however many of the two
+6 -3
View File
@@ -171,9 +171,12 @@ the registry contains an open child, and the last activity completion reports
on-disk id, an adopted driver can route and finish a child whose spawn record on-disk id, an adopted driver can route and finish a child whose spawn record
is already behind the durable stdout offset. The registry's open count is also is already behind the durable stdout offset. The registry's open count is also
Codex's `backgroundTasks` measurement: lifecycle changes send it through the Codex's `backgroundTasks` measurement: lifecycle changes send it through the
same event and session-summary fields as Claude's provider snapshot. Codex same event and session-summary fields as Claude's provider snapshot. The other
background commands are not counted because app-server supplies no complete part of that measurement is app-server's runtime
set of them. `thread/backgroundTerminals/list` set. Its process ids are held only in memory
and added to the open-child count; the driver refreshes the set at terminal
boundaries and while it remains nonempty, rather than decrementing for an
unmatched ending edge.
A subagent that was mid-flight when the backend restarted keeps working: A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and the registry reopens the existing transcript on the next child line, and
+299 -5
View File
@@ -9,7 +9,7 @@
mod translate; mod translate;
use std::collections::VecDeque; use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -35,6 +35,22 @@ const STDERR_LOG: &str = "codex-stderr.log";
const THREAD_FILE: &str = "codex-thread.json"; const THREAD_FILE: &str = "codex-thread.json";
const STATE_FILE: &str = "codex-state.json"; const STATE_FILE: &str = "codex-state.json";
const POLL: std::time::Duration = std::time::Duration::from_millis(50); const POLL: std::time::Duration = std::time::Duration::from_millis(50);
const BACKGROUND_POLL: std::time::Duration = std::time::Duration::from_secs(1);
type BackgroundProcesses = Arc<Mutex<HashSet<String>>>;
#[derive(Default)]
struct BackgroundQuery {
pending: Option<PendingBackgroundQuery>,
unsupported: bool,
}
struct PendingBackgroundQuery {
request: String,
thread: String,
found: HashSet<String>,
dirty: bool,
}
#[derive(Clone, Default, Serialize, Deserialize)] #[derive(Clone, Default, Serialize, Deserialize)]
struct Waiting { struct Waiting {
@@ -108,6 +124,8 @@ struct Inner {
transport: Transport, transport: Transport,
session_dir: PathBuf, session_dir: PathBuf,
subagents: Arc<Subagents>, subagents: Arc<Subagents>,
background_processes: BackgroundProcesses,
background_query: Mutex<BackgroundQuery>,
reading: AtomicBool, reading: AtomicBool,
} }
@@ -175,6 +193,7 @@ impl CodexDriver {
} }
}); });
let background_processes = Arc::new(Mutex::new(HashSet::new()));
let inner = Arc::new(Inner { let inner = Arc::new(Inner {
sink, sink,
state: Mutex::new(state), state: Mutex::new(state),
@@ -187,24 +206,30 @@ impl CodexDriver {
transport, transport,
session_dir: session_dir.to_path_buf(), session_dir: session_dir.to_path_buf(),
subagents, subagents,
background_processes,
background_query: Mutex::new(BackgroundQuery::default()),
reading: AtomicBool::new(true), reading: AtomicBool::new(true),
}); });
if started_here { if started_here {
begin_initialization(&inner); begin_initialization(&inner);
let _ = inner.sink.send(Event::BackgroundTasks { count: 0 });
let _ = inner.sink.send(Event::Status { let _ = inner.sink.send(Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
}); });
} }
save_state(&inner); save_state(&inner);
spawn_follower(Arc::clone(&inner), record); spawn_follower(Arc::clone(&inner), record);
if !started_here {
refresh_background_terminals(&inner);
}
Ok(Self { inner }) Ok(Self { inner })
} }
} }
impl Driver for CodexDriver { impl Driver for CodexDriver {
fn background_tasks(&self) -> Option<usize> { fn background_tasks(&self) -> Option<usize> {
Some(self.inner.subagents.open_count()) Some(background_task_count(&self.inner))
} }
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) { fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
@@ -441,11 +466,14 @@ fn begin_initialization(inner: &Arc<Inner>) {
json!({ json!({
"id": id, "id": id,
"method": "initialize", "method": "initialize",
"params": {"clientInfo": { "params": {
"clientInfo": {
"name": "ai-app", "name": "ai-app",
"title": "AI Sessions", "title": "AI Sessions",
"version": env!("CARGO_PKG_VERSION") "version": env!("CARGO_PKG_VERSION")
}} },
"capabilities": {"experimentalApi": true}
}
}), }),
); );
} }
@@ -624,11 +652,150 @@ fn spawn_follower(inner: Arc<Inner>, record: process::Record) {
tokio::spawn(follow(inner, record, offset)); tokio::spawn(follow(inner, record, offset));
} }
fn background_task_count(inner: &Inner) -> usize {
inner.subagents.open_count() + inner.background_processes.lock().unwrap().len()
}
fn refresh_background_terminals(inner: &Inner) {
let Some(thread) = read_thread(&inner.session_dir) else {
return;
};
let request = {
let mut query = inner.background_query.lock().unwrap();
if query.unsupported {
return;
}
if let Some(pending) = &mut query.pending {
pending.dirty = true;
return;
}
let request = request_id();
query.pending = Some(PendingBackgroundQuery {
request: request.clone(),
thread: thread.clone(),
found: HashSet::new(),
dirty: false,
});
request
};
send_json(
inner,
json!({
"id": request,
"method": "thread/backgroundTerminals/list",
"params": {"threadId": thread, "limit": 100}
}),
);
}
fn handle_background_response(inner: &Inner, line: &Value) -> bool {
let Some(id) = line.get("id").and_then(Value::as_str) else {
return false;
};
let mut query = inner.background_query.lock().unwrap();
let Some(pending) = &mut query.pending else {
return false;
};
if pending.request != id {
return false;
}
if Some(&pending.thread) != read_thread(&inner.session_dir).as_ref() {
query.pending = None;
drop(query);
refresh_background_terminals(inner);
return true;
}
if line.get("error").is_some() {
query.unsupported = line.pointer("/error/code").and_then(Value::as_i64) == Some(-32601);
query.pending = None;
return true;
}
let Some(terminals) = line.pointer("/result/data").and_then(Value::as_array) else {
query.pending = None;
return true;
};
if !terminals
.iter()
.all(|terminal| terminal.get("processId").and_then(Value::as_str).is_some())
{
query.pending = None;
return true;
}
for process in terminals
.iter()
.filter_map(|terminal| terminal.get("processId").and_then(Value::as_str))
{
pending.found.insert(process.to_string());
}
if let Some(cursor) = line.pointer("/result/nextCursor").and_then(Value::as_str) {
let thread = pending.thread.clone();
let request = request_id();
pending.request.clone_from(&request);
drop(query);
send_json(
inner,
json!({
"id": request,
"method": "thread/backgroundTerminals/list",
"params": {"threadId": thread, "limit": 100, "cursor": cursor}
}),
);
return true;
}
let pending = query.pending.take().expect("matched pending query");
drop(query);
if pending.dirty {
refresh_background_terminals(inner);
return true;
}
let found = pending.found;
let changed = {
let mut processes = inner.background_processes.lock().unwrap();
if *processes == found {
false
} else {
*processes = found;
true
}
};
if changed {
let count = background_task_count(inner);
let _ = inner.sink.send(Event::BackgroundTasks { count });
let state = inner.state.lock().unwrap();
if !state.running && !state.closed {
let _ = inner.sink.send(Event::Status {
state: if count > 0 {
SessionStatus::Waiting
} else {
SessionStatus::Idle
},
});
}
}
true
}
fn background_terminals_may_have_changed(method: Option<&str>, params: &Value) -> bool {
matches!(
method,
Some("thread/started" | "turn/completed" | "item/commandExecution/terminalInteraction")
) || matches!(method, Some("item/completed" | "item.completed"))
&& params
.get("item")
.and_then(|item| item.get("type"))
.and_then(Value::as_str)
== Some("commandExecution")
}
async fn follow(inner: Arc<Inner>, mut record: process::Record, mut offset: u64) { async fn follow(inner: Arc<Inner>, mut record: process::Record, mut offset: u64) {
let stdout = inner.session_dir.join(STDOUT_LOG); let stdout = inner.session_dir.join(STDOUT_LOG);
let stderr = inner.session_dir.join(STDERR_LOG); let stderr = inner.session_dir.join(STDERR_LOG);
let mut translator = Translator::new( let mut translator = Translator::new(
Arc::clone(&inner.subagents), Arc::clone(&inner.subagents),
Arc::clone(&inner.background_processes),
read_thread(&inner.session_dir), read_thread(&inner.session_dir),
inner.state.lock().unwrap().active_turn.is_some(), inner.state.lock().unwrap().active_turn.is_some(),
); );
@@ -638,6 +805,7 @@ async fn follow(inner: Arc<Inner>, mut record: process::Record, mut offset: u64)
// quadratic in time and allocation. // quadratic in time and allocation.
let mut read_at = offset; let mut read_at = offset;
let mut pending = Vec::new(); let mut pending = Vec::new();
let mut last_background_poll = std::time::Instant::now();
while inner.reading.load(Ordering::SeqCst) { while inner.reading.load(Ordering::SeqCst) {
let (bytes, next) = match process::read_from(&stdout, read_at) { let (bytes, next) = match process::read_from(&stdout, read_at) {
Ok(read) => read, Ok(read) => read,
@@ -673,6 +841,12 @@ async fn follow(inner: Arc<Inner>, mut record: process::Record, mut offset: u64)
}; };
process::write(&inner.session_dir, &record); process::write(&inner.session_dir, &record);
} }
if !inner.background_processes.lock().unwrap().is_empty()
&& last_background_poll.elapsed() >= BACKGROUND_POLL
{
refresh_background_terminals(&inner);
last_background_poll = std::time::Instant::now();
}
match record.liveness() { match record.liveness() {
process::Liveness::Alive | process::Liveness::Unknown => {} process::Liveness::Alive | process::Liveness::Unknown => {}
process::Liveness::Dead if complete > 0 => {} process::Liveness::Dead if complete > 0 => {}
@@ -713,7 +887,7 @@ async fn follow(inner: Arc<Inner>, mut record: process::Record, mut offset: u64)
} }
fn handle_line(inner: &Arc<Inner>, translator: &mut Translator, line: &Value) { fn handle_line(inner: &Arc<Inner>, translator: &mut Translator, line: &Value) {
if line.get("id").is_some() { if line.get("id").is_some() && !handle_background_response(inner, line) {
handle_response(inner, line); handle_response(inner, line);
} }
let method = line.get("method").and_then(Value::as_str); let method = line.get("method").and_then(Value::as_str);
@@ -781,6 +955,9 @@ fn handle_line(inner: &Arc<Inner>, translator: &mut Translator, line: &Value) {
if parent && method == Some("turn/completed") { if parent && method == Some("turn/completed") {
dispatch_waiting(inner); dispatch_waiting(inner);
} }
if parent && background_terminals_may_have_changed(method, params) {
refresh_background_terminals(inner);
}
} }
/// Images embedded in a structured tool result, copied into the session before the translator's /// Images embedded in a structured tool result, copied into the session before the translator's
@@ -1364,6 +1541,112 @@ mod tests {
assert!(!missing_thread("thread is already running")); assert!(!missing_thread("thread is already running"));
} }
#[test]
fn background_terminal_snapshots_are_id_keyed_and_ignore_unknown_responses() {
let dir = tempfile::tempdir().expect("tempdir");
write_thread(dir.path(), "parent-thread");
let (sink, mut events) = mpsc::unbounded_channel();
let (to_child, mut requests) = mpsc::unbounded_channel();
let inner = Inner {
sink,
state: Mutex::new(ProtocolState::default()),
settings: Mutex::new(Settings {
model: None,
permission_mode: None,
effort: None,
}),
to_child,
transport: Transport::Here,
session_dir: dir.path().to_path_buf(),
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
background_processes: Arc::new(Mutex::new(HashSet::new())),
background_query: Mutex::new(BackgroundQuery::default()),
reading: AtomicBool::new(true),
};
refresh_background_terminals(&inner);
let request: Value = serde_json::from_str(&requests.try_recv().expect("list request"))
.expect("request json");
assert_eq!(request["method"], "thread/backgroundTerminals/list");
let request_id = request["id"].as_str().expect("request id");
assert!(handle_background_response(
&inner,
&json!({
"id": request_id,
"result": {
"data": [
{"processId": "process-a"},
{"processId": "process-b"}
],
"nextCursor": null
}
})
));
assert_eq!(background_task_count(&inner), 2);
assert_eq!(
events.try_recv().expect("count"),
Event::BackgroundTasks { count: 2 }
);
assert_eq!(
events.try_recv().expect("waiting status"),
Event::Status {
state: SessionStatus::Waiting
}
);
assert!(!handle_background_response(
&inner,
&json!({"id": "unknown-completion", "result": {"data": []}})
));
assert_eq!(background_task_count(&inner), 2);
assert!(events.try_recv().is_err());
refresh_background_terminals(&inner);
let request: Value = serde_json::from_str(&requests.try_recv().expect("malformed list"))
.expect("request json");
assert!(handle_background_response(
&inner,
&json!({"id": request["id"], "result": {}})
));
assert_eq!(background_task_count(&inner), 2);
assert!(events.try_recv().is_err());
refresh_background_terminals(&inner);
let request: Value =
serde_json::from_str(&requests.try_recv().expect("second list")).expect("request json");
refresh_background_terminals(&inner);
assert!(handle_background_response(
&inner,
&json!({
"id": request["id"],
"result": {
"data": [{"processId": "process-b"}],
"nextCursor": null
}
})
));
assert_eq!(background_task_count(&inner), 2);
assert!(events.try_recv().is_err());
let request: Value = serde_json::from_str(&requests.try_recv().expect("coalesced list"))
.expect("request json");
assert!(handle_background_response(
&inner,
&json!({
"id": request["id"],
"result": {
"data": [{"processId": "process-b"}],
"nextCursor": null
}
})
));
assert_eq!(background_task_count(&inner), 1);
assert_eq!(
events.try_recv().expect("smaller count"),
Event::BackgroundTasks { count: 1 }
);
}
#[test] #[test]
fn a_missing_thread_is_reported_then_replaced() { fn a_missing_thread_is_reported_then_replaced() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
@@ -1385,6 +1668,8 @@ mod tests {
transport: Transport::Here, transport: Transport::Here,
session_dir: dir.path().to_path_buf(), session_dir: dir.path().to_path_buf(),
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())), subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
background_processes: Arc::new(Mutex::new(HashSet::new())),
background_query: Mutex::new(BackgroundQuery::default()),
reading: AtomicBool::new(true), reading: AtomicBool::new(true),
}); });
@@ -1440,6 +1725,8 @@ mod tests {
transport: Transport::Here, transport: Transport::Here,
session_dir: dir.path().to_path_buf(), session_dir: dir.path().to_path_buf(),
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())), subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
background_processes: Arc::new(Mutex::new(HashSet::new())),
background_query: Mutex::new(BackgroundQuery::default()),
reading: AtomicBool::new(true), reading: AtomicBool::new(true),
}); });
let driver = CodexDriver { let driver = CodexDriver {
@@ -1505,6 +1792,8 @@ mod tests {
transport: Transport::Here, transport: Transport::Here,
session_dir: dir.path().to_path_buf(), session_dir: dir.path().to_path_buf(),
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())), subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
background_processes: Arc::new(Mutex::new(HashSet::new())),
background_query: Mutex::new(BackgroundQuery::default()),
reading: AtomicBool::new(true), reading: AtomicBool::new(true),
}); });
let driver = CodexDriver { let driver = CodexDriver {
@@ -1568,10 +1857,13 @@ mod tests {
transport: Transport::Here, transport: Transport::Here,
session_dir: dir.path().to_path_buf(), session_dir: dir.path().to_path_buf(),
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())), subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
background_processes: Arc::new(Mutex::new(HashSet::new())),
background_query: Mutex::new(BackgroundQuery::default()),
reading: AtomicBool::new(true), reading: AtomicBool::new(true),
}); });
let mut translator = Translator::new( let mut translator = Translator::new(
Arc::clone(&inner.subagents), Arc::clone(&inner.subagents),
Arc::clone(&inner.background_processes),
Some("missing-thread".to_string()), Some("missing-thread".to_string()),
false, false,
); );
@@ -1636,6 +1928,8 @@ mod tests {
transport: Transport::Here, transport: Transport::Here,
session_dir: dir.path().to_path_buf(), session_dir: dir.path().to_path_buf(),
subagents: Arc::new(Subagents::new(dir.path().to_path_buf())), subagents: Arc::new(Subagents::new(dir.path().to_path_buf())),
background_processes: Arc::new(Mutex::new(HashSet::new())),
background_query: Mutex::new(BackgroundQuery::default()),
reading: AtomicBool::new(true), reading: AtomicBool::new(true),
}); });
+36 -17
View File
@@ -5,7 +5,7 @@
//! ignore the rest; an added Codex item must not make a live session go deaf. //! ignore the rest; an added Codex item must not make a live session go deaf.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::{Arc, Mutex};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -18,6 +18,7 @@ pub(super) struct Translator {
completed: bool, completed: bool,
limited: bool, limited: bool,
subagents: Option<Arc<Subagents>>, subagents: Option<Arc<Subagents>>,
background_processes: Option<Arc<Mutex<HashSet<String>>>>,
children: HashMap<String, Translator>, children: HashMap<String, Translator>,
prompts: HashMap<String, String>, prompts: HashMap<String, String>,
async_messages: HashSet<String>, async_messages: HashSet<String>,
@@ -25,10 +26,16 @@ pub(super) struct Translator {
} }
impl Translator { impl Translator {
pub(super) fn new(subagents: Arc<Subagents>, thread_id: Option<String>, in_turn: bool) -> Self { pub(super) fn new(
subagents: Arc<Subagents>,
background_processes: Arc<Mutex<HashSet<String>>>,
thread_id: Option<String>,
in_turn: bool,
) -> Self {
Self { Self {
thread_id, thread_id,
subagents: Some(subagents), subagents: Some(subagents),
background_processes: Some(background_processes),
in_turn, in_turn,
..Self::default() ..Self::default()
} }
@@ -118,9 +125,14 @@ impl Translator {
} }
fn background_task_count(&self) -> Option<usize> { fn background_task_count(&self) -> Option<usize> {
self.subagents self.subagents.as_ref().map(|subagents| {
subagents.open_count()
+ self
.background_processes
.as_ref() .as_ref()
.map(|subagents| subagents.open_count()) .map(|processes| processes.lock().unwrap().len())
.unwrap_or(0)
})
} }
fn translate_line(&mut self, kind: Option<&str>, body: &Value, line: &Value) -> Vec<Event> { fn translate_line(&mut self, kind: Option<&str>, body: &Value, line: &Value) -> Vec<Event> {
@@ -242,11 +254,7 @@ impl Translator {
events.extend(self.failure(error)); events.extend(self.failure(error));
} }
events.push(Event::Status { events.push(Event::Status {
state: if self state: if self.background_task_count().is_some_and(|count| count > 0) {
.subagents
.as_ref()
.is_some_and(|subagents| subagents.any_open(true))
{
SessionStatus::Waiting SessionStatus::Waiting
} else { } else {
SessionStatus::Idle SessionStatus::Idle
@@ -390,13 +398,7 @@ impl Translator {
subagents.finish(id); subagents.finish(id);
} }
self.prompts.remove(id); self.prompts.remove(id);
if was_open if was_open && !self.in_turn && self.background_task_count() == Some(0) {
&& !self.in_turn
&& self
.subagents
.as_ref()
.is_some_and(|subagents| !subagents.any_open(true))
{
vec![Event::Status { vec![Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
}] }]
@@ -1216,8 +1218,10 @@ mod tests {
fn codex_subagents_get_their_own_transcripts_and_hold_the_parent_waiting() { fn codex_subagents_get_their_own_transcripts_and_hold_the_parent_waiting() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let subagents = Arc::new(Subagents::new(dir.path().to_path_buf())); let subagents = Arc::new(Subagents::new(dir.path().to_path_buf()));
let background_processes = Arc::new(Mutex::new(HashSet::new()));
let mut translator = Translator::new( let mut translator = Translator::new(
Arc::clone(&subagents), Arc::clone(&subagents),
Arc::clone(&background_processes),
Some("parent-thread".to_string()), Some("parent-thread".to_string()),
false, false,
); );
@@ -1313,8 +1317,10 @@ mod tests {
fn codex_reports_each_change_to_its_live_background_count() { fn codex_reports_each_change_to_its_live_background_count() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let subagents = Arc::new(Subagents::new(dir.path().to_path_buf())); let subagents = Arc::new(Subagents::new(dir.path().to_path_buf()));
let background_processes = Arc::new(Mutex::new(HashSet::new()));
let mut translator = Translator::new( let mut translator = Translator::new(
Arc::clone(&subagents), Arc::clone(&subagents),
Arc::clone(&background_processes),
Some("parent-thread".to_string()), Some("parent-thread".to_string()),
false, false,
); );
@@ -1335,7 +1341,11 @@ mod tests {
})); }));
assert_eq!(events.last(), Some(&Event::BackgroundTasks { count })); assert_eq!(events.last(), Some(&Event::BackgroundTasks { count }));
} }
for (child, count) in [("child-a", 1), ("child-b", 0)] { background_processes
.lock()
.unwrap()
.insert("command-a".to_string());
for (child, count) in [("child-a", 2), ("child-b", 1)] {
let events = translator.translate(&json!({ let events = translator.translate(&json!({
"method": "item/completed", "method": "item/completed",
"params": { "params": {
@@ -1350,6 +1360,14 @@ mod tests {
} }
})); }));
assert_eq!(events.last(), Some(&Event::BackgroundTasks { count })); assert_eq!(events.last(), Some(&Event::BackgroundTasks { count }));
assert!(!events.iter().any(|event| {
matches!(
event,
Event::Status {
state: SessionStatus::Idle
}
)
}));
} }
} }
@@ -1490,6 +1508,7 @@ mod tests {
subagents.start("child-thread", "child", Some("work")); subagents.start("child-thread", "child", Some("work"));
let mut translator = Translator::new( let mut translator = Translator::new(
Arc::clone(&subagents), Arc::clone(&subagents),
Arc::new(Mutex::new(HashSet::new())),
Some("parent-thread".to_string()), Some("parent-thread".to_string()),
true, true,
); );