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.
- {codexcw-0.2.0 → codexcw-0.2.2}/Cargo.lock +9 -9
- {codexcw-0.2.0 → codexcw-0.2.2}/Cargo.toml +2 -2
- {codexcw-0.2.0 → codexcw-0.2.2}/PKG-INFO +1 -1
- {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/src/lib.rs +69 -19
- {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/tests/test_smoke.py +56 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/uv.lock +1 -1
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/group.rs +39 -13
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/runner.rs +18 -16
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/tests/runner_it.rs +240 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/pyproject.toml +1 -1
- {codexcw-0.2.0 → codexcw-0.2.2}/README.md +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/Cargo.toml +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/README.md +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/bindings/python/tests/fixtures/fake_codex.sh +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/Cargo.toml +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/README.md +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/examples/run.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/account_usage.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/args.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/decoder.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/error.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/event.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/lib.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/request.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/session.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/src/tail.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/tests/account_usage_it.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/crates/codexcw/tests/common/mod.rs +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/python/codexcw/__init__.py +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/python/codexcw/_codexcw.pyi +0 -0
- {codexcw-0.2.0 → codexcw-0.2.2}/python/codexcw/aio.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
241
|
+
version = "3.10.3"
|
|
242
242
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
243
|
-
checksum = "
|
|
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.
|
|
263
|
+
version = "3.5.8"
|
|
264
264
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
265
|
-
checksum = "
|
|
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
|
|
277
|
+
version = "5.1.0"
|
|
278
278
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
279
|
-
checksum = "
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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(
|
|
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).
|
|
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(
|
|
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
|
-
|
|
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(|
|
|
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
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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 _ =
|
|
236
|
-
.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|