修改
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
"""Tests for the bash run_in_background path (local fallback only).
|
||||
|
||||
Covers:
|
||||
- BashBgStore: record_start / mark_completed / mark_auto_resumed race semantics.
|
||||
- _run_bash_background local fallback: spawns a detached process with file
|
||||
redirects, registers it via the bg_register callback, output/pid/exit_code
|
||||
files materialize.
|
||||
- _run_bash_status / _run_bash_kill: route through bg_status_query / bg_kill_request
|
||||
callbacks and format their result.
|
||||
|
||||
Remote (jupyter_runtime) path is intentionally not exercised here — that
|
||||
requires a live wsh account and is covered by the e2e checklist in the plan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent_tool_core import ToolExecutionContext
|
||||
from src.agent_tools import (
|
||||
_run_bash_background,
|
||||
_run_bash_kill,
|
||||
_run_bash_status,
|
||||
)
|
||||
from src.agent_types import AgentPermissions
|
||||
from src.bash_bg_store import BashBgStore, BgTaskSpec, BgTaskStatus
|
||||
|
||||
|
||||
def _make_context(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
bg_register=None,
|
||||
bg_status_query=None,
|
||||
bg_kill_request=None,
|
||||
) -> ToolExecutionContext:
|
||||
return ToolExecutionContext(
|
||||
root=tmp_path,
|
||||
command_timeout_seconds=10.0,
|
||||
max_output_chars=4096,
|
||||
permissions=AgentPermissions(allow_shell_commands=True),
|
||||
scratchpad_directory=tmp_path,
|
||||
bg_register=bg_register,
|
||||
bg_status_query=bg_status_query,
|
||||
bg_kill_request=bg_kill_request,
|
||||
account_id='acct-test',
|
||||
session_id='sess-test',
|
||||
run_id='run-test',
|
||||
)
|
||||
|
||||
|
||||
# ----- BashBgStore --------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_record_and_complete(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_abc',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=12345,
|
||||
task_dir=str(tmp_path / 'bg_abc'),
|
||||
output_path=str(tmp_path / 'bg_abc' / 'output'),
|
||||
pid_path=str(tmp_path / 'bg_abc' / 'pid'),
|
||||
exit_code_path=str(tmp_path / 'bg_abc' / 'exit_code'),
|
||||
command='echo hi',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=True,
|
||||
)
|
||||
store.record_start(spec)
|
||||
row = store.get('bg_abc')
|
||||
assert row is not None
|
||||
assert row['status'] == 'running'
|
||||
assert row['exit_code'] is None
|
||||
|
||||
store.mark_completed('bg_abc', exit_code=0)
|
||||
row = store.get('bg_abc')
|
||||
assert row['status'] == 'completed'
|
||||
assert row['exit_code'] == 0
|
||||
assert row['finished_at'] is not None
|
||||
|
||||
|
||||
def test_store_mark_auto_resumed_is_race_safe(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_race',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=1,
|
||||
task_dir='/tmp/x',
|
||||
output_path='/tmp/x/output',
|
||||
pid_path='/tmp/x/pid',
|
||||
exit_code_path='/tmp/x/exit_code',
|
||||
command='true',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=True,
|
||||
)
|
||||
store.record_start(spec)
|
||||
store.mark_completed('bg_race', exit_code=0)
|
||||
|
||||
# First caller wins; subsequent callers always see False.
|
||||
assert store.mark_auto_resumed('bg_race') is True
|
||||
assert store.mark_auto_resumed('bg_race') is False
|
||||
assert store.mark_auto_resumed('bg_race') is False
|
||||
|
||||
|
||||
def test_store_mark_killed_only_running(tmp_path: Path) -> None:
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
spec = BgTaskSpec(
|
||||
task_id='bg_kill',
|
||||
account_key='acct',
|
||||
session_id='sess',
|
||||
run_id='run',
|
||||
pid=2,
|
||||
task_dir='/tmp/y',
|
||||
output_path='/tmp/y/output',
|
||||
pid_path='/tmp/y/pid',
|
||||
exit_code_path='/tmp/y/exit_code',
|
||||
command='sleep 100',
|
||||
started_at=time.time(),
|
||||
wait_for_completion=False,
|
||||
)
|
||||
store.record_start(spec)
|
||||
store.mark_killed('bg_kill')
|
||||
row = store.get('bg_kill')
|
||||
assert row['status'] == 'cancelled'
|
||||
|
||||
# Re-killing a non-running task is a no-op (status stays cancelled).
|
||||
store.mark_killed('bg_kill')
|
||||
row = store.get('bg_kill')
|
||||
assert row['status'] == 'cancelled'
|
||||
|
||||
|
||||
# ----- _run_bash_background local fallback --------------------------------
|
||||
|
||||
|
||||
def test_run_bash_background_local_spawns_detached(tmp_path: Path) -> None:
|
||||
captured: list[BgTaskSpec] = []
|
||||
|
||||
ctx = _make_context(tmp_path, bg_register=captured.append)
|
||||
content, metadata = _run_bash_background(
|
||||
{
|
||||
'command': 'echo hello-from-bg',
|
||||
'wait_for_completion': True,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
assert metadata['action'] == 'bash_bg'
|
||||
task_id = metadata['task_id']
|
||||
assert task_id.startswith('bg_')
|
||||
assert metadata['wait_for_completion'] is True
|
||||
assert 'pid=' in content
|
||||
|
||||
# bg_register received exactly one spec, with the agent's session/account.
|
||||
assert len(captured) == 1
|
||||
spec = captured[0]
|
||||
assert spec.task_id == task_id
|
||||
assert spec.account_key == 'acct-test'
|
||||
assert spec.session_id == 'sess-test'
|
||||
assert spec.run_id == 'run-test'
|
||||
assert spec.command == 'echo hello-from-bg'
|
||||
assert spec.wait_for_completion is True
|
||||
|
||||
# Wait for the detached subprocess to finish — bounded by exit_code file.
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(spec.exit_code_path)
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
assert exit_code_path.exists(), 'exit_code file did not appear in 5s'
|
||||
assert exit_code_path.read_text(encoding='utf-8').strip() == '0'
|
||||
|
||||
output_text = Path(spec.output_path).read_text(encoding='utf-8')
|
||||
assert 'hello-from-bg' in output_text
|
||||
|
||||
|
||||
def test_run_bash_background_failed_command_records_nonzero_exit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
captured: list[BgTaskSpec] = []
|
||||
ctx = _make_context(tmp_path, bg_register=captured.append)
|
||||
|
||||
_content, metadata = _run_bash_background(
|
||||
{'command': 'exit 7'},
|
||||
ctx,
|
||||
)
|
||||
spec = captured[0]
|
||||
assert metadata['action'] == 'bash_bg'
|
||||
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(spec.exit_code_path)
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
assert exit_code_path.exists()
|
||||
assert exit_code_path.read_text(encoding='utf-8').strip() == '7'
|
||||
|
||||
|
||||
def test_run_bash_background_requires_shell_permission(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolPermissionError
|
||||
|
||||
ctx = ToolExecutionContext(
|
||||
root=tmp_path,
|
||||
command_timeout_seconds=10.0,
|
||||
max_output_chars=4096,
|
||||
permissions=AgentPermissions(allow_shell_commands=False),
|
||||
scratchpad_directory=tmp_path,
|
||||
)
|
||||
with pytest.raises(ToolPermissionError):
|
||||
_run_bash_background({'command': 'echo nope'}, ctx)
|
||||
|
||||
|
||||
# ----- bash_status / bash_kill via callbacks ------------------------------
|
||||
|
||||
|
||||
def test_bash_status_routes_through_callback(tmp_path: Path) -> None:
|
||||
fake_status = BgTaskStatus(
|
||||
task_id='bg_xyz',
|
||||
status='completed',
|
||||
pid=999,
|
||||
started_at=time.time() - 5.0,
|
||||
finished_at=time.time(),
|
||||
exit_code=0,
|
||||
output_path='/tmp/x/output',
|
||||
output_preview='all good\n',
|
||||
auto_resumed=False,
|
||||
)
|
||||
|
||||
queries: list[str] = []
|
||||
|
||||
def query(task_id: str) -> BgTaskStatus | None:
|
||||
queries.append(task_id)
|
||||
return fake_status
|
||||
|
||||
ctx = _make_context(tmp_path, bg_status_query=query)
|
||||
content, metadata = _run_bash_status({'task_id': 'bg_xyz'}, ctx)
|
||||
|
||||
assert queries == ['bg_xyz']
|
||||
assert metadata['action'] == 'bash_status'
|
||||
assert metadata['status'] == 'completed'
|
||||
assert metadata['exit_code'] == 0
|
||||
assert 'all good' in content
|
||||
assert 'task_id=bg_xyz' in content
|
||||
|
||||
|
||||
def test_bash_status_unknown_task_raises(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolExecutionError
|
||||
|
||||
def query(_task_id: str):
|
||||
return None
|
||||
|
||||
ctx = _make_context(tmp_path, bg_status_query=query)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
_run_bash_status({'task_id': 'bg_missing'}, ctx)
|
||||
|
||||
|
||||
def test_bash_status_without_callback_raises(tmp_path: Path) -> None:
|
||||
from src.agent_tool_core import ToolExecutionError
|
||||
|
||||
ctx = _make_context(tmp_path)
|
||||
with pytest.raises(ToolExecutionError):
|
||||
_run_bash_status({'task_id': 'bg_anything'}, ctx)
|
||||
|
||||
|
||||
def test_bash_kill_routes_through_callback(tmp_path: Path) -> None:
|
||||
killed: list[str] = []
|
||||
|
||||
def kill(task_id: str) -> bool:
|
||||
killed.append(task_id)
|
||||
return True
|
||||
|
||||
ctx = _make_context(tmp_path, bg_kill_request=kill)
|
||||
content, metadata = _run_bash_kill({'task_id': 'bg_kk'}, ctx)
|
||||
assert killed == ['bg_kk']
|
||||
assert metadata['killed'] is True
|
||||
assert 'bg_kk' in content
|
||||
|
||||
|
||||
def test_bash_kill_failure_reports(tmp_path: Path) -> None:
|
||||
ctx = _make_context(tmp_path, bg_kill_request=lambda _t: False)
|
||||
content, metadata = _run_bash_kill({'task_id': 'bg_dead'}, ctx)
|
||||
assert metadata['killed'] is False
|
||||
assert '未能终止' in content
|
||||
|
||||
|
||||
# ----- end-to-end: spawn + register + sync via real BashBgStore -----------
|
||||
|
||||
|
||||
def test_local_bg_lifecycle_with_real_store(tmp_path: Path) -> None:
|
||||
"""Plug a real BashBgStore into bg_register and check the row appears."""
|
||||
store = BashBgStore(tmp_path / 'bg.db')
|
||||
|
||||
def register(spec: BgTaskSpec) -> None:
|
||||
store.record_start(spec)
|
||||
|
||||
ctx = _make_context(tmp_path, bg_register=register)
|
||||
_content, metadata = _run_bash_background(
|
||||
{'command': 'echo lifecycle && exit 0'}, ctx,
|
||||
)
|
||||
task_id = metadata['task_id']
|
||||
row = store.get(task_id)
|
||||
assert row is not None
|
||||
assert row['status'] == 'running'
|
||||
|
||||
# Wait for child to write exit_code (more reliable than kill -0, since
|
||||
# the unreaped child becomes a zombie and kill -0 still reports it alive).
|
||||
deadline = time.monotonic() + 5.0
|
||||
exit_code_path = Path(row['exit_code_path'])
|
||||
while time.monotonic() < deadline and not exit_code_path.exists():
|
||||
time.sleep(0.05)
|
||||
if not exit_code_path.exists():
|
||||
pytest.fail('background process did not exit within 5 seconds')
|
||||
|
||||
# Simulate manager finalize step.
|
||||
exit_code = int(
|
||||
exit_code_path.read_text(encoding='utf-8').strip()
|
||||
)
|
||||
store.mark_completed(task_id, exit_code=exit_code)
|
||||
row = store.get(task_id)
|
||||
assert row['status'] == 'completed'
|
||||
assert row['exit_code'] == 0
|
||||
Reference in New Issue
Block a user