Count Codex background terminals
This commit is contained in:
1 parent
cbae7ee8c0
commit
827a30768c
5 files changed
+357
-31
No files matched your search
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user