Files
host 613ff4e7c4 fix: 线程解析按 home×provider 穷尽回退(共享 home + sub2api)
生产实测:app-server 按进程 provider 过滤 thread/list,
不带 sub2api 注入看不到 sub2api 下建的 thread;本地
worker 又用共享 home 而非租户 home。旧解析只查租户
home + 缺省 provider,本地任务一律 404。

修法(只改服务端,不动派发):_run_host_cli 支持顶层
--model-provider;_list_task_threads 按 home(租户→共享,
共享取 host-config-host.json 的 codex_home)× provider
(缺省→sub2api,仅配了 SUB2API_BASE_URL 时)穷尽,首个
非空即停;_resolve_task_thread 把 provider 带给后续的
thread-read/rename/archive CLI;_task_thread_read 改用
解析出的 home + provider(此前误用租户 home)。

三套件:cloud 957 OK(4 跳过)、host 91 OK、deploy 29 OK。
新测试 9 个。未动派发:本地改租户 home 会丢共享 home
里的 skills,另案处理。
2026-10-28 05:36:18 +08:00

221 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""线程解析兜底单测:provider 过滤 + 共享 home 回退。
背景(生产实测 2026-10-28):app-server 按进程 provider
过滤 thread/list,不带 sub2api 注入的进程看不到 sub2api
下建的 thread;本地 worker 又用共享 home 而非租户 home。
旧解析只查租户 home + 缺省 provider,本地任务一律
404 thread not found。本批修法:home(租户→共享)×
provider(缺省→sub2api)按序穷尽,首个非空即停,并把
provider 原样带给后续的 thread-read/rename/archive CLI。
不跑真实模型、不调真实 host.py:_run_host_cli 与
_local_worker_codex_home 均以 stub 替换。
"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
try:
import cloud.server as server_mod
from cloud.server import (
CloudControlPlaneHandler,
_local_worker_codex_home,
_run_host_cli,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import cloud.server as server_mod
from cloud.server import (
CloudControlPlaneHandler,
_local_worker_codex_home,
_run_host_cli,
)
def _handler(tmp):
h = CloudControlPlaneHandler.__new__(CloudControlPlaneHandler)
srv = mock.Mock()
srv.tenants_root = os.path.join(tmp, "tenants")
srv.host_worktrees_dir = os.path.join(tmp, "worktrees")
h.server = srv
return h
def _list_result(threads):
return {"data": threads}
class RunHostCliTestCase(unittest.TestCase):
def test_model_provider_goes_before_subcommand(self):
seen = {}
class Proc:
returncode = 0
stdout = json.dumps({"data": []})
def fake_run(cmd, **kwargs):
seen["cmd"] = cmd
return Proc()
with mock.patch.object(server_mod.subprocess, "run", fake_run):
ok, _ = _run_host_cli(["thread-list", "--cwd", "/wt"],
"/home/x", model_provider="sub2api")
self.assertTrue(ok)
cmd = seen["cmd"]
self.assertLess(cmd.index("--model-provider"),
cmd.index("thread-list"))
self.assertEqual(cmd[cmd.index("--model-provider") + 1],
"sub2api")
def test_no_provider_arg_when_none(self):
seen = {}
class Proc:
returncode = 0
stdout = json.dumps({"data": []})
def fake_run(cmd, **kwargs):
seen["cmd"] = cmd
return Proc()
with mock.patch.object(server_mod.subprocess, "run", fake_run):
ok, _ = _run_host_cli(["thread-list"], "/home/x")
self.assertTrue(ok)
self.assertNotIn("--model-provider", seen["cmd"])
class LocalWorkerHomeTestCase(unittest.TestCase):
def test_reads_host_config_next_to_host_py(self):
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, "host-config-host.json"),
"w", encoding="utf-8") as f:
json.dump({"codex_home": "/srv/shared-home"}, f)
with mock.patch.object(
server_mod, "_host_py_path",
return_value=os.path.join(tmp, "host.py")):
self.assertEqual(_local_worker_codex_home(),
"/srv/shared-home")
def test_relative_home_resolves_against_config_dir(self):
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, "host-config-host.json"),
"w", encoding="utf-8") as f:
json.dump({"codex_home": "work/home"}, f)
with mock.patch.object(
server_mod, "_host_py_path",
return_value=os.path.join(tmp, "host.py")):
self.assertEqual(_local_worker_codex_home(),
os.path.join(tmp, "work/home"))
def test_missing_config_returns_none(self):
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(
server_mod, "_host_py_path",
return_value=os.path.join(tmp, "host.py")):
self.assertIsNone(_local_worker_codex_home())
class ListFallbackTestCase(unittest.TestCase):
def _run(self, calls, env_sub2api=True):
"""calls: {(home, provider): threads-or-error}。
home 用标记串:'tenant' / 'shared',stub 按 _run_host_cli
收到的 codex_home 参数映射。
"""
def fake_cli(argv, home, model_provider=None, **kwargs):
key = ("tenant" if "tenants" in home else "shared",
model_provider)
calls.append(key)
val = self.script.get(key)
if isinstance(val, Exception):
return False, str(val)
return True, _list_result(val if val is not None else [])
return fake_cli
def test_shared_home_plus_sub2api_finds_thread(self):
thread = {"id": "t-1", "updatedAt": 100, "cwd": "/wt"}
self.script = {
("tenant", None): [],
("tenant", "sub2api"): [],
("shared", None): [],
("shared", "sub2api"): [thread],
}
calls = []
with tempfile.TemporaryDirectory() as tmp:
h = _handler(tmp)
task = {"id": "task-1", "project_id": "proj-1"}
env = {"SUB2API_BASE_URL": "https://example.invalid"}
with mock.patch.dict(os.environ, env), \
mock.patch.object(server_mod, "_run_host_cli",
self._run(calls)), \
mock.patch.object(
server_mod, "_local_worker_codex_home",
return_value=os.path.join(tmp, "shared")):
threads, home, _cwd, err, provider = \
h._list_task_threads(task)
self.assertIsNone(err)
self.assertEqual(threads, [thread])
self.assertEqual(provider, "sub2api")
self.assertIn("shared", home)
self.assertEqual(calls[-1], ("shared", "sub2api"))
def test_tenant_hit_stops_early(self):
thread = {"id": "t-9", "updatedAt": 5}
self.script = {("tenant", None): [thread]}
calls = []
with tempfile.TemporaryDirectory() as tmp:
h = _handler(tmp)
task = {"id": "task-1", "project_id": "proj-1"}
with mock.patch.dict(os.environ, {}, clear=False), \
mock.patch.object(server_mod, "_run_host_cli",
self._run(calls)), \
mock.patch.object(
server_mod, "_local_worker_codex_home",
return_value=os.path.join(tmp, "shared")):
threads, _home, _cwd, err, provider = \
h._list_task_threads(task)
self.assertIsNone(err)
self.assertEqual(threads, [thread])
self.assertIsNone(provider)
self.assertEqual(calls, [("tenant", None)])
def test_all_empty_resolves_to_404_not_502(self):
self.script = {}
with tempfile.TemporaryDirectory() as tmp:
h = _handler(tmp)
task = {"id": "task-1", "project_id": "proj-1"}
with mock.patch.dict(
os.environ,
{"SUB2API_BASE_URL": "https://example.invalid"}), \
mock.patch.object(server_mod, "_run_host_cli",
self._run([])), \
mock.patch.object(
server_mod, "_local_worker_codex_home",
return_value=os.path.join(tmp, "shared")):
_t, _h, _c, err, _p = h._resolve_task_thread(task)
self.assertEqual(err, (404, "thread not found"))
def test_host_py_failure_stays_502(self):
self.script = {}
with tempfile.TemporaryDirectory() as tmp:
h = _handler(tmp)
task = {"id": "task-1", "project_id": "proj-1"}
with mock.patch.object(
server_mod, "_run_host_cli",
return_value=(False, "host.py not found")), \
mock.patch.object(
server_mod, "_local_worker_codex_home",
return_value=None):
_t, _h, _c, err, _p = h._resolve_task_thread(task)
self.assertEqual(err[0], 502)
if __name__ == "__main__":
unittest.main()