Observe worker entry and unfinished work instead of subsecond elapsed-time bounds. Release workers in finally blocks. Verify the resume lock with nonblocking acquire while teardown remains parked.
102 lines
3.3 KiB
Python
102 lines
3.3 KiB
Python
"""Regression coverage for timed-out delegation teardown."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from types import SimpleNamespace
|
|
|
|
from tools import delegate_tool
|
|
|
|
|
|
class _SlowUnwindingChild:
|
|
def __init__(self) -> None:
|
|
self.tool_progress_callback = None
|
|
self._credential_pool = None
|
|
self._delegate_saved_tool_names = []
|
|
self._delegate_role = "leaf"
|
|
self._delegate_depth = 1
|
|
self._subagent_id = None
|
|
self.model = "test-model"
|
|
self.session_prompt_tokens = 0
|
|
self.session_completion_tokens = 0
|
|
self.session_estimated_cost_usd = 0.0
|
|
self.session_cost_status = "unknown"
|
|
self.started = threading.Event()
|
|
self.interrupted = threading.Event()
|
|
self.unwinding = threading.Event()
|
|
self.allow_finish = threading.Event()
|
|
self.finished = threading.Event()
|
|
self.closed = threading.Event()
|
|
self.close_while_running = False
|
|
|
|
def run_conversation(self, **_kwargs):
|
|
self.started.set()
|
|
assert self.interrupted.wait(timeout=30)
|
|
# Model the real child turn's finally path: it still performs session
|
|
# activity/SQLite cleanup after the parent requests interruption.
|
|
self.unwinding.set()
|
|
assert self.allow_finish.wait(timeout=30)
|
|
self.finished.set()
|
|
return {
|
|
"final_response": "",
|
|
"completed": False,
|
|
"interrupted": True,
|
|
"api_calls": 1,
|
|
"messages": [],
|
|
}
|
|
|
|
def hard_interrupt(self, _reason=None):
|
|
self.interrupted.set()
|
|
|
|
def get_activity_summary(self):
|
|
return {"api_call_count": 1}
|
|
|
|
def close(self):
|
|
if not self.finished.is_set():
|
|
self.close_while_running = True
|
|
self.closed.set()
|
|
|
|
|
|
def test_timeout_does_not_close_child_while_worker_is_unwinding(monkeypatch):
|
|
child = _SlowUnwindingChild()
|
|
parent = SimpleNamespace(
|
|
session_id="parent-timeout-test",
|
|
_current_task_id=None,
|
|
_active_children=[child],
|
|
_active_children_lock=threading.Lock(),
|
|
)
|
|
monkeypatch.setattr(delegate_tool, "_get_child_timeout", lambda: 0.5)
|
|
monkeypatch.setattr(delegate_tool, "_get_worktree_isolation", lambda: False)
|
|
|
|
from tools.daemon_pool import DaemonThreadPoolExecutor
|
|
|
|
submit = DaemonThreadPoolExecutor.submit
|
|
|
|
def submit_started(executor, *args, **kwargs):
|
|
future = submit(executor, *args, **kwargs)
|
|
assert child.started.wait(timeout=10)
|
|
return future
|
|
|
|
# Timeout accounting starts only after this test's child is running.
|
|
monkeypatch.setattr(DaemonThreadPoolExecutor, "submit", submit_started)
|
|
result = delegate_tool._run_single_child(
|
|
task_index=0,
|
|
goal="exercise timeout teardown",
|
|
child=child,
|
|
parent_agent=parent,
|
|
)
|
|
|
|
assert result["status"] == "timeout"
|
|
assert child.unwinding.wait(timeout=10)
|
|
try:
|
|
assert not child.closed.is_set(), (
|
|
"timed-out child.close() ran before its conversation thread unwound"
|
|
)
|
|
finally:
|
|
child.allow_finish.set()
|
|
assert child.finished.wait(timeout=10)
|
|
assert child.closed.wait(timeout=10)
|
|
assert not child.close_while_running, (
|
|
"timed-out child.close() raced its still-running conversation thread"
|
|
)
|