fix: thread/unarchive 走已归档解析(--archived 列表)

probe9 实测:thread/archive 200 后 thread/unarchive 稳定 404。
根因是 #103 时代的逻辑 bug:unarchive 走普通解析,普通
thread-list 不传 --archived 时只回非归档 thread,刚归档的
thread 永远解析不到。修法只改服务端:_list_task_threads 加
archived 模式(传 --archived,home×provider 穷尽不变),
_resolve 与 _pick_latest_thread 加归档开关,_task_thread_mutate
加 resolve_archived 参数只供 unarchive;rename/archive/compact
仍走非归档解析。生产只读验证:同一 worktree 普通列表 0 条、
archived 列表恰好 1 条(probe9 归档的 thread),其 archived
字段为 None。
This commit is contained in:
host committed 2026-10-28 06:10:02 +08:00
1 parent f5d6d6b9f0
commit 5fd4e23b68
3 files changed
+207 -17

No files matched your search

+151
View File
@@ -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()