fix(web): make run cancellation respond immediately
This commit is contained in:
parent
ca73a4affb
commit
5b052c464b
|
|
@ -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
|
||||||
|
|
||||||
- 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。
|
- 提高工具参数异常时的自动恢复率,产物发布和工具健康分类也更加稳定准确。
|
||||||
|
|
|
||||||
|
|
@ -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 文件副视图
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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\)/);
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 线程先置 Event,run 线程下一次
|
||||||
|
# 轮询立即命中;写入结束即清,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:
|
||||||
|
|
|
||||||
|
|
@ -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)─────────────
|
||||||
|
|
|
||||||
|
|
@ -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 = "接收中…";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue