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,另案处理。
This commit is contained in:
1 parent
86eb43f1ca
commit
613ff4e7c4
3 files changed
+386
-26
No files matched your search
@@ -0,0 +1,220 @@
|
||||
"""线程解析兜底单测: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()
|
||||
Reference in new issue
Block a user