fix(web): make run cancellation respond immediately

This commit is contained in:
caoqianming 2026-09-03 16:54:02 +08:00
parent ca73a4affb
commit 5b052c464b
8 changed files with 122 additions and 22 deletions

View File

@ -6,6 +6,10 @@
> 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。 > 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## Unreleased
- 修复深度思考或事件流重连期间点击“停止”偶尔没有反应的问题;停止信号会在状态落库前立即送达,重复停止也会可靠补发。
## 0.71.0 — 2026-09-03 ## 0.71.0 — 2026-09-03
- 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。 - 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。

View File

@ -174,7 +174,7 @@ Eval 与生产 core 解耦,通过现有 `/v1` API 创建专用任务、监听
**无感部署(蓝绿,0.41)**:生产双 systemd 实例(`ZCBOT_INSTANCE=blue/green`)+ nginx upstream 切流(对外仍 8765),取代单实例 restart 的 503 窗口。双实例并存的互踩由三件事消解:`tasks.run_owner`(0020)让 reaper 只收自己色、sandbox 容器名/label 带实例色各管各的、微信长轮询 PG advisory lock 选主;定时任务 claim 本就 SKIP LOCKED 天然安全。部署编排在 `deploy/update_bluegreen.sh`(流程/兜底见 RUN)。 **无感部署(蓝绿,0.41)**:生产双 systemd 实例(`ZCBOT_INSTANCE=blue/green`)+ nginx upstream 切流(对外仍 8765),取代单实例 restart 的 503 窗口。双实例并存的互踩由三件事消解:`tasks.run_owner`(0020)让 reaper 只收自己色、sandbox 容器名/label 带实例色各管各的、微信长轮询 PG advisory lock 选主;定时任务 claim 本就 SKIP LOCKED 天然安全。部署编排在 `deploy/update_bluegreen.sh`(流程/兜底见 RUN)。
**broker 外置(Redis pub/sub,✅ 0.42)**:0.41 落地时 event/cancel broker 留在进程内,代价是切换窗口内"刷新看不到旧实例 run 直播 / 停止送达不到"两个边缘。2026-07-06 重评(用户量已非个位数)决定实施 —— ①部署时总有 in-flight run,窗口边缘从偶发变常态;②单进程逼近天花板后,最近的扩容手段是稳态双实例同时接流量,broker 外置是它的硬前提(否则 POST 与 SSE 落不同实例直播全瞎)。选 Redis 不选 PG LISTEN/NOTIFY(token 级 delta 全过主库太吵:NOTIFY 全局队列 + 8KB payload 限制,把实时路径耦到 PG)、不选 nginx sticky hash(upstream 变更时 hash 重排,恰在部署窗口失效,治标)。实现(`web/broker.py`,LocalRunBroker/RedisRunBroker 同接口鸭子类型):`ZCBOT_REDIS_URL` env 开关,不设即进程内(dev 零影响);event 走 `PUBLISH zcbot:ev:<tid>` + 单条 async pubsub reader 路由到本地订阅 queue(Redis 只管跨进程一跳,fan-out 最后一公里仍在进程内);done = SETEX key(60s)+ publish 双通道,订阅先挂 channel 再查 key 不漏;cancel = SETEX/GET/DEL key,loop 在 chunk 间 poll(localhost ~0.1ms)。取消另以共享 PG 中已经提交的 `tasks.run_status='cancelling'` 作可靠兜底:运行线程每秒低频读取一次,未配置 Redis 的蓝绿窗口或 Redis 运行中故障时仍可跨实例停止PG 不承载 token 直播事件。容错纪律:启动 ping 不通 fail-fast(同 sandbox init),运行中失败 10s 节流 log + 降级丢帧,reader 1s 退避重连。Redis 不解决的:run 本体仍绑进程(进程死 run 死,drain/reaper 不变)、线程池上限(调 `ZCBOT_RUN_MAX_WORKERS` 的事)。 **broker 外置(Redis pub/sub,✅ 0.42)**:0.41 落地时 event/cancel broker 留在进程内,代价是切换窗口内"刷新看不到旧实例 run 直播 / 停止送达不到"两个边缘。2026-07-06 重评(用户量已非个位数)决定实施 —— ①部署时总有 in-flight run,窗口边缘从偶发变常态;②单进程逼近天花板后,最近的扩容手段是稳态双实例同时接流量,broker 外置是它的硬前提(否则 POST 与 SSE 落不同实例直播全瞎)。选 Redis 不选 PG LISTEN/NOTIFY(token 级 delta 全过主库太吵:NOTIFY 全局队列 + 8KB payload 限制,把实时路径耦到 PG)、不选 nginx sticky hash(upstream 变更时 hash 重排,恰在部署窗口失效,治标)。实现(`web/broker.py`,LocalRunBroker/RedisRunBroker 同接口鸭子类型):`ZCBOT_REDIS_URL` env 开关,不设即进程内(dev 零影响);event 走 `PUBLISH zcbot:ev:<tid>` + 单条 async pubsub reader 路由到本地订阅 queue(Redis 只管跨进程一跳,fan-out 最后一公里仍在进程内);done = SETEX key(60s)+ publish 双通道,订阅先挂 channel 再查 key 不漏;cancel 在鉴权后先置本实例 Event再 SETEX/GET/DEL key 跨实例传递loop 在 chunk 间优先 poll 本地信号(通常约 100ms 内退出,不等待 Redis 或 DB 往返)。取消另以共享 PG 中已经提交的 `tasks.run_status='cancelling'` 作可靠兜底:运行线程每秒低频读取一次,未配置 Redis 的蓝绿窗口或 Redis 运行中故障时仍可跨实例停止PG 不承载 token 直播事件。容错纪律:启动 ping 不通 fail-fast(同 sandbox init),运行中失败 10s 节流 log + 降级丢帧,reader 1s 退避重连。Redis 不解决的:run 本体仍绑进程(进程死 run 死,drain/reaper 不变)、线程池上限(调 `ZCBOT_RUN_MAX_WORKERS` 的事)。
### 7.1 心智模型:Task 一等公民 + Dir 文件副视图 ### 7.1 心智模型:Task 一等公民 + Dir 文件副视图

View File

@ -55,12 +55,15 @@ test("composer uses one Codex-style action button for stop and queued send", ()
assert.match(html, /\.msg\.queued/); assert.match(html, /\.msg\.queued/);
assert.match(chat, /card\.className = "msg user queued"/); assert.match(chat, /card\.className = "msg user queued"/);
assert.match(chat, /function composerHasPayload\(\)/); assert.match(chat, /function composerHasPayload\(\)/);
assert.match(chat, /function isCurrentTaskRunActive\(\)/);
assert.match(chat, /btn\.textContent = "停止"/); assert.match(chat, /btn\.textContent = "停止"/);
assert.match(chat, /if \(composerHasPayload\(\)\)[\s\S]*queueCurrentMessage\(\)/); assert.match(chat, /if \(composerHasPayload\(\)\)[\s\S]*queueCurrentMessage\(\)/);
assert.match(chat, /_actionMode === "loading" \|\| _actionMode === "submitting"[\s\S]*btn\.disabled = true/); assert.match(chat, /_actionMode === "loading" \|\| _actionMode === "submitting"[\s\S]*btn\.disabled = true/);
assert.match(chat, /setActionMode\("submitting"\)/); assert.match(chat, /setActionMode\("submitting"\)/);
assert.match(chat, /if \(state\.taskId === taskId\) \$\("chat-input"\)\.value = ""/); assert.match(chat, /if \(state\.taskId === taskId\) \$\("chat-input"\)\.value = ""/);
assert.match(chat, /if \(state\.taskId === taskId\) setActionMode\("streaming"\)/); assert.match(chat, /if \(state\.taskId === taskId\) setActionMode\("streaming"\)/);
assert.match(chat, /const activeRun = isCurrentTaskRunActive\(\)/);
assert.match(chat, /else if \(activeRun\)[\s\S]*cancelCurrentTask\(\)/);
assert.match(chat, /function queueCurrentMessage\(\)/); assert.match(chat, /function queueCurrentMessage\(\)/);
assert.match(chat, /async function dispatchNextQueuedMessage\(taskId\)/); assert.match(chat, /async function dispatchNextQueuedMessage\(taskId\)/);
assert.match(chat, /void dispatchNextQueuedMessage\(ctx\.taskId\)/); assert.match(chat, /void dispatchNextQueuedMessage\(ctx\.taskId\)/);

View File

@ -10,6 +10,7 @@
用两个 broker 实例共享同一 FakeServer 模拟蓝绿两进程 用两个 broker 实例共享同一 FakeServer 模拟蓝绿两进程
""" """
import asyncio import asyncio
import threading
import unittest import unittest
from uuid import uuid4 from uuid import uuid4
@ -134,8 +135,36 @@ class TestRedisBroker(unittest.TestCase):
b.emit(tid, {"type": "text", "delta": "x"}) # 不抛 b.emit(tid, {"type": "text", "delta": "x"}) # 不抛
self.assertFalse(b.is_cancelled(tid)) # 降级 False self.assertFalse(b.is_cancelled(tid)) # 降级 False
b.request_cancel(tid) # 不抛 b.request_cancel(tid) # 不抛
self.assertFalse(b.is_cancelled(tid))
b.close(tid) # 不抛 b.close(tid) # 不抛
def test_cancel_uses_local_signal_while_redis_write_is_blocked(self):
"""Redis 卡顿时,同实例 run 无需等待网络超时即可看到停止。"""
class SlowBroken:
def __init__(self):
self.writing = threading.Event()
self.release = threading.Event()
def set(self, *args, **kwargs):
self.writing.set()
self.release.wait(2)
raise ConnectionError("redis down")
def get(self, *args, **kwargs):
raise ConnectionError("redis down")
redis = SlowBroken()
b = RedisRunBroker("redis://fake:6379/0", sync_client=redis,
async_client=fakeredis.aioredis.FakeRedis(decode_responses=True))
tid = uuid4()
worker = threading.Thread(target=b.request_cancel, args=(tid,), daemon=True)
worker.start()
self.assertTrue(redis.writing.wait(1))
self.assertTrue(b.is_cancelled(tid))
redis.release.set()
worker.join(1)
self.assertFalse(b.is_cancelled(tid))
def test_bind_loop_fail_fast_when_unreachable(self): def test_bind_loop_fail_fast_when_unreachable(self):
class Boom: class Boom:
def ping(self): def ping(self):

View File

@ -194,8 +194,16 @@ class TasksCrudTests(unittest.TestCase):
tid = _client.post("/v1/tasks", json={"name": "状态闸任务"}, headers=_AUTH).json()["task_id"] tid = _client.post("/v1/tasks", json={"name": "状态闸任务"}, headers=_AUTH).json()["task_id"]
# idle 时 cancel → 409 # idle 时 cancel → 409
self.assertEqual(_client.post(f"/v1/tasks/{tid}/cancel", headers=_AUTH).status_code, 409) self.assertEqual(_client.post(f"/v1/tasks/{tid}/cancel", headers=_AUTH).status_code, 409)
# running 时 clear → 409;回 idle 后 clear → 200 且归零 # running 时 cancel 可重试:第一次切 cancelling第二次幂等补发信号。
_set_run_status(tid, "running") _set_run_status(tid, "running")
with patch("web.routers.messages.broker.request_cancel") as request_cancel:
first = _client.post(f"/v1/tasks/{tid}/cancel", headers=_AUTH)
second = _client.post(f"/v1/tasks/{tid}/cancel", headers=_AUTH)
self.assertEqual(first.status_code, 202, first.text)
self.assertEqual(second.status_code, 202, second.text)
self.assertEqual(request_cancel.call_count, 2)
# cancelling 时 clear → 409;回 idle 后 clear → 200 且归零
self.assertEqual(_client.post(f"/v1/tasks/{tid}/clear", headers=_AUTH).status_code, 409) self.assertEqual(_client.post(f"/v1/tasks/{tid}/clear", headers=_AUTH).status_code, 409)
_set_run_status(tid, "idle") _set_run_status(tid, "idle")
d = _client.post(f"/v1/tasks/{tid}/clear", headers=_AUTH).json() d = _client.post(f"/v1/tasks/{tid}/clear", headers=_AUTH).json()

View File

@ -173,6 +173,9 @@ class RedisRunBroker:
self._pubsub: Any = None self._pubsub: Any = None
self._reader_task: Optional[asyncio.Task] = None self._reader_task: Optional[asyncio.Task] = None
self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set) self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set)
# Redis 写入期间的同实例瞬时旁路HTTP 线程先置 Eventrun 线程下一次
# 轮询立即命中写入结束即清Redis key 负责跨实例传递和持久兜底。
self._cancel_flags: dict[UUID, threading.Event] = {}
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._last_err_log = 0.0 self._last_err_log = 0.0
@ -345,14 +348,23 @@ class RedisRunBroker:
# ─────────────── cancel signaling ─────────────── # ─────────────── cancel signaling ───────────────
def request_cancel(self, task_id: UUID) -> None: def request_cancel(self, task_id: UUID) -> None:
self._cancel_flags.setdefault(task_id, threading.Event()).set()
try: try:
self._redis.set(f"{self._CANCEL_PREFIX}{task_id}", "1", ex=self._CANCEL_TTL) self._redis.set(f"{self._CANCEL_PREFIX}{task_id}", "1", ex=self._CANCEL_TTL)
except Exception as e: except Exception as e:
self._log_err("request_cancel", e) self._log_err("request_cancel", e)
finally:
self._cancel_flags.pop(task_id, None)
def is_cancelled(self, task_id: UUID) -> bool: def is_cancelled(self, task_id: UUID) -> bool:
"""AgentLoop 在 stream chunk 间 poll(工作线程);redis 挂了返 False 降级 """AgentLoop 在 stream chunk 间 poll同实例先读 Event跨实例再读 Redis。
(cancel 暂不可达,等价于 redis 前的跨实例行为,run 不受影响)"""
Redis 运行中故障时跨实例快信号不可达但同实例 Event 与共享 DB fallback
仍可取消不影响正常 run
"""
local = self._cancel_flags.get(task_id)
if local is not None and local.is_set():
return True
try: try:
return self._redis.get(f"{self._CANCEL_PREFIX}{task_id}") is not None return self._redis.get(f"{self._CANCEL_PREFIX}{task_id}") is not None
except Exception as e: except Exception as e:
@ -360,6 +372,7 @@ class RedisRunBroker:
return False return False
def clear_cancel(self, task_id: UUID) -> None: def clear_cancel(self, task_id: UUID) -> None:
self._cancel_flags.pop(task_id, None)
try: try:
self._redis.delete(f"{self._CANCEL_PREFIX}{task_id}") self._redis.delete(f"{self._CANCEL_PREFIX}{task_id}")
except Exception as e: except Exception as e:

