codexcw 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {codexcw-0.2.0 → codexcw-0.2.2}/Cargo.lock +9 -9
  2. {codexcw-0.2.0 → codexcw-0.2.2}/Cargo.toml +2 -2
  3. {codexcw-0.2.0 → codexcw-0.2.2}/PKG-INFO +1 -1
  4. {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/src/lib.rs +69 -19
  5. {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/tests/test_smoke.py +56 -0
  6. {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/uv.lock +1 -1
  7. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/group.rs +39 -13
  8. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/runner.rs +18 -16
  9. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/tests/runner_it.rs +240 -0
  10. {codexcw-0.2.0 → codexcw-0.2.2}/pyproject.toml +1 -1
  11. {codexcw-0.2.0 → codexcw-0.2.2}/README.md +0 -0
  12. {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/Cargo.toml +0 -0
  13. {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/README.md +0 -0
  14. {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/tests/fixtures/fake_codex.sh +0 -0
  15. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/Cargo.toml +0 -0
  16. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/README.md +0 -0
  17. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/examples/run.rs +0 -0
  18. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/account_usage.rs +0 -0
  19. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/args.rs +0 -0
  20. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/decoder.rs +0 -0
  21. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/error.rs +0 -0
  22. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/event.rs +0 -0
  23. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/lib.rs +0 -0
  24. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/request.rs +0 -0
  25. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/session.rs +0 -0
  26. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/tail.rs +0 -0
  27. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/tests/account_usage_it.rs +0 -0
  28. {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/tests/common/mod.rs +0 -0
  29. {codexcw-0.2.0 → codexcw-0.2.2}/python/codexcw/__init__.py +0 -0
  30. {codexcw-0.2.0 → codexcw-0.2.2}/python/codexcw/_codexcw.pyi +0 -0
  31. {codexcw-0.2.0 → codexcw-0.2.2}/python/codexcw/aio.py +0 -0
  32. {codexcw-0.2.0 → codexcw-0.2.2}/python/codexcw/py.typed +0 -0
@@ -22,7 +22,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
22
22
 
23
23
  [[package]]
24
24
  name = "codexcw"
25
- version = "0.2.0"
25
+ version = "0.2.2"
26
26
  dependencies = [
27
27
  "serde",
28
28
  "serde_json",
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "codexcw-node"
38
- version = "0.2.0"
38
+ version = "0.2.2"
39
39
  dependencies = [
40
40
  "codexcw",
41
41
  "napi",
@@ -47,7 +47,7 @@ dependencies = [
47
47
 
48
48
  [[package]]
49
49
  name = "codexcw-py"
50
- version = "0.2.0"
50
+ version = "0.2.2"
51
51
  dependencies = [
52
52
  "codexcw",
53
53
  "pyo3",
@@ -238,9 +238,9 @@ dependencies = [
238
238
 
239
239
  [[package]]
240
240
  name = "napi"
241
- version = "3.9.3"
241
+ version = "3.10.3"
242
242
  source = "registry+https://github.com/rust-lang/crates.io-index"
243
- checksum = "fbd9f9295f3ff5921e78a71222c3361a8216f7760b1a99a6ad4e8441de18bbb9"
243
+ checksum = "0c71997d6f7ad4a756966e452426848ac27d3b37a295302d63afbbcce0270f93"
244
244
  dependencies = [
245
245
  "bitflags",
246
246
  "ctor",
@@ -260,9 +260,9 @@ checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
260
260
 
261
261
  [[package]]
262
262
  name = "napi-derive"
263
- version = "3.5.6"
263
+ version = "3.5.8"
264
264
  source = "registry+https://github.com/rust-lang/crates.io-index"
265
- checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
265
+ checksum = "6730ee4e7b335eac6d7cf10fc2525d92743bd4713a13cb2e6667f0e97322d7c3"
266
266
  dependencies = [
267
267
  "convert_case",
268
268
  "ctor",
@@ -274,9 +274,9 @@ dependencies = [
274
274
 
275
275
  [[package]]
276
276
  name = "napi-derive-backend"
277
- version = "5.0.4"
277
+ version = "5.1.0"
278
278
  source = "registry+https://github.com/rust-lang/crates.io-index"
279
- checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
279
+ checksum = "db5ccc18b1b16d1049dcbd3e1a21fdfbf3ce720b8944979c5e1a76d4e30d9f9f"
280
280
  dependencies = [
281
281
  "convert_case",
282
282
  "proc-macro2",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/codexcw", "bindings/python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.0"
6
+ version = "0.2.2"
7
7
  edition = "2021"
8
8
  rust-version = "1.90"
9
9
  license = "CC0-1.0"
@@ -12,7 +12,7 @@ homepage = "https://github.com/c3-oss/codexcw"
12
12
  authors = ["c3-oss"]
13
13
 
14
14
  [workspace.dependencies]
15
- codexcw = { path = "crates/codexcw", version = "0.2.0" }
15
+ codexcw = { path = "crates/codexcw", version = "0.2.2" }
16
16
  tokio = { version = "1", default-features = false, features = [
17
17
  "process",
18
18
  "io-util",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codexcw
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: Operating System :: POSIX
@@ -420,12 +420,12 @@ struct AccountUsageReqData {
420
420
  }
421
421
 
422
422
  impl AccountUsageReqData {
423
- fn into_core(self) -> CoreAccountUsageRequest {
424
- CoreAccountUsageRequest {
423
+ fn into_core(self) -> Result<CoreAccountUsageRequest, PyError> {
424
+ Ok(CoreAccountUsageRequest {
425
425
  executable: self.executable,
426
426
  env: self.env.unwrap_or_default().into_iter().collect(),
427
- timeout: self.timeout.map(Duration::from_secs_f64),
428
- }
427
+ timeout: self.timeout.map(parse_account_usage_timeout).transpose()?,
428
+ })
429
429
  }
430
430
  }
431
431
 
@@ -467,6 +467,15 @@ fn invalid_request(message: String) -> PyError {
467
467
  }
468
468
  }
469
469
 
470
+ fn parse_account_usage_timeout(timeout: f64) -> Result<Duration, PyError> {
471
+ Duration::try_from_secs_f64(timeout).map_err(|_| {
472
+ invalid_request(
473
+ "account usage timeout must be finite, non-negative, and within the supported duration range"
474
+ .to_string(),
475
+ )
476
+ })
477
+ }
478
+
470
479
  fn to_py_usage(usage: &Usage) -> PyUsage {
471
480
  PyUsage {
472
481
  input_tokens: usage.input_tokens,
@@ -691,7 +700,15 @@ fn to_py_account_usage(usage: &CoreAccountUsage) -> PyAccountUsage {
691
700
  #[pyfunction]
692
701
  #[pyo3(signature = (req=None))]
693
702
  fn get_account_usage(py: Python<'_>, req: Option<AccountUsageReqData>) -> PyAccountUsageOutcome {
694
- let core_req = req.map(AccountUsageReqData::into_core).unwrap_or_default();
703
+ let core_req = match req.map(AccountUsageReqData::into_core).transpose() {
704
+ Ok(req) => req.unwrap_or_default(),
705
+ Err(error) => {
706
+ return PyAccountUsageOutcome {
707
+ result: None,
708
+ error: Some(error),
709
+ };
710
+ }
711
+ };
695
712
  let result = py.detach(|| runtime().block_on(core_get_account_usage(core_req)));
696
713
  match result {
697
714
  Ok(usage) => PyAccountUsageOutcome {
@@ -818,6 +835,9 @@ impl Session {
818
835
  struct LiveGroup {
819
836
  core: codexcw::Group,
820
837
  stream: Mutex<ReceiverStream<codexcw::RunEvent>>,
838
+ indices: Vec<usize>,
839
+ conversion_errors: Vec<(usize, PyError)>,
840
+ total: usize,
821
841
  }
822
842
 
823
843
  /// A batch of running `codex exec` processes.
@@ -827,12 +847,20 @@ pub struct Group {
827
847
  }
828
848
 
829
849
  impl Group {
830
- fn from_core(mut core: codexcw::Group) -> Self {
850
+ fn from_core(
851
+ mut core: codexcw::Group,
852
+ indices: Vec<usize>,
853
+ conversion_errors: Vec<(usize, PyError)>,
854
+ total: usize,
855
+ ) -> Self {
831
856
  let stream = core.events();
832
857
  Group {
833
858
  inner: LiveGroup {
834
859
  core,
835
860
  stream: Mutex::new(stream),
861
+ indices,
862
+ conversion_errors,
863
+ total,
836
864
  },
837
865
  }
838
866
  }
@@ -851,7 +879,7 @@ impl Group {
851
879
  });
852
880
  run_event.map(|re| PyRunEvent {
853
881
  run_id: re.run_id,
854
- index: re.index as u32,
882
+ index: self.inner.indices[re.index] as u32,
855
883
  event: to_py_event(&re.event),
856
884
  })
857
885
  }
@@ -867,14 +895,27 @@ impl Group {
867
895
  Ok(results) => results,
868
896
  Err(group_error) => group_error.results,
869
897
  });
870
- results
898
+ let mut mapped: Vec<Option<PyGroupResult>> = vec![None; self.inner.total];
899
+ for (index, error) in &self.inner.conversion_errors {
900
+ mapped[*index] = Some(PyGroupResult {
901
+ index: *index as u32,
902
+ run_id: String::new(),
903
+ result: None,
904
+ error: Some(error.clone()),
905
+ });
906
+ }
907
+ for result in results {
908
+ let index = self.inner.indices[result.index];
909
+ mapped[index] = Some(PyGroupResult {
910
+ index: index as u32,
911
+ run_id: result.run_id,
912
+ result: result.result.as_ref().map(to_py_result),
913
+ error: result.error.as_ref().map(to_py_error),
914
+ });
915
+ }
916
+ mapped
871
917
  .into_iter()
872
- .map(|r| PyGroupResult {
873
- index: r.index as u32,
874
- run_id: r.run_id,
875
- result: r.result.as_ref().map(to_py_result),
876
- error: r.error.as_ref().map(to_py_error),
877
- })
918
+ .map(|result| result.expect("group result missing"))
878
919
  .collect()
879
920
  }
880
921
 
@@ -998,10 +1039,19 @@ impl Runner {
998
1039
  max_concurrent: Option<u32>,
999
1040
  event_buffer: Option<u32>,
1000
1041
  ) -> Group {
1001
- let core_reqs: Vec<Request> = reqs
1002
- .into_iter()
1003
- .map(|r| r.into_core().unwrap_or_default())
1004
- .collect();
1042
+ let total = reqs.len();
1043
+ let mut core_reqs = Vec::with_capacity(reqs.len());
1044
+ let mut indices = Vec::with_capacity(reqs.len());
1045
+ let mut conversion_errors = Vec::new();
1046
+ for (index, req) in reqs.into_iter().enumerate() {
1047
+ match req.into_core() {
1048
+ Ok(request) => {
1049
+ core_reqs.push(request);
1050
+ indices.push(index);
1051
+ }
1052
+ Err(error) => conversion_errors.push((index, error)),
1053
+ }
1054
+ }
1005
1055
  let mut many = ManyOptions::default();
1006
1056
  if let Some(n) = max_concurrent {
1007
1057
  many.max_concurrent = n as usize;
@@ -1011,7 +1061,7 @@ impl Runner {
1011
1061
  }
1012
1062
  let runner = self.core.clone();
1013
1063
  let core_group = py.detach(move || runtime().block_on(runner.run_many(core_reqs, many)));
1014
- Group::from_core(core_group)
1064
+ Group::from_core(core_group, indices, conversion_errors, total)
1015
1065
  }
1016
1066
  }
1017
1067
 
@@ -148,6 +148,38 @@ def test_run_many_collects_results(tmp_path):
148
148
  assert result.result.final_message == "Oi."
149
149
 
150
150
 
151
+ def test_run_many_preserves_request_conversion_errors(tmp_path):
152
+ runner, _, _ = _runner_with_capture(tmp_path)
153
+
154
+ group = runner.run_many(
155
+ [
156
+ Request(prompt="bad sandbox", sandbox="bogus"),
157
+ Request(prompt="valid"),
158
+ Request(prompt="bad approval", approval="bogus"),
159
+ ]
160
+ )
161
+
162
+ event_indices = [run_event.index for run_event in group.events()]
163
+ results = group.wait()
164
+
165
+ assert [result.index for result in results] == [0, 1, 2]
166
+ assert event_indices
167
+ assert set(event_indices) == {1}
168
+
169
+ assert isinstance(results[0].error, CodexcwError)
170
+ assert results[0].error.kind == "invalidRequest"
171
+ assert "unknown sandbox mode: bogus" in str(results[0].error)
172
+ assert results[0].result is None
173
+
174
+ assert results[1].error is None
175
+ assert results[1].result.final_message == "Oi."
176
+
177
+ assert isinstance(results[2].error, CodexcwError)
178
+ assert results[2].error.kind == "invalidRequest"
179
+ assert "unknown approval policy: bogus" in str(results[2].error)
180
+ assert results[2].result is None
181
+
182
+
151
183
  def test_get_account_usage_reads_limits(tmp_path):
152
184
  fake = _usage_fake(tmp_path)
153
185
  args_file = tmp_path / "usage-args.txt"
@@ -214,6 +246,18 @@ done
214
246
  assert "timeout" in str(excinfo.value)
215
247
 
216
248
 
249
+ @pytest.mark.parametrize(
250
+ "timeout",
251
+ [-1.0, float("nan"), float("inf"), float("-inf"), sys.float_info.max],
252
+ )
253
+ def test_get_account_usage_rejects_invalid_timeout(timeout):
254
+ with pytest.raises(CodexcwError) as excinfo:
255
+ get_account_usage(AccountUsageRequest(timeout=timeout))
256
+
257
+ assert excinfo.value.kind == "invalidRequest"
258
+ assert "account usage timeout must be finite, non-negative" in str(excinfo.value)
259
+
260
+
217
261
  async def test_async_run_and_stream(tmp_path):
218
262
  args_file = tmp_path / "args.txt"
219
263
  stdin_file = tmp_path / "stdin.txt"
@@ -251,6 +295,18 @@ async def test_async_get_account_usage(tmp_path):
251
295
  assert usage.token_usage.daily_usage_buckets[0].tokens == "42"
252
296
 
253
297
 
298
+ @pytest.mark.parametrize(
299
+ "timeout",
300
+ [-1.0, float("nan"), float("inf"), float("-inf"), sys.float_info.max],
301
+ )
302
+ async def test_async_get_account_usage_rejects_invalid_timeout(timeout):
303
+ with pytest.raises(CodexcwError) as excinfo:
304
+ await codexcw.aio.get_account_usage(AccountUsageRequest(timeout=timeout))
305
+
306
+ assert excinfo.value.kind == "invalidRequest"
307
+ assert "account usage timeout must be finite, non-negative" in str(excinfo.value)
308
+
309
+
254
310
  @pytest.mark.skipif(
255
311
  os.environ.get("CODEXCW_LIVE_CODEX") != "1",
256
312
  reason="set CODEXCW_LIVE_CODEX=1 to run against the real codex executable",
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "codexcw"
20
- version = "0.2.0"
20
+ version = "0.2.2"
21
21
  source = { editable = "." }
22
22
 
23
23
  [package.optional-dependencies]
@@ -106,10 +106,13 @@ impl Runner {
106
106
  pub async fn run_many(&self, reqs: Vec<Request>, opts: ManyOptions) -> Group {
107
107
  let event_buffer = opts.event_buffer.unwrap_or(self.event_buffer()).max(1);
108
108
  let max_concurrent = opts.max_concurrent.max(1);
109
+ let (event_tx, event_rx) = mpsc::unbounded_channel();
109
110
  let (tx, rx) = mpsc::channel(event_buffer);
110
111
  let cancel = CancellationToken::new();
111
112
  let completion = Arc::new(Latch::new());
112
113
 
114
+ tokio::spawn(forward_events(event_rx, tx, cancel.clone()));
115
+
113
116
  let runner = self.clone();
114
117
  let task_cancel = cancel.clone();
115
118
  let task_completion = completion.clone();
@@ -120,7 +123,7 @@ impl Runner {
120
123
  reqs,
121
124
  max_concurrent,
122
125
  run_opts,
123
- tx,
126
+ event_tx,
124
127
  task_cancel,
125
128
  task_completion,
126
129
  )
@@ -140,7 +143,7 @@ async fn run_many_inner(
140
143
  reqs: Vec<Request>,
141
144
  max_concurrent: usize,
142
145
  run_opts: RunOptions,
143
- tx: mpsc::Sender<RunEvent>,
146
+ event_tx: mpsc::UnboundedSender<RunEvent>,
144
147
  cancel: CancellationToken,
145
148
  completion: Arc<Latch<Vec<GroupResult>>>,
146
149
  ) {
@@ -166,17 +169,17 @@ async fn run_many_inner(
166
169
  .await
167
170
  .expect("semaphore closed");
168
171
  let runner = runner.clone();
169
- let tx = tx.clone();
172
+ let event_tx = event_tx.clone();
170
173
  let cancel = cancel.clone();
171
174
  let run_opts = run_opts.clone();
172
175
  set.spawn(async move {
173
176
  let _permit = permit;
174
- let result = run_one(runner, index, req, run_opts, tx, cancel).await;
177
+ let result = run_one(runner, index, req, run_opts, event_tx, cancel).await;
175
178
  (index, result)
176
179
  });
177
180
  }
178
181
 
179
- drop(tx);
182
+ drop(event_tx);
180
183
 
181
184
  while let Some(joined) = set.join_next().await {
182
185
  if let Ok((index, result)) = joined {
@@ -200,12 +203,37 @@ async fn run_many_inner(
200
203
  completion.set(results);
201
204
  }
202
205
 
206
+ async fn forward_events(
207
+ mut source: mpsc::UnboundedReceiver<RunEvent>,
208
+ destination: mpsc::Sender<RunEvent>,
209
+ cancel: CancellationToken,
210
+ ) {
211
+ loop {
212
+ let event = tokio::select! {
213
+ biased;
214
+ _ = cancel.cancelled() => break,
215
+ event = source.recv() => event,
216
+ };
217
+ let Some(event) = event else {
218
+ break;
219
+ };
220
+ let sent = tokio::select! {
221
+ biased;
222
+ _ = cancel.cancelled() => break,
223
+ sent = destination.send(event) => sent,
224
+ };
225
+ if sent.is_err() {
226
+ break;
227
+ }
228
+ }
229
+ }
230
+
203
231
  async fn run_one(
204
232
  runner: Runner,
205
233
  index: usize,
206
234
  req: Request,
207
235
  run_opts: RunOptions,
208
- tx: mpsc::Sender<RunEvent>,
236
+ event_tx: mpsc::UnboundedSender<RunEvent>,
209
237
  cancel: CancellationToken,
210
238
  ) -> GroupResult {
211
239
  let mut session = match runner.start_opts(req, run_opts).await {
@@ -232,13 +260,11 @@ async fn run_one(
232
260
  event = session.next_event() => {
233
261
  match event {
234
262
  Some(event) => {
235
- let _ = tx
236
- .send(RunEvent {
237
- run_id: run_id.clone(),
238
- index,
239
- event,
240
- })
241
- .await;
263
+ let _ = event_tx.send(RunEvent {
264
+ run_id: run_id.clone(),
265
+ index,
266
+ event,
267
+ });
242
268
  }
243
269
  None => break,
244
270
  }
@@ -148,11 +148,14 @@ impl Runner {
148
148
  }
149
149
 
150
150
  let run_id = new_run_id();
151
+ let (event_tx, event_rx) = mpsc::unbounded_channel();
151
152
  let (tx, rx) = mpsc::channel(self.inner.event_buffer.max(1));
152
153
  let cancel = CancellationToken::new();
153
154
  let thread_id = Arc::new(Mutex::new(String::new()));
154
155
  let completion = Arc::new(Completion::new());
155
156
 
157
+ tokio::spawn(forward_events(event_rx, tx));
158
+
156
159
  let tail = Arc::new(TailBuffer::new(self.inner.stderr_limit));
157
160
  let stderr_task = tokio::spawn(drain_stderr(stderr, tail.clone()));
158
161
 
@@ -161,7 +164,7 @@ impl Runner {
161
164
  stdout,
162
165
  stderr_task,
163
166
  tail,
164
- tx,
167
+ event_tx,
165
168
  cancel: cancel.clone(),
166
169
  completion: completion.clone(),
167
170
  handler: opts.handler,
@@ -289,7 +292,7 @@ struct CollectCtx {
289
292
  stdout: ChildStdout,
290
293
  stderr_task: tokio::task::JoinHandle<()>,
291
294
  tail: Arc<TailBuffer>,
292
- tx: mpsc::Sender<Event>,
295
+ event_tx: mpsc::UnboundedSender<Event>,
293
296
  cancel: CancellationToken,
294
297
  completion: Arc<Completion>,
295
298
  handler: Option<Handler>,
@@ -309,13 +312,24 @@ pub(crate) async fn drain_stderr(mut stderr: ChildStderr, tail: Arc<TailBuffer>)
309
312
  }
310
313
  }
311
314
 
315
+ async fn forward_events(
316
+ mut source: mpsc::UnboundedReceiver<Event>,
317
+ destination: mpsc::Sender<Event>,
318
+ ) {
319
+ while let Some(event) = source.recv().await {
320
+ if destination.send(event).await.is_err() {
321
+ break;
322
+ }
323
+ }
324
+ }
325
+
312
326
  async fn collect(ctx: CollectCtx) {
313
327
  let CollectCtx {
314
328
  mut child,
315
329
  stdout,
316
330
  stderr_task,
317
331
  tail,
318
- tx,
332
+ event_tx,
319
333
  cancel,
320
334
  completion,
321
335
  handler,
@@ -406,18 +420,7 @@ async fn collect(ctx: CollectCtx) {
406
420
  last_event = Some(event.clone());
407
421
  events.push(event.clone());
408
422
 
409
- tokio::select! {
410
- biased;
411
- _ = cancel.cancelled() => {
412
- if run_err.is_none() {
413
- run_err = Some(Error::Cancelled);
414
- }
415
- break;
416
- }
417
- send = tx.send(event.clone()) => {
418
- let _ = send;
419
- }
420
- }
423
+ let _ = event_tx.send(event.clone());
421
424
 
422
425
  if let Some(handler) = &handler {
423
426
  if let Err(message) = handler(event.clone()).await {
@@ -459,7 +462,6 @@ async fn collect(ctx: CollectCtx) {
459
462
  report,
460
463
  error: run_err,
461
464
  });
462
- drop(tx);
463
465
  }
464
466
 
465
467
  fn classify_process_error(
@@ -7,6 +7,7 @@ mod common;
7
7
 
8
8
  use codexcw::{handler, Error, EventKind, EventPayload, ManyOptions, Request, RunOptions, Runner};
9
9
  use common::{read_args, write_fake_codex};
10
+ use tokio::time::{timeout, Duration};
10
11
 
11
12
  fn runner_for(fake: &common::FakeCodex) -> Runner {
12
13
  Runner::builder()
@@ -308,3 +309,242 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens
308
309
  .expect("second result");
309
310
  assert!(matches!(failed.error, Some(Error::PromptRequired)));
310
311
  }
312
+
313
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
314
+ async fn session_wait_completes_when_event_buffer_fills() {
315
+ let fake = write_fake_codex(
316
+ r#"record_args "$@"
317
+ cat >/dev/null
318
+ printf '%s\n' '{"type":"thread.started","thread_id":"thread-buffer"}'
319
+ printf '%s\n' '{"type":"turn.started"}'
320
+ printf '%s\n' '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"done"}}'
321
+ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}'
322
+ "#,
323
+ );
324
+ let runner = Runner::builder()
325
+ .executable(fake.executable())
326
+ .event_buffer(1)
327
+ .build();
328
+ let mut session = runner
329
+ .start(Request::new("buffer"))
330
+ .await
331
+ .expect("session starts");
332
+
333
+ let result = timeout(Duration::from_secs(5), session.wait())
334
+ .await
335
+ .expect("wait must not depend on event consumption")
336
+ .expect("run succeeds");
337
+
338
+ assert_eq!(result.events.len(), 4);
339
+ assert_eq!(result.final_message, "done");
340
+
341
+ let mut streamed = Vec::new();
342
+ while let Some(event) = session.next_event().await {
343
+ streamed.push(event.kind);
344
+ }
345
+ assert_eq!(
346
+ streamed,
347
+ [
348
+ EventKind::ThreadStarted,
349
+ EventKind::TurnStarted,
350
+ EventKind::ItemCompleted,
351
+ EventKind::TurnCompleted,
352
+ ]
353
+ );
354
+ }
355
+
356
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
357
+ async fn session_wait_handles_claude_shaped_events_without_consumption() {
358
+ let fake = write_fake_codex(
359
+ r#"record_args "$@"
360
+ cat >/dev/null
361
+ printf '%s\n' '{"type":"system","subtype":"init","session_id":"session-buffer"}'
362
+ printf '%s\n' '{"type":"rate_limit_event","session_id":"session-buffer"}'
363
+ printf '%s\n' '{"type":"result","subtype":"success","result":"done","session_id":"session-buffer"}'
364
+ "#,
365
+ );
366
+ let runner = Runner::builder()
367
+ .executable(fake.executable())
368
+ .event_buffer(1)
369
+ .build();
370
+ let session = runner
371
+ .start(Request::new("buffer"))
372
+ .await
373
+ .expect("session starts");
374
+
375
+ let result = timeout(Duration::from_secs(5), session.wait())
376
+ .await
377
+ .expect("wait must not depend on event shape")
378
+ .expect("run succeeds");
379
+ let kinds: Vec<&str> = result
380
+ .events
381
+ .iter()
382
+ .map(|event| event.kind.as_str())
383
+ .collect();
384
+
385
+ assert_eq!(kinds, ["system", "rate_limit_event", "result"]);
386
+ }
387
+
388
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
389
+ async fn group_wait_completes_when_event_buffer_fills() {
390
+ let fake = write_fake_codex(
391
+ r#"record_args "$@"
392
+ cat >/dev/null
393
+ printf '%s\n' '{"type":"thread.started","thread_id":"thread-group-buffer"}'
394
+ printf '%s\n' '{"type":"turn.started"}'
395
+ printf '%s\n' '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"done"}}'
396
+ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}'
397
+ "#,
398
+ );
399
+ let mut group = runner_for(&fake)
400
+ .run_many(
401
+ vec![Request::new("a"), Request::new("b")],
402
+ ManyOptions {
403
+ max_concurrent: 2,
404
+ event_buffer: Some(1),
405
+ ..Default::default()
406
+ },
407
+ )
408
+ .await;
409
+
410
+ let results = timeout(Duration::from_secs(5), group.wait())
411
+ .await
412
+ .expect("group wait must not depend on event consumption")
413
+ .expect("group succeeds");
414
+
415
+ assert_eq!(results.len(), 2);
416
+ assert!(results.iter().all(|result| result
417
+ .result
418
+ .as_ref()
419
+ .is_some_and(|report| report.events.len() == 4)));
420
+
421
+ let mut event_count = 0;
422
+ while group.next_event().await.is_some() {
423
+ event_count += 1;
424
+ }
425
+ assert_eq!(event_count, 8);
426
+ }
427
+
428
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
429
+ async fn group_cancel_releases_forwarding_blocked_by_backpressure() {
430
+ let fake = write_fake_codex(
431
+ r#"record_args "$@"
432
+ cat >/dev/null
433
+ printf '%s\n' '{"type":"thread.started","thread_id":"thread-group-cancel"}'
434
+ printf '%s\n' '{"type":"turn.started"}'
435
+ printf '%s\n' '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"working"}}'
436
+ mkfifo "$CODEXCW_WAIT_FIFO"
437
+ read -r _ < "$CODEXCW_WAIT_FIFO"
438
+ "#,
439
+ );
440
+ let wait_fifo = fake.stdin_file.with_file_name("wait.fifo");
441
+ let runner = Runner::builder()
442
+ .executable(fake.executable())
443
+ .env("CODEXCW_WAIT_FIFO", wait_fifo.to_str().unwrap())
444
+ .build();
445
+ let mut group = runner
446
+ .run_many(
447
+ vec![Request::new("cancel")],
448
+ ManyOptions {
449
+ event_buffer: Some(1),
450
+ ..Default::default()
451
+ },
452
+ )
453
+ .await;
454
+
455
+ timeout(Duration::from_secs(5), group.next_event())
456
+ .await
457
+ .expect("first event must arrive")
458
+ .expect("event stream remains open");
459
+ tokio::time::sleep(Duration::from_millis(100)).await;
460
+ group.cancel();
461
+
462
+ let error = timeout(Duration::from_secs(5), group.wait())
463
+ .await
464
+ .expect("cancelled group must finish under backpressure")
465
+ .expect_err("group is cancelled");
466
+ assert!(matches!(error.results[0].error, Some(Error::Cancelled)));
467
+
468
+ let mut remaining = 0;
469
+ loop {
470
+ match timeout(Duration::from_secs(2), group.next_event()).await {
471
+ Ok(Some(_)) => remaining += 1,
472
+ Ok(None) => break,
473
+ Err(_) => panic!("cancelled forwarder must close the event stream"),
474
+ }
475
+ }
476
+ assert!(
477
+ remaining <= 1,
478
+ "cancelled forwarder delivered {remaining} queued events"
479
+ );
480
+ }
481
+
482
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
483
+ async fn event_stream_preserves_order_with_small_buffer() {
484
+ let fake = write_fake_codex(
485
+ r#"record_args "$@"
486
+ cat >/dev/null
487
+ printf '%s\n' '{"type":"thread.started","thread_id":"thread-order"}'
488
+ printf '%s\n' '{"type":"turn.started"}'
489
+ printf '%s\n' '{"type":"item.started","item":{"id":"item_0","type":"agent_message"}}'
490
+ printf '%s\n' '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"done"}}'
491
+ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}'
492
+ "#,
493
+ );
494
+ let runner = Runner::builder()
495
+ .executable(fake.executable())
496
+ .event_buffer(1)
497
+ .build();
498
+ let mut session = runner
499
+ .start(Request::new("order"))
500
+ .await
501
+ .expect("session starts");
502
+ let mut kinds = Vec::new();
503
+
504
+ while let Some(event) = session.next_event().await {
505
+ kinds.push(event.kind);
506
+ }
507
+ session.wait().await.expect("run succeeds");
508
+
509
+ assert_eq!(
510
+ kinds,
511
+ [
512
+ EventKind::ThreadStarted,
513
+ EventKind::TurnStarted,
514
+ EventKind::ItemStarted,
515
+ EventKind::ItemCompleted,
516
+ EventKind::TurnCompleted,
517
+ ]
518
+ );
519
+ }
520
+
521
+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
522
+ async fn session_wait_handles_large_event_burst_without_consumption() {
523
+ let fake = write_fake_codex(
524
+ r#"record_args "$@"
525
+ cat >/dev/null
526
+ i=0
527
+ while [ "$i" -lt 4096 ]; do
528
+ printf '{"type":"burst.%s"}\n' "$i"
529
+ i=$((i + 1))
530
+ done
531
+ "#,
532
+ );
533
+ let runner = Runner::builder()
534
+ .executable(fake.executable())
535
+ .event_buffer(1)
536
+ .build();
537
+ let session = runner
538
+ .start(Request::new("burst"))
539
+ .await
540
+ .expect("session starts");
541
+
542
+ let result = timeout(Duration::from_secs(5), session.wait())
543
+ .await
544
+ .expect("large burst must not block completion")
545
+ .expect("run succeeds");
546
+
547
+ assert_eq!(result.events.len(), 4096);
548
+ assert_eq!(result.events[0].kind.as_str(), "burst.0");
549
+ assert_eq!(result.events[4095].kind.as_str(), "burst.4095");
550
+ }
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "codexcw"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Run the Codex CLI non-interactively from Python, backed by a Rust core."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes