Files
MemeMeow/tests/test_agent_executor.py
2026-09-20 16:17:21 +08:00

887 lines
40 KiB
Python

"""Agent executor 协议、认证、路径边界和取消语义测试。"""
from __future__ import annotations
import json
import stat
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from collections.abc import Iterator
import pytest
from backend.agent_executor import AgentExecutorClient, AgentExecutorError
from backend.agent_executor import ExecutorTaskResponse
from backend.config import Settings
from backend.opencode import OpenCodeError, OpenCodeRunner
from backend.opencode_workspace import WorkspaceCapabilitySigner
from executor import server as executor_server
from executor.token import ExecutorTokenError, ensure_token_file, read_token_file
def _candidate() -> dict[str, object]:
"""返回满足后端结果文件基本契约的 stub 结果。"""
return {
"title": "executor 测试",
"summary": "受控任务结果",
"subjects": ["主体"],
"visible_text": [],
"references": [],
"meaning": None,
"keywords": ["测试"],
"search_queries": [],
"uncertainties": [],
"source_urls": [],
}
def test_executor_token_file_is_random_persistent_and_permission_limited(tmp_path: Path) -> None:
"""首次启动生成 0600 token,后续读取必须复用同一凭据。"""
secret_dir = tmp_path / "secret"
secret_dir.mkdir(mode=0o700)
token_path = secret_dir / "token"
token = ensure_token_file(token_path)
assert len(token) >= 32
assert stat.S_IMODE(token_path.stat().st_mode) == 0o600
assert read_token_file(token_path) == token
assert ensure_token_file(token_path) == token
def test_executor_token_file_rejects_insecure_permissions_and_symlinks(tmp_path: Path) -> None:
"""token 文件的 group/other 权限和符号链接必须阻断启动。"""
secret_dir = tmp_path / "secret"
secret_dir.mkdir(mode=0o700)
token_path = secret_dir / "token"
token_path.write_text("x" * 43 + "\n", encoding="utf-8")
token_path.chmod(0o640)
with pytest.raises(ExecutorTokenError, match="permissions_invalid"):
read_token_file(token_path)
link = secret_dir / "link"
link.symlink_to(token_path)
with pytest.raises(ExecutorTokenError):
read_token_file(link)
def test_settings_prefers_compose_token_file_over_dotenv_token(tmp_path: Path) -> None:
"""Compose API 必须读取共享文件,避免旧 dotenv token 与 executor 分叉。"""
secret_dir = tmp_path / "secret"
secret_dir.mkdir(mode=0o700)
token_path = secret_dir / "token"
token = ensure_token_file(token_path)
env_file = tmp_path / ".env"
env_file.write_text(
f"MEMEMEOW_AGENT_EXECUTOR_TOKEN_FILE={token_path}\n"
"MEMEMEOW_AGENT_EXECUTOR_TOKEN=stale-dotenv-token\n",
encoding="utf-8",
)
settings = Settings.from_env(env_file)
assert settings.agent_executor_token == token
@pytest.fixture
def executor_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""启动带 fake OpenCode 的本地 executor HTTP 服务。"""
runtime = tmp_path / "runtime"
images = tmp_path / "images"
skill = tmp_path / "skill"
runtime.mkdir()
images.mkdir()
skill.mkdir()
(images / "sample.png").write_bytes(b"image")
fake = tmp_path / "fake-opencode"
fake.write_text(
"#!/usr/bin/env python3\n"
"import json, os, signal, sys, time\n"
"from pathlib import Path\n"
"task = os.environ['MEMEMEOW_AGENT_TASK_ID']\n"
"runtime = Path(os.environ['OPENCODE_DB']).parent\n"
"active = runtime / 'active'\n"
"active.mkdir(parents=True, exist_ok=True)\n"
"marker = active / task\n"
"marker.write_text(str(os.getpid()), encoding='ascii')\n"
"def handle_sigterm(_signum, _frame):\n"
" marker.unlink(missing_ok=True)\n"
" raise SystemExit(143)\n"
"signal.signal(signal.SIGTERM, handle_sigterm)\n"
"try:\n"
" if os.getenv('FAKE_SLEEP'):\n"
" time.sleep(float(os.environ['FAKE_SLEEP']))\n"
" directory = runtime / 'task-results' / task\n"
" directory.mkdir(parents=True, exist_ok=True)\n"
" image_arg = sys.argv[sys.argv.index('--file') + 1]\n"
" observation = {'cwd': os.getcwd(), 'db': os.environ['OPENCODE_DB'], 'config': os.environ['OPENCODE_CONFIG'], 'config_dir': os.environ['OPENCODE_CONFIG_DIR'], 'image': Path(image_arg).read_bytes().decode('utf-8')}\n"
" Path(os.environ['OPENCODE_CONFIG']).with_name('observation.json').write_text(json.dumps(observation), encoding='utf-8')\n"
" candidate = {'title':'executor 测试','summary':'受控任务结果','subjects':['主体'],'visible_text':[],'references':[],'meaning':None,'keywords':['测试'],'search_queries':[],'uncertainties':[],'source_urls':[]}\n"
" if os.getenv('FAKE_MODE') == 'sensitive-result':\n"
" candidate['references'] = ['path=/runtime/secret']\n"
" if os.getenv('FAKE_MODE') == 'provider429':\n"
" with open(directory / 'result.json.draft', 'w', encoding='utf-8') as handle: json.dump({'partial': True}, handle)\n"
" print(json.dumps({'type':'session.created','session_id':'session-' + task}), flush=True)\n"
" print(json.dumps({'type':'error','error':{'data':{'message':'provider status 429'}}}), flush=True)\n"
" sys.exit(7)\n"
" if os.getenv('FAKE_MODE') == 'resume' and ('--session' not in sys.argv or 'session-' + task not in sys.argv):\n"
" print(json.dumps({'type':'error','error':{'data':{'message':'session missing'}}}), flush=True)\n"
" sys.exit(8)\n"
" with open(directory / 'result.json.draft', 'w', encoding='utf-8') as handle: json.dump(candidate, handle, ensure_ascii=False)\n"
" os.replace(directory / 'result.json.draft', directory / 'result.json.tmp')\n"
" if os.getenv('FAKE_MODE') == 'large-stdout':\n"
" filler = json.dumps({'type':'message','text':'前置🙂' * 80}, ensure_ascii=False)\n"
" for _ in range(400): print(filler)\n"
" sys.stdout.flush()\n"
" sys.stdout.buffer.write(b'{\"type\":\"message\",\"text\":\"broken \\xff\"}\\n')\n"
" sys.stdout.flush()\n"
" print(json.dumps({'type':'session.created','session_id':'session-' + task}), flush=True)\n"
" for _ in range(400): print(filler)\n"
" else:\n"
" print(json.dumps({'type':'session.created','session_id':'session-' + task}), flush=True)\n"
"finally:\n"
" marker.unlink(missing_ok=True)\n",
encoding="utf-8",
)
fake.chmod(fake.stat().st_mode | stat.S_IXUSR)
skill.chmod(0o555)
images.chmod(0o555)
(images / "sample.png").chmod(0o444)
monkeypatch.setattr(executor_server, "RUNTIME_ROOT", runtime)
monkeypatch.setattr(executor_server, "WORKSPACE", runtime / "workspace")
monkeypatch.setattr(executor_server, "RESULT_ROOT", runtime / "task-results")
monkeypatch.setattr(executor_server, "LOG_ROOT", runtime / "logs")
monkeypatch.setattr(executor_server, "IMAGE_ROOT", images)
monkeypatch.setattr(executor_server, "SKILL_ROOT", skill)
monkeypatch.setenv("MEMEMEOW_AGENT_EXECUTOR_TOKEN", "executor-test-token")
monkeypatch.setenv("MEMEMEOW_OPENCODE_MODEL", "mememeow/gpt-5.6-luna")
monkeypatch.setenv("MEMEMEOW_OPENCODE_BASE_URL", "https://example.invalid/v1")
monkeypatch.setenv("MEMEMEOW_OPENCODE_API_KEY", "test-key")
monkeypatch.setenv("MEMEMEOW_OPENCODE_EXECUTABLE", str(fake))
executor = executor_server.Executor()
monkeypatch.setattr(executor, "health", lambda: {"ready": True, "docker_socket_absent": True})
http = executor_server.ExecutorHTTPServer(("127.0.0.1", 0), executor)
thread = threading.Thread(target=http.serve_forever, daemon=True)
thread.start()
client = AgentExecutorClient(f"http://127.0.0.1:{http.server_port}", "executor-test-token", timeout=10)
try:
yield executor, client, runtime
finally:
executor.close()
http.shutdown()
http.server_close()
thread.join(timeout=2)
def test_executor_rejects_bad_token_and_arbitrary_fields(executor_fixture) -> None:
"""认证失败和任意命令字段必须在执行前被拒绝。"""
_executor, client, _runtime = executor_fixture
bad = AgentExecutorClient(client.url, "wrong-token", timeout=2)
with pytest.raises(AgentExecutorError) as error:
bad.status("task-1")
assert error.value.code == "agent_executor_unauthorized"
with pytest.raises(AgentExecutorError) as error:
bad.health()
assert error.value.code == "agent_executor_unauthorized"
with pytest.raises(AgentExecutorError) as error:
client._request("POST", "/v1/tasks", {"task_id": "task-1", "image_relative_path": "sample.png", "command": "id"})
assert error.value.code == "invalid_task"
def test_executor_runs_fixed_task_and_returns_result(executor_fixture) -> None:
"""固定任务只能读取 images 相对路径,并通过共享结果文件传递结果。"""
_executor, client, runtime = executor_fixture
result = client.run(task_id="task-success", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
assert result.status == "succeeded"
assert result.session_id == "session-task-success"
payload = json.loads((runtime / "task-results/task-success/result.json.tmp").read_text(encoding="utf-8"))
assert payload["title"] == "executor 测试"
with pytest.raises(AgentExecutorError) as error:
client._request("POST", "/v1/tasks", {"task_id": "task-path", "image_relative_path": "../outside.png", "wait": True})
assert error.value.code == "agent_image_path_forbidden"
@pytest.mark.parametrize(
("visual_snapshot_sha256", "analysis_policy"),
(
(None, None),
("a" * 64, None),
(None, {"version": 1, "termination_cost": "0.30"}),
("a" * 64, {"version": 1, "termination_cost": "0.30"}),
),
)
def test_image_analysis_does_not_require_linux_namespace(
tmp_path: Path,
visual_snapshot_sha256: str | None,
analysis_policy: dict[str, object] | None,
) -> None:
"""图片分析命令即使带候选快照或用量策略,也不调用 namespace 隔离。"""
task = executor_server.TaskState(
task_id="task-analysis",
business_task_id="task-analysis",
executor_attempt_id="attempt-analysis",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=5,
task_scratch_root=tmp_path / "scratch",
visual_snapshot_sha256=visual_snapshot_sha256,
analysis_policy=analysis_policy,
)
command = ["/bin/true"]
assert executor_server.Executor._sandbox_command(SimpleNamespace(token_file=""), task, command) == command
def test_executor_preserves_result_validation_reason_code(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""结果公开边界失败时,外部错误码不变但 executor 保留内部原因码。"""
executor, client, runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_MODE": "sensitive-result"})
with pytest.raises(AgentExecutorError) as error:
client.run(task_id="sensitive-result", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
assert error.value.code == "agent_result_file_schema_invalid"
assert error.value.reason_code == "result_sensitive_data"
attempt = executor.tasks[error.value.executor_attempt_id]
assert attempt.error == {
"error": "agent_result_file_schema_invalid",
"message": "任务执行失败",
"reason_code": "result_sensitive_data",
}
metadata = json.loads((runtime / "task-results/sensitive-result/.executor-attempts.json").read_text(encoding="utf-8"))
assert metadata["attempts"][-1]["error"]["reason_code"] == "result_sensitive_data"
def test_executor_captures_session_from_complete_large_stdout(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""stdout 超过采样范围且含损坏 UTF-8 时,完整流仍应交付合法结果。"""
executor, client, _runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_MODE": "large-stdout"})
result = client.run(task_id="large-stdout", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
assert result.status == "succeeded"
assert result.session_id == "session-large-stdout"
def test_executor_rejects_malformed_json_with_stable_error(executor_fixture) -> None:
"""损坏的 JSON 请求不能把解析器原文变成不稳定错误码。"""
_executor, client, _runtime = executor_fixture
request = urllib.request.Request(
f"{client.url}/v1/tasks",
data=b"{not-json",
headers={"Accept": "application/json", "Content-Type": "application/json", "Authorization": f"Bearer {client.token}"},
method="POST",
)
with pytest.raises(urllib.error.HTTPError) as error:
urllib.request.urlopen(request, timeout=2)
payload = json.loads(error.value.read().decode("utf-8"))
assert error.value.code == 400
assert payload == {"error": "invalid_task", "message": "任务请求无效"}
def test_executor_client_waits_for_nonterminal_sync_response(monkeypatch: pytest.MonkeyPatch) -> None:
"""同步提交即使先收到 queued/running,也必须轮询到终态后再交付结果。"""
client = AgentExecutorClient("http://agent:8277", "executor-token", timeout=2)
calls: list[tuple[str, str]] = []
responses = iter(
[
(202, {"task_id": "queued-task", "status": "queued"}),
(200, {"task_id": "queued-task", "status": "running"}),
(200, {"task_id": "queued-task", "status": "succeeded", "session_id": "session-1"}),
]
)
def request(method: str, path: str, payload=None, *, timeout=None):
calls.append((method, path))
return next(responses)
monkeypatch.setattr(client, "_request", request)
result = client.run(task_id="queued-task", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=1)
assert result.status == "succeeded"
assert result.session_id == "session-1"
assert calls == [("POST", "/v1/tasks"), ("GET", "/v1/tasks/queued-task"), ("GET", "/v1/tasks/queued-task")]
def test_executor_client_timeout_keeps_cancel_diagnostics() -> None:
"""等待超时后的取消响应必须保留最终金额、检查时间、提醒和终止信号。"""
client = AgentExecutorClient("http://agent:8277", "executor-token", timeout=2)
pending = ExecutorTaskResponse(
"timeout-task",
"running",
"session-before-cancel",
None,
None,
executor_attempt_id="attempt-timeout",
observed_cost="0.11",
usage_checked_at="2026-09-19T07:59:00+00:00",
)
cancelled = ExecutorTaskResponse(
"attempt-timeout",
"cancelled",
"session-after-cancel",
{"error": "task_interrupted"},
None,
executor_attempt_id="attempt-timeout",
process_reaped=True,
observed_cost="0.37",
usage_checked_at="2026-09-19T08:00:00+00:00",
reminder_sent=True,
termination_signal="SIGTERM",
check_stage="analysis_monitor",
trigger_reason="agent_maximum_analysis_depth_exceeded",
)
failure = client._timeout_error(pending, cancelled, None)
assert failure.code == "agent_timeout"
assert failure.executor_attempt_id == "attempt-timeout"
assert failure.session_id == "session-after-cancel"
assert failure.process_reaped is True
assert failure.observed_cost == "0.37"
assert failure.usage_checked_at == "2026-09-19T08:00:00+00:00"
assert failure.reminder_sent is True
assert failure.termination_signal == "SIGTERM"
assert failure.check_stage == "analysis_monitor"
assert failure.trigger_reason == "agent_maximum_analysis_depth_exceeded"
def test_executor_client_preserves_analysis_termination_diagnostic(monkeypatch: pytest.MonkeyPatch) -> None:
"""受信客户端提取固定终止诊断,同时丢弃嵌套对象中的其它字段。"""
client = AgentExecutorClient("http://agent:8277", "executor-token", timeout=2)
def request(method: str, path: str, payload=None, *, timeout=None):
"""返回达到分析金额边界后的真实协议形状。"""
del method, path, timeout
attempt_id = str((payload or {})["executor_attempt_id"])
return 200, {
"task_id": "analysis-limit-task",
"business_task_id": "analysis-limit-task",
"executor_attempt_id": attempt_id,
"status": "failed",
"process_reaped": True,
"observed_cost": "0.31",
"usage_checked_at": "2026-09-19T08:00:00+00:00",
"reminder_sent": True,
"error": {
"error": "agent_maximum_analysis_depth_exceeded",
"message": "超过最大分析程度",
"analysis_diagnostic": {
"termination_signal": "SIGTERM",
"check_stage": "analysis_monitor",
"trigger_reason": "agent_maximum_analysis_depth_exceeded",
"private_extra": "discarded",
},
},
}
monkeypatch.setattr(client, "_request", request)
with pytest.raises(AgentExecutorError) as failure:
client.run(
task_id="analysis-limit-task",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=5,
)
assert failure.value.code == "agent_maximum_analysis_depth_exceeded"
assert failure.value.process_reaped is True
assert failure.value.observed_cost == "0.31"
assert failure.value.usage_checked_at == "2026-09-19T08:00:00+00:00"
assert failure.value.reminder_sent is True
assert failure.value.termination_reason == "analysis_cost_limit"
assert failure.value.termination_signal == "SIGTERM"
assert failure.value.check_stage == "analysis_monitor"
assert failure.value.trigger_reason == "agent_maximum_analysis_depth_exceeded"
def test_executor_client_forwards_resume_source_attempt(monkeypatch: pytest.MonkeyPatch) -> None:
"""续跑请求必须把上一条 executor attempt 作为明确绑定传给 executor。"""
client = AgentExecutorClient("http://agent:8277", "executor-token", timeout=2)
captured: dict[str, object] = {}
def request(method: str, path: str, payload=None, *, timeout=None):
"""捕获受控请求中的恢复绑定字段。"""
del method, path, timeout
captured.update(payload or {})
return 200, {
"task_id": "resume-task",
"business_task_id": "resume-task",
"executor_attempt_id": "attempt-new",
"status": "succeeded",
"session_id": "session-1",
}
monkeypatch.setattr(client, "_request", request)
response = client.run(
task_id="resume-task",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=5,
executor_attempt_id="attempt-new",
session_id="session-1",
resume_of_attempt_id="attempt-old",
)
assert response.status == "succeeded"
assert captured["resume_of_attempt_id"] == "attempt-old"
def test_executor_client_attempt_history_is_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
"""长期 API 进程只保留有限的业务 Task 到 executor attempt 映射。"""
client = AgentExecutorClient("http://agent:8277", "executor-token", timeout=2)
client._attempt_ids = {f"old-{index}": f"attempt-{index}" for index in range(5000)}
def request(method: str, path: str, payload=None, *, timeout=None):
"""返回与本次请求 attempt 一致的最小成功响应。"""
del method, path, timeout
return 200, {
"task_id": "new-task",
"business_task_id": "new-task",
"executor_attempt_id": (payload or {}).get("executor_attempt_id"),
"status": "succeeded",
"session_id": "session-new",
}
monkeypatch.setattr(client, "_request", request)
client.run(task_id="new-task", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
assert len(client._attempt_ids) == 5000
assert "old-0" not in client._attempt_ids
def test_executor_cancel_terminates_only_one_task(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""取消 queued/running 任务必须返回稳定状态而不停止 executor 服务。"""
executor, client, _runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_SLEEP": "5"})
_status, queued = client._request(
"POST",
"/v1/tasks",
{"task_id": "task-cancel", "image_relative_path": "sample.png", "timeout_seconds": 10, "wait": False},
)
assert queued["status"] in {"queued", "running"}
for _ in range(100):
if executor.tasks["task-cancel"].status == "running":
break
time.sleep(0.01)
cancelled = client.cancel("task-cancel")
assert cancelled.status == "cancelled"
assert client.health()["ready"] is True
def test_executor_fences_concurrent_attempts_for_same_business_task(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""同一业务 Task 有活动 attempt 时,executor 拒绝并发启动第二个进程。"""
executor, client, _runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_SLEEP": "5"})
client._request(
"POST",
"/v1/tasks",
{
"task_id": "concurrent-task",
"business_task_id": "concurrent-task",
"executor_attempt_id": "attempt-first",
"image_relative_path": "sample.png",
"reverse_image_policy": "forbid",
"timeout_seconds": 10,
"wait": False,
},
)
for _ in range(100):
if executor.tasks["attempt-first"].status == "running":
break
time.sleep(0.01)
with pytest.raises(AgentExecutorError) as error:
client._request(
"POST",
"/v1/tasks",
{
"task_id": "concurrent-task",
"business_task_id": "concurrent-task",
"executor_attempt_id": "attempt-second",
"image_relative_path": "sample.png",
"reverse_image_policy": "forbid",
"timeout_seconds": 10,
"wait": False,
},
)
assert error.value.code == "task_exists"
client.cancel("attempt-first")
def test_executor_queue_accepts_tasks_beyond_legacy_backpressure(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""运行任务占用 worker 时,旧背压值不应把排队任务拒绝为 429。"""
executor, client, _runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_SLEEP": "2"})
client._request(
"POST",
"/v1/tasks",
{"task_id": "task-running", "image_relative_path": "sample.png", "timeout_seconds": 5, "wait": False},
)
for _ in range(100):
if executor.tasks["task-running"].status == "running":
break
time.sleep(0.01)
status, queued = client._request(
"POST",
"/v1/tasks",
{"task_id": "task-queued", "image_relative_path": "sample.png", "timeout_seconds": 5, "wait": False},
)
assert status == 202
assert queued["status"] in {"queued", "running"}
status, overflow = client._request(
"POST",
"/v1/tasks",
{"task_id": "task-overflow", "image_relative_path": "sample.png", "timeout_seconds": 5, "wait": False},
)
assert status == 202
assert overflow["status"] in {"queued", "running"}
for task_id in ("task-running", "task-queued", "task-overflow"):
client.cancel(task_id)
def test_executor_concurrent_workspaces_share_runtime_and_isolate_execution(executor_fixture, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""不同 workspace 并发时共享 DB 但隔离 cwd、配置、图片、结果,并验证取消/超时只影响自身。"""
executor, _client, runtime = executor_fixture
executor.pool.shutdown(wait=True)
executor.max_workers = 2
executor.pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="mememeow-opencode-concurrency")
workspace_root = tmp_path / "workspaces"
for selector, marker in (("scope-a", "image-a"), ("scope-b", "image-b")):
base = workspace_root / selector
for directory in ("workspace", "images", "metadata", "skills"):
(base / directory).mkdir(parents=True)
(base / "images" / "sample.png").write_text(marker, encoding="utf-8")
executor.workspace_root = workspace_root
signer = WorkspaceCapabilitySigner("workspace-test-key")
executor.capability_signer = signer
original_environment = executor._task_environment
sleep_by_task = {
"task-a": "0.5",
"task-b": "0.5",
"cancel-a": "5",
"cancel-b": "0.2",
"timeout-a": "2",
"timeout-b": "0.2",
}
monkeypatch.setattr(
executor,
"_task_environment",
lambda task: {
**original_environment(task),
**({"FAKE_SLEEP": sleep_by_task[task.business_task_id]} if task.business_task_id in sleep_by_task else {}),
},
)
def submit(task_id: str, attempt_id: str, selector: str, *, timeout_seconds: int = 5):
"""提交带 selector capability 的异步测试任务。"""
task, _wait = executor.submit(
{
"task_id": task_id,
"business_task_id": task_id,
"executor_attempt_id": attempt_id,
"image_relative_path": "sample.png",
"reverse_image_policy": "forbid",
"timeout_seconds": timeout_seconds,
"wait": False,
"workspace_selector": selector,
"workspace_capability": signer.issue(task_id=task_id, attempt_id=attempt_id, selector=selector),
}
)
return task
active = runtime / "active"
first = submit("task-a", "attempt-a", "scope-a")
second = submit("task-b", "attempt-b", "scope-b")
deadline = time.monotonic() + 3
max_active = 0
while time.monotonic() < deadline and not ({"task-a", "task-b"} <= {path.name for path in active.glob("*")}):
max_active = max(max_active, len(list(active.glob("*"))))
time.sleep(0.01)
max_active = max(max_active, len(list(active.glob("*"))))
assert max_active == 2
assert first.done.wait(3)
assert second.done.wait(3)
assert first.status == "succeeded"
assert second.status == "succeeded"
assert executor.pool._max_workers == 2
assert len(list(active.glob("*"))) == 0
observations = []
for task_id, selector, marker in (("task-a", "scope-a", "image-a"), ("task-b", "scope-b", "image-b")):
scratch = workspace_root / selector / "workspace" / "tasks" / task_id
observation = json.loads((scratch / "observation.json").read_text(encoding="utf-8"))
assert observation["cwd"] == str(scratch.parent.parent)
assert observation["config"] == str(scratch / "opencode.json")
assert observation["config_dir"] == str(scratch / ".opencode")
assert observation["db"] == str(runtime / "opencode.db")
assert observation["image"] == marker
config = json.loads((scratch / "opencode.json").read_text(encoding="utf-8"))
assert config["permission"]["external_directory"][f"{(runtime / 'task-results' / task_id).absolute()}/*"] == "allow"
assert config["permission"]["edit"]["*"] == "deny"
assert config["permission"]["edit"][f"**/{(scratch / 'opencode.json').as_posix().lstrip('/')}"] == "deny"
result_path = runtime / "task-results" / task_id / "result.json.tmp"
assert result_path.is_file()
observations.append(observation)
assert {item["db"] for item in observations} == {str(runtime / "opencode.db")}
cancelled = submit("cancel-a", "attempt-cancel-a", "scope-a")
unaffected_by_cancel = submit("cancel-b", "attempt-cancel-b", "scope-b")
deadline = time.monotonic() + 3
while time.monotonic() < deadline and cancelled.status != "running":
time.sleep(0.01)
executor.cancel(cancelled.executor_attempt_id)
assert cancelled.done.wait(3)
assert unaffected_by_cancel.done.wait(3)
assert cancelled.status == "cancelled"
assert unaffected_by_cancel.status == "succeeded"
timed_out = submit("timeout-a", "attempt-timeout-a", "scope-a", timeout_seconds=1)
unaffected_by_timeout = submit("timeout-b", "attempt-timeout-b", "scope-b")
assert timed_out.done.wait(4)
assert unaffected_by_timeout.done.wait(4)
assert timed_out.status == "failed"
assert timed_out.error == {"error": "agent_timeout", "message": "OpenCode 执行超时"}
assert unaffected_by_timeout.status == "succeeded"
assert len(list(active.glob("*"))) == 0
assert executor._queued_count() == 0
def test_executor_captures_failed_session_and_resumes_with_new_attempt(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""模型网关失败仍保存 session,续跑使用新 attempt 并保留草稿。"""
executor, client, runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_MODE": "provider429"})
with pytest.raises(AgentExecutorError) as failure:
client.run(task_id="resume-task", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
assert failure.value.code == "agent_provider_rate_limited"
assert failure.value.session_id == "session-resume-task"
assert failure.value.executor_attempt_id
old_attempt = failure.value.executor_attempt_id
assert (runtime / "task-results/resume-task/result.json.draft").exists()
assert executor.tasks[old_attempt].status == "failed"
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_MODE": "resume"})
resumed = client.run(
task_id="resume-task",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=5,
session_id=failure.value.session_id,
)
assert resumed.status == "succeeded"
assert resumed.session_id == failure.value.session_id
assert resumed.executor_attempt_id
assert resumed.executor_attempt_id != old_attempt
assert executor.tasks[old_attempt].status == "failed"
def test_executor_marks_unreaped_process_as_unknown_execution(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""超时终止未能确认父进程回收时,attempt 必须禁止自动续跑。"""
executor, _client, _runtime = executor_fixture
task = executor_server.TaskState(
task_id="unreaped-task",
business_task_id="unreaped-task",
executor_attempt_id="attempt-unreaped",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=1,
)
class UnreapedProcess:
"""模拟 kill 后仍无法由父进程 waitpid 回收的子进程。"""
returncode = None
pid = 1234
def poll(self):
"""保持运行态,驱动 executor 的超时 fencing 分支。"""
return None
clock = iter((0.0, 2.0))
monkeypatch.setattr(executor_server.time, "monotonic", lambda: next(clock))
monkeypatch.setattr(executor_server.subprocess, "Popen", lambda *_args, **_kwargs: UnreapedProcess())
monkeypatch.setattr(executor, "_terminate", lambda _process: False)
executor._run(task)
assert task.status == "failed"
assert task.process_reaped is False
assert task.error == {"error": "unknown_execution", "message": "无法确认 OpenCode 进程已终止"}
def test_executor_rejects_unreaped_attempt_as_resume_source(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""内存中的未回收失败 attempt 不得被 session 续跑重新采用。"""
executor, client, _runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_MODE": "provider429"})
with pytest.raises(AgentExecutorError) as failure:
client.run(task_id="unreaped-source", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
source = executor.tasks[failure.value.executor_attempt_id]
source.process_reaped = False
with pytest.raises(AgentExecutorError) as resume_failure:
client.run(
task_id="unreaped-source",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=5,
session_id=failure.value.session_id,
)
assert resume_failure.value.code == "unknown_execution"
def test_executor_rejects_session_binding_mismatch_and_reused_attempt(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""续跑必须绑定同一业务事实,终态 attempt ID 不得重复提交。"""
executor, client, _runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_MODE": "provider429"})
with pytest.raises(AgentExecutorError) as failure:
client.run(task_id="binding-task", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
attempt_id = failure.value.executor_attempt_id
assert attempt_id
with pytest.raises(AgentExecutorError) as mismatch:
client.run(
task_id="binding-task",
image_relative_path="sample.png",
reverse_image_policy="forbid",
timeout_seconds=5,
session_id=failure.value.session_id,
processing_config_hash="a" * 64,
)
assert mismatch.value.code == "session_binding_mismatch"
with pytest.raises(AgentExecutorError) as duplicate:
client._request(
"POST",
"/v1/tasks",
{
"task_id": "binding-task",
"business_task_id": "binding-task",
"executor_attempt_id": attempt_id,
"image_relative_path": "sample.png",
"reverse_image_policy": "forbid",
"timeout_seconds": 5,
"wait": False,
},
)
assert duplicate.value.code == "task_exists"
def test_executor_restart_uses_signed_attempt_metadata(executor_fixture, monkeypatch: pytest.MonkeyPatch) -> None:
"""executor 重启后只从签名 attempt 元数据恢复同一失败 session。"""
executor, client, _runtime = executor_fixture
original_environment = executor._task_environment
monkeypatch.setattr(executor, "_task_environment", lambda task: {**original_environment(task), "FAKE_MODE": "provider429"})
with pytest.raises(AgentExecutorError) as failure:
client.run(task_id="restart-task", image_relative_path="sample.png", reverse_image_policy="forbid", timeout_seconds=5)
executor.close()
restarted = executor_server.Executor()
monkeypatch.setattr(restarted, "health", lambda: {"ready": True})
restarted_environment = restarted._task_environment
monkeypatch.setattr(restarted, "_task_environment", lambda task: {**restarted_environment(task), "FAKE_MODE": "resume"})
try:
task, wait = restarted.submit(
{
"task_id": "restart-task",
"business_task_id": "restart-task",
"executor_attempt_id": "attempt-after-restart",
"session_id": failure.value.session_id,
"image_relative_path": "sample.png",
"reverse_image_policy": "forbid",
"timeout_seconds": 5,
"wait": True,
}
)
assert wait is True
assert task.is_resume is True
assert task.done.wait(2)
assert task.status == "succeeded"
assert task.resume_of_attempt_id == failure.value.executor_attempt_id
finally:
restarted.close()
def test_runner_executor_mode_uses_http_without_docker_cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Compose runner 只构造 HTTP 任务,不探测或启动 Docker CLI。"""
data = tmp_path / "data"
image_root = data / "images"
image_root.mkdir(parents=True)
image = image_root / "sample.png"
image.write_bytes(b"image")
settings = Settings(
data_root=data,
image_root=image_root,
opencode_model="mememeow/gpt-5.6-luna",
opencode_base_url="https://example.invalid/v1",
opencode_api_key="test-key",
agent_runtime_mode="executor",
agent_executor_url="http://agent:8277",
agent_executor_token="executor-token",
)
runner = OpenCodeRunner(settings, project_root=tmp_path)
monkeypatch.setattr(runner.executor, "health", lambda: {"ready": True})
monkeypatch.setattr(runner, "validate_candidate", lambda value: value)
def run_http(**kwargs):
"""模拟 executor 写入共享结果文件并返回 session。"""
result_dir = runner.runtime_root / "task-results" / kwargs["task_id"]
result_dir.mkdir(parents=True, exist_ok=True)
(result_dir / "result.json.tmp").write_text(json.dumps(_candidate(), ensure_ascii=False), encoding="utf-8")
return ExecutorTaskResponse(
kwargs["task_id"],
"succeeded",
"session-http",
None,
"task-results/result.json.tmp",
process_reaped=True,
observed_cost="0.12",
usage_checked_at="2026-09-19T08:00:00+00:00",
reminder_sent=True,
)
monkeypatch.setattr(runner.executor, "run", run_http)
monkeypatch.setattr(executor_server.subprocess, "Popen", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("Compose runner must not start local subprocess")))
result, session = runner.run(image, lambda *_args: None, task_id="http-task")
assert result["title"] == "executor 测试"
assert session == "session-http"
attempt_id = runner.executor_attempt_id_for("http-task")
assert runner.analysis_observation_for("http-task", attempt_id) == {
"observed_cost": "0.12",
"usage_checked_at": "2026-09-19T08:00:00+00:00",
"reminder_sent": True,
"process_reaped": True,
}
assert runner.analysis_observation_for("http-task", attempt_id) is None
def test_runner_executor_mode_rejects_success_without_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""executor 成功响应缺少 session 时不能把业务 task ID 冒充恢复标识。"""
data = tmp_path / "data"
image_root = data / "images"
image_root.mkdir(parents=True)
image = image_root / "sample.png"
image.write_bytes(b"image")
settings = Settings(
data_root=data,
image_root=image_root,
opencode_model="mememeow/gpt-5.6-luna",
opencode_base_url="https://example.invalid/v1",
opencode_api_key="test-key",
agent_runtime_mode="executor",
agent_executor_url="http://agent:8277",
agent_executor_token="executor-token",
)
runner = OpenCodeRunner(settings, project_root=tmp_path)
monkeypatch.setattr(runner.executor, "health", lambda: {"ready": True})
monkeypatch.setattr(runner.executor, "run", lambda **kwargs: ExecutorTaskResponse(kwargs["task_id"], "succeeded", None, None, None, "attempt-no-session"))
with pytest.raises(OpenCodeError) as failure:
runner.run(image, lambda *_args: None, task_id="task-without-session")
assert failure.value.code == "agent_output_invalid_json"
assert failure.value.session_id is None