View File

@ -349,8 +349,11 @@ def register_message_routes(app, *, require_user) -> None:
"""向当前 task 的活跃 run 发协作式 cancel 信号。 """向当前 task 的活跃 run 发协作式 cancel 信号。
- 单活 run 形态下"取消当前活动"语义无歧义;客户端只需 task_id - 单活 run 形态下"取消当前活动"语义无歧义;客户端只需 task_id
- 校验 task 归属 user;否则 404 - 校验 task 归属 user;否则 404
- tasks.run_status 不是 `running` 409(idle / cancelling / error 都不能 cancel) - `running` `cancelling`;已经是 `cancelling` 时幂等重发 cancel 信号
- `cancelling`(过渡态),BG 线程 loop stream chunk + 工具调用之间 poll 看见即退; - 其他终态 409
- 鉴权和状态校验后先发 broker 快信号再持久化 `cancelling` 作为跨实例兜底
不让事务提交延迟模型停止
- BG 线程 loop stream chunk + 工具调用之间 poll 看见即退;
退出后 finally 写终态(正常idle,异常error) 退出后 finally 写终态(正常idle,异常error)
- LLM streaming,cancel 延迟 ~ chunk 间隔(100ms ) - LLM streaming,cancel 延迟 ~ chunk 间隔(100ms )
""" """
@ -362,19 +365,23 @@ def register_message_routes(app, *, require_user) -> None:
row = s.execute( row = s.execute(
select(Task.run_status, Task.title_source) select(Task.run_status, Task.title_source)
.where(Task.task_id == tid, Task.user_id == user_id) .where(Task.task_id == tid, Task.user_id == user_id)
.with_for_update()
).first() ).first()
if row is None: if row is None:
raise HTTPException(404, f"task not found: {tid}") raise HTTPException(404, f"task not found: {tid}")
if row.run_status != "running": if row.run_status not in ("running", "cancelling"):
raise HTTPException( raise HTTPException(
409, 409,
f"task not running (run_status={row.run_status}); cannot cancel", f"task not running (run_status={row.run_status}); cannot cancel",
) )
s.execute( # 先走低延迟 broker。DB UPDATE/commit 即使被短事务阻塞,当前模型读取线程
update(Task).where(Task.task_id == tid).values(run_status="cancelling") # 也能先在约 100ms 的轮询周期内退出cancelling 列继续承担跨实例可靠兜底。
)
broker.request_cancel(tid) broker.request_cancel(tid)
if row.run_status == "running":
s.execute(
update(Task)
.where(Task.task_id == tid, Task.run_status == "running")
.values(run_status="cancelling")
)
return {"ok": True, "task_id": str(tid), "run_status": "cancelling"} return {"ok": True, "task_id": str(tid), "run_status": "cancelling"}
# ───────────── Background procs(bg proc,DESIGN §8.12)───────────── # ───────────── Background procs(bg proc,DESIGN §8.12)─────────────

