Merge pull request #106 from openqj/feat-thread-unarchive
fix: thread/unarchive 走已归档解析(--archived 列表)
This commit is contained in:
3 files changed
+207
-17
No files matched your search
+35
-17
@@ -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。
|
||||
|
||||
@@ -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()
|
||||
@@ -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 正确)。
|
||||
Reference in new issue
Block a user