diff --git a/cloud/server.py b/cloud/server.py index 0f4f4e3..e9ca411 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -482,12 +482,15 @@ def _extract_thread(result): return None -def _pick_latest_thread(threads): +def _pick_latest_thread(threads, include_archived=False): """cwd 过滤返回多个 thread 时的取用规则:取最新一个非 archived。协议说明:Thread 对象本身没有 archived 字段, 非归档由 thread/list 的 archived 过滤保证(不传 archived 时只回非归档 thread);这里只防御性地跳过显式带 archived 真值的条目(CLI 形状若补了该字段时仍正确)。 + include_archived 为真时不跳过(unarchive 解析专用的 + 已归档列表;实测 archived 列表里条目的 archived 字段 + 为 None,不跳过也能命中,但显式放行更稳)。 最新 = updatedAt 最大,缺失时退 createdAt,再缺失时按 列表原顺序取后者(同秒时后到的更新)。""" best = None @@ -495,7 +498,7 @@ def _pick_latest_thread(threads): for idx, item in enumerate(threads): if not isinstance(item, dict): continue - if item.get("archived"): + if item.get("archived") and not include_archived: continue ts = item.get("updatedAt") if ts is None: @@ -4242,10 +4245,12 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): " thread ops must run there" % worktree_host) return None - def _list_task_threads(self, task): + def _list_task_threads(self, task, archived=False): """对任务调 thread-list:返回 (threads, codex_home, cwd, err, provider),err 为 (status, msg) 或 None。cwd 过滤 用任务 worktree(见 _thread_worktree_for_task)。 + archived 为真时传 --archived,只列已归档 thread + (unarchive 解析专用;不传时只列非归档)。 候选按序穷尽,首个非空即停(thread 操作不是热路径, 多次 CLI 调用可接受): @@ -4275,10 +4280,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): providers.append("sub2api") first_error = None any_success = False + list_argv = ["thread-list", "--cwd", cwd] + if archived: + list_argv.append("--archived") for home in homes: for provider in providers: ok, result = _run_host_cli( - ["thread-list", "--cwd", cwd], home, + list_argv, home, model_provider=provider) if not ok: if first_error is None: @@ -4297,18 +4305,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): return None, None, None, (502, first_error), None return [], codex_home, cwd, None, None - def _resolve_task_thread(self, task): + def _resolve_task_thread(self, task, archived=False): """单线程端点的解析:thread-list 后按 _pick_latest_thread - 取最新一个非 archived。返回 (thread, codex_home, cwd, - err, provider);零个 thread 时 err 为 (404, "thread not - found")。provider 必须原样带给后续的 thread-read / - rename / archive CLI——同一 thread 在另一 provider - 进程里同样看不到,丢了它改操作会 502。""" + 取最新一个非 archived(archived 为真时取最新一个已归档, + unarchive 专用——不传时只列非归档,刚归档的 thread + 永远解析不到,unarchive 也就永远 404)。返回 (thread, + codex_home, cwd, err, provider);零个 thread 时 err 为 + (404, "thread not found")。provider 必须原样带给后续的 + thread-read / rename / archive CLI——同一 thread 在另一 + provider 进程里同样看不到,丢了它改操作会 502。""" threads, codex_home, cwd, err, provider = \ - self._list_task_threads(task) + self._list_task_threads(task, archived=archived) if err is not None: return None, None, None, err, None - thread = _pick_latest_thread(threads) + thread = _pick_latest_thread(threads, + include_archived=archived) if thread is None: return None, None, None, (404, "thread not found"), None return thread, codex_home, cwd, None, provider @@ -4370,10 +4381,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): "thread": full}) def _task_thread_mutate(self, user_id, task_id, argv, audit_action, - audit_detail, timeout=60): + audit_detail, timeout=60, + resolve_archived=False): """线程改操作的公共流程:权限用项目 create_task;只允许 终态任务(completed/failed/interrupted),running/queued - 一律 409;解析 thread 后调对应 CLI,成功记审计。""" + 一律 409;解析 thread 后调对应 CLI,成功记审计。 + resolve_archived 只供 unarchive:已归档 thread 只能在 + --archived 列表里找到,走普通解析永远 404。""" task, err = self._thread_task(user_id, task_id, "create_task") if err is not None: self._send_json(err[0], {"error": err[1]}) @@ -4388,7 +4402,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): "status": task["status"]}) return thread, codex_home, _cwd, err, provider = \ - self._resolve_task_thread(task) + self._resolve_task_thread(task, archived=resolve_archived) if err is not None: self._send_json(err[0], {"error": err[1]}) return @@ -4432,9 +4446,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler): def _task_thread_unarchive(self, user_id, task_id): """POST /tasks/{id}/thread/unarchive:thread/unarchive。 - 记 thread_unarchived 审计。""" + 记 thread_unarchived 审计。注意:必须走已归档解析 + (resolve_archived)——刚归档的 thread 只在 + --archived 列表里,普通解析永远 404,unarchive 也就 + 永远跑不通;这是 #103 时代的逻辑 bug,本批修。""" self._task_thread_mutate(user_id, task_id, ["thread-unarchive"], - "thread_unarchived", "%s unarchived") + "thread_unarchived", "%s unarchived", + resolve_archived=True) def _task_thread_compact(self, user_id, task_id): """POST /tasks/{id}/thread/compact:thread/compact/start。 diff --git a/cloud/tests/test_thread_unarchive.py b/cloud/tests/test_thread_unarchive.py new file mode 100644 index 0000000..35de5cc --- /dev/null +++ b/cloud/tests/test_thread_unarchive.py @@ -0,0 +1,151 @@ +"""unarchive 解析单测:已归档 thread 只能在 --archived 列表里。 + +背景(生产实测 2026-10-28,探针 probe9):thread/archive +200 后 thread/unarchive 稳定 404。根因是 #103 时代的 +逻辑 bug:unarchive 走普通解析,普通 thread-list 不传 +--archived 时只回非归档 thread,刚归档的 thread 永远 +解析不到。本批修法:解析加 archived 模式,unarchive +专走该模式;其余改操作(rename/archive/compact)不动, +仍走非归档解析。 + +不跑真实模型、不调真实 host.py:_run_host_cli 均 stub。 +""" + +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, + _pick_latest_thread, + ) +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + import cloud.server as server_mod + from cloud.server import ( + CloudControlPlaneHandler, + _pick_latest_thread, + ) + + +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 + + +class ArchivedResolveTestCase(unittest.TestCase): + def _stub(self, calls, plain, archived): + def fake_cli(argv, home, model_provider=None, **kwargs): + calls.append(list(argv)) + if "--archived" in argv: + return True, {"data": archived} + return True, {"data": plain} + return fake_cli + + def test_archived_list_passes_flag(self): + # 生产形状:archived 列表里条目的 archived 字段为 None。 + thread = {"id": "t-a", "updatedAt": 100, "archived": None} + calls = [] + 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", + self._stub(calls, [], [thread])), \ + mock.patch.object( + server_mod, "_local_worker_codex_home", + return_value=None): + threads, _h, _c, err, _p = \ + h._list_task_threads(task, archived=True) + self.assertIsNone(err) + self.assertEqual(threads, [thread]) + self.assertIn("--archived", calls[0]) + + def test_plain_resolve_404s_when_only_archived_exists(self): + # 这就是探针里 404 的原样:普通列表空、归档列表有。 + thread = {"id": "t-a", "updatedAt": 100, "archived": None} + 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", + self._stub([], [], [thread])), \ + 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, (404, "thread not found")) + got, _h, _c, err2, _p = \ + h._resolve_task_thread(task, archived=True) + self.assertIsNone(err2) + self.assertEqual(got["id"], "t-a") + + def test_pick_latest_archived_flag(self): + thread = {"id": "t-a", "updatedAt": 100, "archived": True} + self.assertIsNone(_pick_latest_thread([thread])) + self.assertEqual( + _pick_latest_thread([thread], include_archived=True), + thread) + + +class UnarchiveMutateTestCase(unittest.TestCase): + def test_unarchive_resolves_archived_and_calls_cli(self): + thread = {"id": "t-a"} + task = {"id": "task-1", "project_id": "proj-1", + "status": "completed"} + seen = {} + with tempfile.TemporaryDirectory() as tmp: + h = _handler(tmp) + h.server.db_path = os.path.join(tmp, "control.db") + + def fake_resolve(task_arg, archived=False): + seen["archived"] = archived + return thread, "/home/tenant", "/wt", None, "sub2api" + + def fake_cli(argv, home, timeout=60, model_provider=None, + **kwargs): + seen["argv"] = argv + seen["provider"] = model_provider + return True, {} + + store = mock.Mock() + ctx = mock.Mock() + ctx.__enter__ = mock.Mock(return_value=store) + ctx.__exit__ = mock.Mock(return_value=False) + sent = {} + + def fake_send(status, body): + sent["status"] = status + sent["body"] = body + + with mock.patch.object(h, "_thread_task", + return_value=(task, None)), \ + mock.patch.object(h, "_thread_task_guard", + return_value=None), \ + mock.patch.object(h, "_resolve_task_thread", + fake_resolve), \ + mock.patch.object(server_mod, "_run_host_cli", + fake_cli), \ + mock.patch.object(server_mod, "_auth_store", + return_value=ctx), \ + mock.patch.object(h, "_send_json", fake_send): + h._task_thread_unarchive("user-1", "task-1") + self.assertTrue(seen["archived"]) + self.assertEqual(seen["argv"], + ["thread-unarchive", "--thread-id", "t-a"]) + self.assertEqual(seen["provider"], "sub2api") + self.assertEqual(sent["status"], 200) + self.assertEqual(sent["body"]["thread_id"], "t-a") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/thread-resolve-fix.md b/docs/thread-resolve-fix.md index b97e82d..1ed5e83 100644 --- a/docs/thread-resolve-fix.md +++ b/docs/thread-resolve-fix.md @@ -61,3 +61,24 @@ - 新测试 9 个:`cloud/tests/test_thread_resolve.py` (CLI 参数位置、共享 home 读取、回退顺序、空→404、 host.py 失败→502)。 + +## 后续:unarchive 归档解析 + +探针 probe9 发现:thread/archive 200 后 thread/unarchive +稳定 404。根因是 #103 时代的逻辑 bug:unarchive 走普通 +解析,普通 thread-list 不传 --archived 时只回非归档 +thread,刚归档的 thread 永远解析不到。修法(只改服务端): + +- `_list_task_threads(task, archived=False)`:archived 为真 + 时传 `--archived`,只列已归档 thread;home×provider 穷尽 + 回退不变。生产只读验证:同一 worktree 普通列表 0 条、 + archived 列表恰好 1 条(即 probe9 归档的 thread),且其 + archived 字段为 None。 +- `_resolve_task_thread(task, archived=False)` 与 + `_pick_latest_thread(threads, include_archived=False)`: + 归档模式下不跳过显式 archived 条目。 +- `_task_thread_mutate(..., resolve_archived=False)`:只供 + unarchive;rename/archive/compact 仍走非归档解析。 +- 新测试 4 个:`cloud/tests/test_thread_unarchive.py` + (--archived 透传、普通解析 404 而归档解析命中、pick + 归档开关、unarchive 调 CLI 时解析模式与 provider 正确)。