View File

@ -1366,6 +1366,16 @@ function isCurrentTaskStreaming() {
return !!getLiveRun(state.taskId); return !!getLiveRun(state.taskId);
} }
function isCurrentTaskRunActive() {
if (isCurrentTaskStreaming()) return true;
const meta = state.taskMeta;
return !!(
meta
&& meta.task_id === state.taskId
&& (meta.run_status === "running" || meta.run_status === "cancelling")
);
}
// 直播卡片内文字按「轮次」分段:每段一个 .body,工具调用会关闭当前段,之后的新文字 // 直播卡片内文字按「轮次」分段:每段一个 .body,工具调用会关闭当前段,之后的新文字
// 在卡片底部另起一段 —— 使流式文字与工具卡按时序穿插、最新文字始终贴在底部可见。 // 在卡片底部另起一段 —— 使流式文字与工具卡按时序穿插、最新文字始终贴在底部可见。
// 历史渲染天然按消息分段,直播这样分段后两态结构一致,run 结束 reload 无跳变。 // 历史渲染天然按消息分段,直播这样分段后两态结构一致,run 结束 reload 无跳变。
@ -2079,11 +2089,14 @@ export async function openSoftwareJobResults(taskId, outputDir) {
} }
function chatAction() { function chatAction() {
const busy = isCurrentTaskStreaming() || hasRunningProc(state.taskId); // SSE 重连 / 收尾交错时 liveRuns 可能短暂缺席,但 task 的持久状态仍是 running。
// 按钮既然处于运行态,点击空 composer 必须继续走 cancel不能退化成“发送空消息”。
const activeRun = isCurrentTaskRunActive();
const busy = activeRun || hasRunningProc(state.taskId);
if (busy) { if (busy) {
if (composerHasPayload()) { if (composerHasPayload()) {
queueCurrentMessage(); queueCurrentMessage();
} else if (isCurrentTaskStreaming()) { } else if (activeRun) {
cancelCurrentTask(); cancelCurrentTask();
} else { } else {
killTaskProcs(state.taskId); killTaskProcs(state.taskId);
@ -3160,21 +3173,44 @@ async function sendMessage(overrideText) {
async function cancelCurrentTask() { async function cancelCurrentTask() {
const run = getLiveRun(state.taskId); const run = getLiveRun(state.taskId);
if (!state.taskId || !run) return; const taskId = state.taskId;
run.cancelling = true; if (!taskId || !isCurrentTaskRunActive()) return;
if (run) run.cancelling = true;
if (state.taskMeta && state.taskMeta.task_id === taskId) {
state.taskMeta.run_status = "cancelling";
}
setActionMode("cancelling"); setActionMode("cancelling");
syncTaskRowRunIndicator(state.taskId); syncTaskRowRunIndicator(taskId);
$("chat-hint").textContent = "停止中…"; $("chat-hint").textContent = "停止中…";
try { try {
await api("POST", `/v1/tasks/${state.taskId}/cancel`); await api("POST", `/v1/tasks/${taskId}/cancel`);
// 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清 // 不重置 streaming / 按钮 — 等 SSE 的 cancelled / done 走完一并清
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
// 409 = 已结束 / 已 cancelling,不算错;其他贴 toast // 409 只可能来自滚动升级中的旧后端:回读真实状态,仍在取消就保持停止中,
if (e.status !== 409) appendErrorCard("cancel: " + e.message); // 已终止则让现有 SSE/任务刷新接管。网络错误才恢复为可重试的“停止”。
run.cancelling = false; if (e.status === 409) {
try {
const meta = await api("GET", `/v1/tasks/${taskId}`);
if (state.taskId !== taskId) return;
state.taskMeta = meta;
if (meta.run_status === "cancelling") return;
if (meta.run_status !== "running") {
if (run) discardInactiveLiveRun(taskId);
else setActionMode("idle");
$("chat-hint").textContent = "";
return;
}
} catch (_) { /* 下面恢复按钮,允许用户再次尝试 */ }
} else {
appendErrorCard("cancel: " + e.message);
}
if (run) run.cancelling = false;
if (state.taskMeta && state.taskMeta.task_id === taskId) {
state.taskMeta.run_status = "running";
}
setActionMode("streaming"); setActionMode("streaming");
syncTaskRowRunIndicator(run.taskId); syncTaskRowRunIndicator(taskId);
$("chat-hint").textContent = "接收中…"; $("chat-hint").textContent = "接收中…";
} }
} }