Skip to content

Commit d4561d2

Browse files
committed
Merge pull request #831 from agentforce314/fix/ci-windows-and-openai3
2 parents acda316 + 6616d70 commit d4561d2

13 files changed

Lines changed: 240 additions & 62 deletions

.github/workflows/ci.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,21 @@ jobs:
9393
run: npm run typecheck
9494

9595
- name: Unit tests (ui + electron projects)
96-
run: npx vitest run
96+
# junit output feeds the same "Test Results" publisher as the pytest
97+
# legs (test-results.yml globs every artifact for *.xml), because the
98+
# desktop job's failures are otherwise invisible from outside: job
99+
# LOGS need an authenticated API call, artifacts do not — and this
100+
# job spent its first days red on windows-latest with nothing but a
101+
# ✗ to diagnose from.
102+
run: npx vitest run --reporter=default --reporter=junit --outputFile.junit=test-results/desktop.xml
103+
104+
- name: Upload test results
105+
if: (!cancelled())
106+
uses: actions/upload-artifact@v4
107+
with:
108+
name: Test Results (Desktop, ${{ matrix.os }})
109+
path: ui-desktop/test-results/*.xml
110+
retention-days: 3
97111

98112
harbor-adapter:
99113
# The harbor eval adapter (eval/harbor/clawcodex_agent.py) cannot be

pyproject.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,19 @@ classifiers = [
3030

3131
dependencies = [
3232
"anthropic>=0.116.0",
33-
"openai>=1.109.1",
33+
# Capped below 3.0 deliberately. openai 3.0.0 (2026-08-12) is the HTTPX2
34+
# migration: the SDK's default transport became httpx2 (httpx is no
35+
# longer even a dependency), and requests silently stop flowing through
36+
# the httpx layer we are coupled to on both sides of the SDK boundary —
37+
# verified against 3.0.0: a ``chat()`` under a patched ``httpx.Client.
38+
# send`` sails straight past it onto the real network. Concretely broken:
39+
# ``http_client=httpx.Client(verify=False)`` (openai/zai/openrouter/
40+
# deepseek + the spec registry's TLS-verify escape hatch) no longer
41+
# carries the transport, and ``src/providers/_stream_abort.py`` closes
42+
# the httpx ``Response`` behind SDK streams to break blocked reads —
43+
# internals httpx2 streams do not expose. Lift only with an httpx2
44+
# migration of the above (SDK guide: openai-python httpx2.md).
45+
"openai>=1.109.1,<3",
3446
"python-dotenv>=1.2.2",
3547
"rich>=13.9.4",
3648
"prompt-toolkit>=3.0.52",

requirements.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
# Runtime dependencies (production install).
22
# Dev/test-only tools live in requirements.dev.txt.
33
anthropic>=0.116.0
4-
openai>=1.109.1
4+
# Capped below 3.0: openai 3.0.0 (2026-08-12) moved the SDK onto httpx2, so
5+
# requests silently bypass the httpx layer we hook — the ``http_client=
6+
# httpx.Client(verify=False)`` escape hatch stops carrying the transport and
7+
# the stream-abort guard's httpx ``Response`` internals vanish. Lift after
8+
# migrating those to httpx2. See the longer note in pyproject.toml.
9+
openai>=1.109.1,<3
510
python-dotenv>=1.2.2
611
rich>=13.9.4
712
prompt-toolkit>=3.0.52

src/skills/loader.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -927,7 +927,17 @@ def activate_conditional_skills_for_paths(
927927
# the work for each conditional skill. Filter invalid entries here.
928928
rel_paths: list[str] = []
929929
for file_path in file_paths:
930-
rel_path = os.path.relpath(file_path, cwd)
930+
try:
931+
rel_path = os.path.relpath(file_path, cwd)
932+
except ValueError:
933+
# Windows raises ValueError from relpath when file_path and cwd
934+
# live on different drives (e.g. an absolute path like
935+
# ``C:\\etc\\passwd`` measured against a ``D:`` workspace, or the
936+
# POSIX-looking ``/etc/passwd`` that resolves onto the process's
937+
# current drive). A path we cannot express relative to cwd is by
938+
# definition outside it — skip it, same as the ``..``/absolute
939+
# guards below. (POSIX never raises here, so this is Windows-only.)
940+
continue
931941
if not rel_path or rel_path.startswith("..") or os.path.isabs(rel_path):
932942
continue
933943
rel_paths.append(rel_path)

tests/tasks/test_kill_shell_for_agent.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,19 @@
2020

2121

2222
def _spawn(cmd: str) -> subprocess.Popen:
23+
# Resolve bash and the process-group kwargs the way production does. A bare
24+
# ``["bash", ...]`` resolves via PATH, and on the Windows CI runner PATH's
25+
# first ``bash.exe`` is the WSL launcher (``C:\Windows\System32\bash.exe``),
26+
# which exits immediately with no distro installed — so a ``sleep 30`` here
27+
# would be dead on arrival and the "still running / untouched" assertions
28+
# would fail on Windows only. ``bash_argv`` resolves Git Bash explicitly;
29+
# ``popen_tree_kwargs`` supplies ``start_new_session`` on POSIX and
30+
# ``CREATE_NEW_PROCESS_GROUP`` on Windows (a bare ``start_new_session=True``
31+
# is POSIX-only), matching how the real spawner makes a killable tree.
32+
from src.utils.shell_platform import bash_argv, popen_tree_kwargs
33+
2334
return subprocess.Popen(
24-
["bash", "-lc", cmd], stdin=subprocess.DEVNULL, start_new_session=True
35+
bash_argv(cmd), stdin=subprocess.DEVNULL, **popen_tree_kwargs()
2536
)
2637

2738

tests/test_bash_timeout_vs_esc.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"""
3636
from __future__ import annotations
3737

38+
import sys
3839
import threading
3940
import time
4041
from pathlib import Path
@@ -46,6 +47,15 @@
4647
_run_bash_with_abort,
4748
)
4849
from src.utils.abort_controller import AbortController
50+
from src.utils.shell_platform import bash_argv
51+
52+
# Resolve bash the way production does. A bare ``["bash", ...]`` argv resolves
53+
# via PATH, and on the Windows CI runner PATH's first ``bash.exe`` is the WSL
54+
# launcher at ``C:\Windows\System32\bash.exe`` — which exits immediately with
55+
# no distro installed, so ``sleep``/``echo`` never run and every timing
56+
# assertion below fails on Windows only. ``bash_argv`` resolves Git Bash
57+
# explicitly (the same helper ``_run_bash_with_abort``'s production callers
58+
# use), keeping these supervisor tests on the real shell on every platform.
4959

5060

5161
def test_run_bash_with_abort_sets_only_timed_out_on_timeout(tmp_path: Path) -> None:
@@ -56,7 +66,7 @@ def test_run_bash_with_abort_sets_only_timed_out_on_timeout(tmp_path: Path) -> N
5666
"""
5767
start = time.monotonic()
5868
result = _run_bash_with_abort(
59-
["bash", "-lc", "sleep 3; echo done"],
69+
bash_argv("sleep 3; echo done"),
6070
cwd=str(tmp_path),
6171
timeout_s=1,
6272
abort_signal=None,
@@ -70,11 +80,27 @@ def test_run_bash_with_abort_sets_only_timed_out_on_timeout(tmp_path: Path) -> N
7080
"timeout). Conflating them makes the model treat timed-out commands "
7181
"as user-cancelled and retry them on resume."
7282
)
73-
# Sanity: SIGKILL takes effect near-instantly; the timeout deadline
74-
# is 1s + at most one ``_ABORT_POLL_INTERVAL_S`` (50ms) jitter.
75-
assert elapsed < 3.0, (
76-
f"timeout supervisor should have killed the process well under "
77-
f"the 3s sleep, took {elapsed:.2f}s"
83+
# Sanity: the supervisor must trip the 1s deadline, not wait out the full
84+
# 3s sleep. On POSIX the process-group kill closes the pipe at once, so
85+
# elapsed ~1s and the pre-port ``< 3.0`` bound also proves the kill beat
86+
# the sleep. On Windows, ``taskkill /T`` cannot reach the MSYS2 grandchild
87+
# that ``sleep`` becomes — its Windows parent is not ``bash.exe`` — so that
88+
# orphan keeps the stdout pipe open and ``communicate()`` blocks up to
89+
# ``_KILL_REAP_TIMEOUT_S`` draining it: elapsed lands at ~1s + drain,
90+
# indistinguishable by clock from a natural 3s completion. There the
91+
# ``timed_out``/``interrupted`` assertions above carry the regression
92+
# guard, and this bound only catches a supervisor that hangs past one
93+
# kill-drain.
94+
if sys.platform == "win32":
95+
from src.tool_system.tools.bash.bash_tool import _KILL_REAP_TIMEOUT_S
96+
97+
max_elapsed = 1.0 + _KILL_REAP_TIMEOUT_S + 1.5
98+
else:
99+
max_elapsed = 3.0
100+
assert elapsed < max_elapsed, (
101+
f"timeout supervisor should have tripped the 1s deadline rather than "
102+
f"waited out the 3s sleep; took {elapsed:.2f}s (budget "
103+
f"{max_elapsed:.1f}s)"
78104
)
79105

80106

@@ -96,7 +122,7 @@ def _trip_abort() -> None:
96122
threading.Thread(target=_trip_abort, daemon=True).start()
97123

98124
result = _run_bash_with_abort(
99-
["bash", "-lc", "sleep 3; echo done"],
125+
bash_argv("sleep 3; echo done"),
100126
cwd=str(tmp_path),
101127
timeout_s=30, # well above the abort time
102128
abort_signal=ctrl.signal,
@@ -236,7 +262,7 @@ def test_run_bash_with_abort_natural_exit_sets_neither_flag(tmp_path: Path) -> N
236262
where a code change accidentally sets one of them on the happy path.
237263
"""
238264
result = _run_bash_with_abort(
239-
["bash", "-lc", "echo hello"],
265+
bash_argv("echo hello"),
240266
cwd=str(tmp_path),
241267
timeout_s=10,
242268
abort_signal=None,
@@ -251,11 +277,7 @@ def test_run_bash_with_abort_natural_exit_sets_neither_flag(tmp_path: Path) -> N
251277
def test_detached_descendant_does_not_discard_captured_stdout(tmp_path: Path) -> None:
252278
"""A detached child may keep the output pipe open after Bash exits."""
253279
result = _run_bash_with_abort(
254-
[
255-
"bash",
256-
"-lc",
257-
"nohup sh -c 'sleep 4' >/dev/null 2>&1 & echo server-started",
258-
],
280+
bash_argv("nohup sh -c 'sleep 4' >/dev/null 2>&1 & echo server-started"),
259281
cwd=str(tmp_path),
260282
timeout_s=10,
261283
abort_signal=None,

tests/test_ch10_coordination_round4.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,26 +98,47 @@ class TestBashReaperMakesEligible(unittest.TestCase):
9898

9999
def test_reaper_terminal_state_is_eligible(self):
100100
import subprocess
101+
import tempfile
101102
import time
102103

103104
from src.tasks.eviction import is_eligible_for_eviction
104105
from src.tool_system.context import ToolContext
105106
from src.tool_system.tools.bash.background import spawn_background_bash
106107

107-
ctx = ToolContext(workspace_root=Path("/tmp"))
108+
# A real directory on every platform. ``Path("/tmp")`` reaches Popen's
109+
# ``cwd=`` as the drive-relative ``\tmp`` on Windows, and the CI runner
110+
# has no ``C:\tmp`` — spawn dies with NotADirectoryError (WinError 267)
111+
# before the reaper is ever exercised. (Machines where ``C:\tmp``
112+
# happens to exist hid this locally.)
113+
tmp = Path(tempfile.gettempdir())
114+
ctx = ToolContext(workspace_root=tmp)
108115
# A command that exits immediately.
109116
result = spawn_background_bash(
110-
command="true", cwd=Path("/tmp"), description="t", context=ctx,
117+
command="true", cwd=tmp, description="t", context=ctx,
111118
)
112119
task_id = result["backgroundTaskId"]
113-
# Wait for the reaper daemon to flip the state terminal.
114-
deadline = time.time() + 5.0
120+
# Wait for the reaper daemon to flip the state terminal AND deliver the
121+
# completion notification. The reaper does this in two separate registry
122+
# updates: _patch sets the terminal status + evict_after (schedule_
123+
# eviction), then enqueue_shell_notification sets notified=True. Breaking
124+
# on the terminal status alone races that second update — on a loaded
125+
# runner the loop can snapshot the state in the window where status is
126+
# terminal but notified is still False, then fail assertTrue(notified)
127+
# on that stale snapshot (flaky in a batch run, fine in isolation). Wait
128+
# for notified, which the reaper sets last and which implies the
129+
# terminal status + evict_after were already set.
130+
deadline = time.time() + 10.0
115131
state = None
116132
while time.time() < deadline:
117133
state = ctx.runtime_tasks.get(task_id)
118-
if state is not None and state.status in ("completed", "failed"):
134+
if (
135+
state is not None
136+
and state.status in ("completed", "failed")
137+
and state.notified
138+
and state.evict_after is not None
139+
):
119140
break
120-
time.sleep(0.05)
141+
time.sleep(0.02)
121142
self.assertIsNotNone(state)
122143
self.assertIn(state.status, ("completed", "failed"))
123144
# The reaper set notified + evict_after (WI-2), so it is eligible

tests/test_shell_completion_notification.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,13 +126,30 @@ class TestSpawnReapIntegration:
126126
"""critic #4: a REAL spawn_background_bash → reap → notification, not just
127127
the unit builder."""
128128

129-
def test_bg_bash_completion_notifies_the_model(self, tmp_path):
129+
def test_bg_bash_completion_notifies_the_model(self, tmp_path, monkeypatch):
130130
import time
131131
from pathlib import Path
132132

133+
import src.utils.task_notification as tn
133134
from src.tool_system.context import ToolContext, ToolUseOptions
134135
from src.tool_system.tools.bash.background import spawn_background_bash
135136

137+
# Observe delivery at the enqueue call, not by polling the global
138+
# queue: the queue is process-global and anything else alive in the
139+
# process may legitimately drain it (the agent-server worker loop —
140+
# exercised by tests/server/*, which run earlier — consumes exactly
141+
# this mode). CI run 31579051653 lost the race that way: the reaper
142+
# delivered, but 100 peek() polls saw only an already-drained queue.
143+
# The spy records AND forwards, so the production path stays intact.
144+
delivered = []
145+
real_enqueue = tn.enqueue_pending_notification
146+
147+
def _spy(*, value, mode="task-notification"):
148+
delivered.append(value)
149+
return real_enqueue(value=value, mode=mode)
150+
151+
monkeypatch.setattr(tn, "enqueue_pending_notification", _spy)
152+
136153
clear_pending_notifications()
137154
ctx = ToolContext(workspace_root=tmp_path)
138155
ctx.options = ToolUseOptions(tools=[])
@@ -141,15 +158,21 @@ def test_bg_bash_completion_notifies_the_model(self, tmp_path):
141158
description="quick fail", context=ctx,
142159
)
143160
task_id = out["backgroundTaskId"]
144-
# wait for the reap thread to deliver
145-
for _ in range(100):
146-
if peek_pending_notifications():
161+
# Wait for the reap thread to deliver. Deadline-based and generous:
162+
# a loaded 2-core runner can stall a bash spawn well past a flat 5s.
163+
deadline = time.monotonic() + 15.0
164+
while time.monotonic() < deadline:
165+
if delivered:
147166
break
148-
time.sleep(0.05)
149-
q = peek_pending_notifications()
150-
assert q, "no completion notification delivered"
151-
joined = "\n".join(str(n) for n in q)
167+
time.sleep(0.02)
168+
assert delivered, "no completion notification delivered"
169+
joined = "\n".join(delivered)
152170
assert 'Background command "quick fail" failed with exit code 3' in joined
171+
# The check-and-set committed before the enqueue: the registry state
172+
# must show notified=True (and the terminal facts the envelope claims).
173+
st = ctx.runtime_tasks.get(task_id)
174+
assert st is not None and st.notified is True
175+
assert st.status == "failed" and st.exit_code == 3
153176
clear_pending_notifications()
154177

155178

tests/test_stream_watchdog.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,27 @@
2424
)
2525

2626

27+
def _await_fire(watchdog: StreamWatchdog, response: MagicMock, timeout: float = 5.0) -> None:
28+
"""Wait (bounded) until the watchdog has fired AND closed the response.
29+
30+
``_on_timeout`` sets ``fired`` under the lock but closes the response
31+
after releasing it, so there is a real window where ``fired`` is True
32+
and ``close()`` hasn't happened yet — that ordering is deliberate (the
33+
consumer must be able to classify a timeout the moment it is decided).
34+
A fixed ``time.sleep`` then ``close.assert_called()`` races that window
35+
plus Windows' ~15.6ms timer granularity and runner scheduling stalls
36+
(the observed CI flake). Polling keeps the assertion ("a fire closes
37+
the response") while dropping the bet on scheduler punctuality. The
38+
caller still asserts afterward, so a genuine no-fire/no-close bug fails
39+
loudly at the deadline rather than hanging.
40+
"""
41+
deadline = time.monotonic() + timeout
42+
while time.monotonic() < deadline:
43+
if watchdog.fired and response.close.called:
44+
return
45+
time.sleep(0.01)
46+
47+
2748
class TestStreamIdleTimeoutResolution(unittest.TestCase):
2849
"""Env-var resolution for ``CLAUDE_STREAM_IDLE_TIMEOUT_MS``."""
2950

@@ -202,7 +223,7 @@ def test_survives_first_event_wait_then_fires_on_inter_event_idle(self):
202223
self.assertFalse(watchdog.fired, "must not fire during first-event grace")
203224
resp.close.assert_not_called()
204225
watchdog.reset() # first event arrived → tighten to inter-event
205-
time.sleep(0.25) # past inter-event now
226+
_await_fire(watchdog, resp) # inter-event idle lapses
206227
self.assertTrue(watchdog.fired, "must fire on inter-event idle after start")
207228
resp.close.assert_called()
208229
watchdog.disarm()
@@ -217,7 +238,7 @@ def test_first_event_timeout_still_fires_a_truly_dead_stream(self):
217238
stream, timeout_s=0.1, first_event_timeout_s=0.2
218239
)
219240
watchdog.arm()
220-
time.sleep(0.35) # never any event; first-event grace lapses
241+
_await_fire(watchdog, resp) # never any event; first-event grace lapses
221242
self.assertTrue(watchdog.fired)
222243
resp.close.assert_called()
223244
watchdog.disarm()
@@ -246,8 +267,12 @@ def test_byte_progress_prevents_fire(self):
246267
response.num_bytes_downloaded += 128
247268
self.assertFalse(watchdog.fired, "byte progress must re-arm, not fire")
248269
response.close.assert_not_called()
249-
# Once bytes stop, the next deadline fires.
250-
time.sleep(0.2)
270+
# Once bytes stop, the next deadline fires. Polled, not slept: the
271+
# last byte bump re-arms one more ~0.08s deadline, and a fixed 0.2s
272+
# sleep left <0.1s margin for the timer thread on a loaded runner —
273+
# plus the fired-vs-close window (see _await_fire) — the exact
274+
# windows-latest CI failure ("Expected 'close' to have been called").
275+
_await_fire(watchdog, response)
251276
self.assertTrue(watchdog.fired)
252277
response.close.assert_called()
253278
watchdog.disarm()
@@ -263,7 +288,7 @@ def test_no_byte_progress_still_fires(self):
263288
stream, timeout_s=0.08, first_event_timeout_s=0.08
264289
)
265290
watchdog.arm()
266-
time.sleep(0.25)
291+
_await_fire(watchdog, response)
267292
self.assertTrue(watchdog.fired)
268293
response.close.assert_called()
269294
watchdog.disarm()

0 commit comments

Comments
 (0)