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

+35 -17
View File
@@ -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。