core(run cancel): POST /runs/{rid}/cancel + AgentLoop 协作式 cancel + dev SPA stop 按钮

落地 DESIGN §7.2 原标"待"的 cancel 路由 — 等待回复 / LLM 操作时也能中断。

- broker 加 request_cancel / is_cancelled / clear_cancel(per-run threading.Event)
- AgentLoop 加 cancel_check 回调,每轮 LLM 前 + tool_calls 之间 poll;命中给
  未执行 tool_call 补 [cancelled by user] tool result 保 LiteLLM 协议,emit
  cancelled event
- 单活 gate + 启动 reaper 扩到 running | cancelling
- BG 装 cancel_check + 终态判 cancelled/ok + finally clear flag
- dev SPA stop 按钮 + cancelled badge + fetchSse try/finally 失败路径复原 UI

LLM 同步 call 本身不可中断,最坏等当前一轮跑完(通常几十秒)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-18 08:42:45 +08:00
parent 976ef45e87
commit bf41631437
7 changed files with 203 additions and 58 deletions

View File

@ -255,7 +255,13 @@ Tasks
POST 防 `messages.idx` UniqueConstraint race; POST 防 `messages.idx` UniqueConstraint race;
UI 应 disable send 按钮直到 SSE `done`) UI 应 disable send 按钮直到 SSE `done`)
GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下) GET /v1/tasks/{id}/runs/{rid}/events SSE 流(见下)
POST /v1/tasks/{id}/runs/{rid}/cancel (待 — 做出来后 409 可主动 cancel,不必等流式结束) POST /v1/tasks/{id}/runs/{rid}/cancel 协作式 cancel(202):标 `cancelling` + 信号
broker;BG loop 在工具调用之间 poll 看见即退,
给未执行 tool_call 补 `[cancelled by user]`
tool result(保 LiteLLM 协议),emit `cancelled`
事件;finally 写终态 `cancelled`(异常 `error`)。
LLM 同步 call 本身不可中断 — 最坏等当前一轮跑完。
run.status != `running` → 409(已结束 / 已 cancelling)
Files(user-rooted,不绑 task — `workspace/users/<uid>/` 为根) Files(user-rooted,不绑 task — `workspace/users/<uid>/` 为根)
GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};留空 → user_root; GET /v1/files?path= 列子目录 {entries, crumbs, exists, root, current};留空 → user_root;
@ -281,6 +287,7 @@ text {"content":"<delta 或全量,取决于 model streaming 配置>"}
tool_call {"name":"...","args":{...},"args_preview":"..."} tool_call {"name":"...","args":{...},"args_preview":"..."}
tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览给 UI tool_result {"name":"...","preview":"...","truncated":bool} # 完整 result 走 DB,SSE 只送预览给 UI
llm_end {"prompt_tokens":N,"completion_tokens":N} llm_end {"prompt_tokens":N,"completion_tokens":N}
cancelled {} # cancel 命中,后随 done 收流
error {"msg":"<type>: <detail>"} error {"msg":"<type>: <detail>"}
done {} done {}
``` ```
@ -403,7 +410,8 @@ usage_events(id, user_id, task_id uuid, run_id uuid, kind, value, ts)
| 误删 folder | 二确认 + 输入 folder 名;真要再加 trash bin | | 误删 folder | 二确认 + 输入 folder 名;真要再加 trash bin |
| DB-then-FS 中断留孤儿目录 | 后台 GC 周期扫"FS 有但 DB 无引用" | | DB-then-FS 中断留孤儿目录 | 后台 GC 周期扫"FS 有但 DB 无引用" |
| 同 folder 多 task 并发写同名 | 文件级悲观锁,冲突早失败 | | 同 folder 多 task 并发写同名 | 文件级悲观锁,冲突早失败 |
| 同 task 并发 POST messages 撞 `messages.idx` UniqueConstraint | `POST /v1/tasks/{id}/messages` 单活 run 检查:`SELECT … FOR UPDATE` 锁 task 行 + 查 `runs.status='running'`,有 → 409;同事务插新 Run 行避 TOCTOU。配启动 lifespan reaper 把孤儿 running 标 error(进程 crash 残留)。未来真生产 multi-worker 换 heartbeat / lease | | 同 task 并发 POST messages 撞 `messages.idx` UniqueConstraint | `POST /v1/tasks/{id}/messages` 单活 run 检查:`SELECT … FOR UPDATE` 锁 task 行 + 查 `runs.status in ('running','cancelling')`,有 → 409;同事务插新 Run 行避 TOCTOU。配启动 lifespan reaper 把孤儿 `running`/`cancelling` 全标 error(进程 crash 残留)。未来真生产 multi-worker 换 heartbeat / lease |
| Run 跑太久 / 用户想中断 | `POST /v1/tasks/{id}/runs/{rid}/cancel` 协作式 cancel:标 `cancelling` + broker 信号;`AgentLoop.cancel_check` 回调在每轮 LLM 前、tool_calls 之间 poll;命中给未执行 tool_call 补 `[cancelled by user]` tool result 保 LiteLLM 协议,emit `cancelled` 事件,BG finally 写终态 `cancelled`。LLM 同步 call 本身不可中断 — 接受最坏等当前一轮跑完(几十秒内) |
| Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI | | Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI |
| 资源滥用 | BYO key 默认;月度配额;cold task LRU 清 | | 资源滥用 | BYO key 默认;月度配额;cold task LRU 清 |

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(`POST /v1/tasks/{id}/messages` 同 task 单活 run:`SELECT … FOR UPDATE` 锁 task + 活跃 Run 检查,已有 running → 409;启动 lifespan reaper 把孤儿 running 标 error) 最后更新:2026-05-18(`POST /v1/tasks/{id}/runs/{rid}/cancel` 协作式 cancel + `cancelled` SSE 事件 + dev SPA stop 按钮;gate 扩到 `cancelling`)
--- ---
@ -15,12 +15,13 @@
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;**同 task 单活 run 锁 ✅**(POST /messages 409 + lifespan reaper);真 OIDC 待;C(Executor)待;E(CLI 双模式)待。 | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;**D `/v1` JSON API 完工 ✅**(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);**D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅**;**同 task 单活 run 锁 ✅**;**run cancel + dev SPA stop 按钮 ✅**;真 OIDC 待;C(Executor)待;E(CLI 双模式)待。 |
--- ---
## 已完成关键能力 ## 已完成关键能力
- **05-18 / cancel run endpoint + AgentLoop 协作式 cancel + dev SPA stop 按钮**:用户反馈"等待回复或 LLM 操作时没有停止接口"。落地 DESIGN §7.2 原标"待"的 `POST /v1/tasks/{id}/runs/{rid}/cancel`。**Broker**(`web/broker.py`):加 `request_cancel(rid)` / `is_cancelled(rid)` / `clear_cancel(rid)` 三方法,内部 `dict[UUID, threading.Event]` per-run;`setdefault` 保证 BG 还没 register 也能 set。**Loop**(`core/loop.py`):`AgentLoop` 加 `cancel_check: Optional[Callable[[], bool]]` 字段(CLI 路径不传 = None 永不 cancel),`_is_cancelled()` helper + `_fill_cancelled_tool_results(remaining)` 给未执行的 tool_call 全部 append `[cancelled by user]` tool message —— LiteLLM 协议要求每个 assistant tool_call 必须有匹配 tool result,否则 resume 时 LLM 报错。Check 点:每轮 LLM 前 + tool_calls 之间。命中 emit `cancelled` event + return `[cancelled]`。**LLM 同步 call 本身不可中断**(litellm 同步阻塞,无原生 cancel)—— 接受最坏等当前一轮跑完(通常几十秒),注释里讲清楚。**Endpoint**(`web/app.py::cancel_run`):校验 task 归属 user + run 归属 task(else 404),`run.status` 必须是 `running`(else 409 含具体 status);标 `cancelling`(过渡态)+ `broker.request_cancel(rid)`;202。`_run_agent_bg` 装配时 `agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid)`,run 完时判 `broker.is_cancelled` 写终态 `cancelled` vs `ok`;finally `broker.clear_cancel + broker.close`。**Gate 同步扩**:`post_message` 单活 run 检查从 `status == 'running'``status in ('running', 'cancelling')`,确保 cancel 后旧 BG 还没退出时新 POST 仍 409(避免新旧 run 撞 messages.idx)。**Reaper 同步扩**:lifespan 启动也扫 `cancelling`(进程 crash 时 BG 来不及写终态 cancelled,反正没线程在跑就清掉)。**dev SPA**(`web/static/dev.html`):chat 表单加 `<button id="chat-cancel" class="small danger">stop</button>`(常态 hidden);state 加 `currentRunId`;sendMessage 拿到 run_id 后 show stop,fetchSse `try/finally` 收尾时一并 hide stop / 清 currentRunId / 复原 send button(确保 SSE 失败路径 UI 也 reset 不卡死)。cancel 按钮 click → `POST /runs/{rid}/cancel`;409 静默忽略(并发 done 不算错)。`handleSseEvent` 加 `cancelled` case → 在当前 assistant 卡贴一个虚线红框 "已停止(stopped by user)" badge。CSS 加 `.cancelled-badge`。**Smoke 15 case 全绿**:HTTP 层 11 case(cancel happy + 双 cancel 409 + cancelling 期间 POST messages 409 + ghost run 404 + invalid UUID 404 + cross-task 404 + cross-user 404 + cancel 已 ok 409 + cancel 已 error 409 + no auth 401 + stale reaper 扫 cancelling);Loop 层 4 case(cancel before first iter 不调 LLM / cancel between tool_calls 补 cancelled placeholder 3 个 + 保协议 + emit cancelled / 正常 done 不 emit cancelled / CLI 路径 cancel_check=None 默永不 cancel)。**没动 SSE handler 的 break list**(`("done", "error")`):cancelled 在 SSE 里走流给前端看,broker.close 之后立即跟 done 收流。**文档同步**:DESIGN §7.2 路由表 cancel 行从"待"扩成完整描述 + SSE 事件加 `cancelled{}` 行 + §7.7 风险表加"Run 跑太久 / 用户想中断"行;RUN 路由表加 cancel 行 + POST /messages 409 文案改 "running / cancelling" + 故障兜底加三行(cancel 409 / 点 stop 没立刻停 / reaper 扫 cancelling);PROGRESS 已完成 + 下一步重排(去掉 cancel,留 OIDC / C Executor / E CLI 双模式)。
- **05-18 / `POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:用户连点 send / 多 tab 同时发消息 → 两个 BG 线程争 `messages.idx`(UniqueConstraint 会 race-crash 第二个 INSERT)的旧 TODO 落地。**实现**:`web/app.py::post_message` 把所有权 + 活跃 Run 检查 + 新 Run INSERT 收进一个 `session_scope()` 事务,首行用 `select(Task.task_id).where(...).with_for_update()` 锁 task 行序列化并发 POST;事务内查 `Run.status='running'` 命中即 raise `HTTPException(409, "task already has a running run ({rid}); wait for it to finish")`;无活跃则同事务 `s.add(Run(...status="running"))` —— 三步原子完成,避免 TOCTOU。lifespan 加 **stale-run reaper**:启动时 `UPDATE runs SET status='error', error='server restarted before run finished' WHERE status='running'`,把进程 crash 留下的孤儿 running 全清掉(否则对应 task 永挂 409)。结果 rowcount > 0 时 print info 行 `[startup] reaped N stale running run(s)`。Cancel 路由(DESIGN §7.2 标 "待")没改:有了它 409 时用户可主动 cancel,不必等流式结束。**没动 `Session.append`**:gate 已在 HTTP 层挡住了,单写者前提下 idx 自递增不会冲;在 ORM 里再加锁是过度。**Smoke 10 case 全绿**(in-process TestClient + `_run_agent_bg` mock 不真起 LLM):happy(202 + Run INSERT running)/ gate(同 task 第二 POST 409 + detail 含 "running run" + "wait for it to finish")/ clear after Run.status=ok 解锁(202)/ clear after Run.status=error 同(202)/ ghost task 跨用户路径 404(锁前所有权检查)/ invalid UUID 404 / empty content 400 早于 lock / no auth 401 早于 lock / stale reaper 测试(强行 SET 全部 Run=running → 开新 TestClient 触发 lifespan → 所有 running 变 error + 之后 POST 还能 202)/ cross-user(other UID token 访 sentinel task → 404 不暴露存在性)。**采坑**:`@case` 每个用 `make_client()` 起新 app 会重复触发 reaper,把 case 1 留下的 running 清掉 → case 2 的 409 测不出来;改成全部 case 共享一个 SHARED_CLIENT 跑,仅 stale-reaper case 用 `fresh=True` 开第二个。**文档同步**:DESIGN §7.2 POST /messages 行注 409 行为 + cancel "待" 后注"做出来后 409 可主动 cancel" / §7.7 风险表加"同 task 并发 POST messages.idx race"行;RUN 路由表 POST /messages 注 409;故障兜底替过期 TODO 行 → 加 "POST 返 409" 处置 + "[startup] reaped N stale running" 解释。**未来 TODO**:multi-worker 部署形态下 reaper 不能简单全表清(会误清其他 worker 的真在跑 run),换 heartbeat + lease(注释里记了)。 - **05-18 / `POST /v1/tasks/{id}/messages` 单活 run 锁 + 孤儿 reaper**:用户连点 send / 多 tab 同时发消息 → 两个 BG 线程争 `messages.idx`(UniqueConstraint 会 race-crash 第二个 INSERT)的旧 TODO 落地。**实现**:`web/app.py::post_message` 把所有权 + 活跃 Run 检查 + 新 Run INSERT 收进一个 `session_scope()` 事务,首行用 `select(Task.task_id).where(...).with_for_update()` 锁 task 行序列化并发 POST;事务内查 `Run.status='running'` 命中即 raise `HTTPException(409, "task already has a running run ({rid}); wait for it to finish")`;无活跃则同事务 `s.add(Run(...status="running"))` —— 三步原子完成,避免 TOCTOU。lifespan 加 **stale-run reaper**:启动时 `UPDATE runs SET status='error', error='server restarted before run finished' WHERE status='running'`,把进程 crash 留下的孤儿 running 全清掉(否则对应 task 永挂 409)。结果 rowcount > 0 时 print info 行 `[startup] reaped N stale running run(s)`。Cancel 路由(DESIGN §7.2 标 "待")没改:有了它 409 时用户可主动 cancel,不必等流式结束。**没动 `Session.append`**:gate 已在 HTTP 层挡住了,单写者前提下 idx 自递增不会冲;在 ORM 里再加锁是过度。**Smoke 10 case 全绿**(in-process TestClient + `_run_agent_bg` mock 不真起 LLM):happy(202 + Run INSERT running)/ gate(同 task 第二 POST 409 + detail 含 "running run" + "wait for it to finish")/ clear after Run.status=ok 解锁(202)/ clear after Run.status=error 同(202)/ ghost task 跨用户路径 404(锁前所有权检查)/ invalid UUID 404 / empty content 400 早于 lock / no auth 401 早于 lock / stale reaper 测试(强行 SET 全部 Run=running → 开新 TestClient 触发 lifespan → 所有 running 变 error + 之后 POST 还能 202)/ cross-user(other UID token 访 sentinel task → 404 不暴露存在性)。**采坑**:`@case` 每个用 `make_client()` 起新 app 会重复触发 reaper,把 case 1 留下的 running 清掉 → case 2 的 409 测不出来;改成全部 case 共享一个 SHARED_CLIENT 跑,仅 stale-reaper case 用 `fresh=True` 开第二个。**文档同步**:DESIGN §7.2 POST /messages 行注 409 行为 + cancel "待" 后注"做出来后 409 可主动 cancel" / §7.7 风险表加"同 task 并发 POST messages.idx race"行;RUN 路由表 POST /messages 注 409;故障兜底替过期 TODO 行 → 加 "POST 返 409" 处置 + "[startup] reaped N stale running" 解释。**未来 TODO**:multi-worker 部署形态下 reaper 不能简单全表清(会误清其他 worker 的真在跑 run),换 heartbeat + lease(注释里记了)。
- **05-17 / files API 全面 user-rooted(去掉 task_id 前置)**:用户反馈"web 页应该能看到 user 的所有目录,现在只能选 task 后右侧才刷新"——根因是原 files API 用 task_id 拐杖间接拿 working_dir,迫使前端必须先选 task。语义上 files 操作只关心"路径 + user 边界",task_id 是多余的;同时 §7.1 心智模型早就把 task 和 dir 定义为正交副视图,API 不该混。**后端**:删 `_load_working_dir(task_id, user_id)`,加 `_load_user_root(user_id)`(走 `main.user_root(ws, uid)` 自动 mkdir 拿 `workspace/users/<uid>/`);4 路由全换:`GET /v1/files?path=` / `GET /v1/files/download?path=` / `POST /v1/files/upload` / `POST /v1/files/delete`。`_safe_join` 边界从 task_dir 改 user_root,安全性不降低;`_enumerate_files` 加 dotfile 过滤(`if p.name.startswith(".")` 跳过 `.memory/` 等,同 `/v1/folders` 约定);`_rel_to` 把 `Path(".")` 归一为空串(避免 root 时 current="." 这种 ugly 形态)。删 `from_db_path` import(只剩 `to_db_path`)。**dev SPA**:`loadFiles()` 不再 gate on `state.taskId`,enterApp 时直接调一次拉 user_root;`selectTask` 在拿到 task meta 后 `state.filesPath = wdName`(从 working_dir 末段抽出)再 loadFiles,选 task 自动跳到对应子目录但用户可点 crumb 回 root 看其他目录;crumbs root 标签 "/" → "我的"(user_root 直观);files-proj header 从"项目名(state.taskMeta 派生)"改"路径首段(数据驱动)",空时显示 `(user root)`。**新增 upload 按钮**(原来藏在外部页面里没暴露给 SPA):pane-head 加 `⬆` 按钮 + 隐藏 `<input type=file multiple>`,onchange 走 FormData POST `/v1/files/upload`,path 取当前 `state.filesPath`(空 → user_root);上传完 loadFiles 刷新。`deleteCurrentTask` 不再重置 files 面板(task 删了但 FS 文件保留,继续浏览有意义),只 reload 当前路径。`btn-refresh-files` 移除 disabled 状态(任何时候可用)。**Smoke 68 case 全绿**(in-process TestClient,跑完即删 `_smoke_files.py`):列 user_root(包含 working_dir 目录,`.memory` 被过滤) / 列子目录 2 层 / 不存在路径 200+exists=False / 路径安全 6 case(`../` / 绝对 / Windows 绝对 / `\\` 起头)/ upload 单 / multi+nested mkdir / 上传到 root / 文件名攻击 4 case(`../` `..` `/` `\\`)/ download 文件 + 深度 + 目录 400 + ghost 404 + 越界 400 / delete 文件 / 空目录 / 非空 400 / user_root 拒 / ghost 404 / 越界 400 / 跨 user 隔离 4 case(A 不见 B,B 不见 A)/ 无 token 全 401(GET list / POST upload / POST delete / GET download)/ 子目录里 dotfile 也过滤 / 新 user 首访 user_root 自动 mkdir + 列表空。**文档**:DESIGN §7.2 路由表段 + lead-in 同步("Task 一等公民,files 是其副视图(经 task_dir 暴露)" → "Task 一等公民;files 与 task 正交,走 user-rooted /v1/files*,以 workspace/users/<uid>/ 为边界")。 - **05-17 / files API 全面 user-rooted(去掉 task_id 前置)**:用户反馈"web 页应该能看到 user 的所有目录,现在只能选 task 后右侧才刷新"——根因是原 files API 用 task_id 拐杖间接拿 working_dir,迫使前端必须先选 task。语义上 files 操作只关心"路径 + user 边界",task_id 是多余的;同时 §7.1 心智模型早就把 task 和 dir 定义为正交副视图,API 不该混。**后端**:删 `_load_working_dir(task_id, user_id)`,加 `_load_user_root(user_id)`(走 `main.user_root(ws, uid)` 自动 mkdir 拿 `workspace/users/<uid>/`);4 路由全换:`GET /v1/files?path=` / `GET /v1/files/download?path=` / `POST /v1/files/upload` / `POST /v1/files/delete`。`_safe_join` 边界从 task_dir 改 user_root,安全性不降低;`_enumerate_files` 加 dotfile 过滤(`if p.name.startswith(".")` 跳过 `.memory/` 等,同 `/v1/folders` 约定);`_rel_to` 把 `Path(".")` 归一为空串(避免 root 时 current="." 这种 ugly 形态)。删 `from_db_path` import(只剩 `to_db_path`)。**dev SPA**:`loadFiles()` 不再 gate on `state.taskId`,enterApp 时直接调一次拉 user_root;`selectTask` 在拿到 task meta 后 `state.filesPath = wdName`(从 working_dir 末段抽出)再 loadFiles,选 task 自动跳到对应子目录但用户可点 crumb 回 root 看其他目录;crumbs root 标签 "/" → "我的"(user_root 直观);files-proj header 从"项目名(state.taskMeta 派生)"改"路径首段(数据驱动)",空时显示 `(user root)`。**新增 upload 按钮**(原来藏在外部页面里没暴露给 SPA):pane-head 加 `⬆` 按钮 + 隐藏 `<input type=file multiple>`,onchange 走 FormData POST `/v1/files/upload`,path 取当前 `state.filesPath`(空 → user_root);上传完 loadFiles 刷新。`deleteCurrentTask` 不再重置 files 面板(task 删了但 FS 文件保留,继续浏览有意义),只 reload 当前路径。`btn-refresh-files` 移除 disabled 状态(任何时候可用)。**Smoke 68 case 全绿**(in-process TestClient,跑完即删 `_smoke_files.py`):列 user_root(包含 working_dir 目录,`.memory` 被过滤) / 列子目录 2 层 / 不存在路径 200+exists=False / 路径安全 6 case(`../` / 绝对 / Windows 绝对 / `\\` 起头)/ upload 单 / multi+nested mkdir / 上传到 root / 文件名攻击 4 case(`../` `..` `/` `\\`)/ download 文件 + 深度 + 目录 400 + ghost 404 + 越界 400 / delete 文件 / 空目录 / 非空 400 / user_root 拒 / ghost 404 / 越界 400 / 跨 user 隔离 4 case(A 不见 B,B 不见 A)/ 无 token 全 401(GET list / POST upload / POST delete / GET download)/ 子目录里 dotfile 也过滤 / 新 user 首访 user_root 自动 mkdir + 列表空。**文档**:DESIGN §7.2 路由表段 + lead-in 同步("Task 一等公民,files 是其副视图(经 task_dir 暴露)" → "Task 一等公民;files 与 task 正交,走 user-rooted /v1/files*,以 workspace/users/<uid>/ 为边界")。
- **Q1 → 05-06 / Phase 1-4**:骨架 / 三 skill / run_python / Model Profile + Probing。ppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。 - **Q1 → 05-06 / Phase 1-4**:骨架 / 三 skill / run_python / Model Profile + Probing。ppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。
@ -75,7 +76,7 @@
``` ```
core/capabilities.py 71 core/capabilities.py 71
core/llm.py 93 ← +litellm 离线 cost map env core/llm.py 93 ← +litellm 离线 cost map env
core/loop.py 152 ← §7 A: sink.emit core/loop.py 182 ← §7 A sink.emit + cancel_check 协作式 cancel
core/sinks.py 101 ← §7 A core/sinks.py 101 ← §7 A
core/ui.py 38 core/ui.py 38
core/paths.py 50 ← task_dir db form 归一(to_db_path / from_db_path) core/paths.py 50 ← task_dir db form 归一(to_db_path / from_db_path)
@ -101,13 +102,13 @@ db/migrations/versions/
0001_initial_schema.py 125 ← §7 B Step 1 0001_initial_schema.py 125 ← §7 B Step 1
0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对 0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对
web/__init__.py 5 ← Phase G G1 web/__init__.py 5 ← Phase G G1
web/app.py 815 ← /v1/ JSON API + user_id 隔离 + files user-rooted web/app.py 898 ← /v1/ JSON API + user_id 隔离 + run lock + cancel endpoint
web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换 web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换
web/broker.py 88 Phase G G4: in-process pub/sub web/broker.py 109 ← in-process pub/sub + cancel signal per-run
web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议) web/sinks.py 20 ← Phase G G4: WebEventSink (§7 A sink 协议)
web/static/dev.html ~600 ← D' dev SPA(login + 3-pane,vanilla JS) web/static/dev.html 1133 ← D' dev SPA + stop 按钮 + cancelled badge
───────────────────────────────── ─────────────────────────────────
Python 合计 ~3700 行(+ dev.html ~600 静态) Python 合计 ~3800 行(+ dev.html 1133 静态)
``` ```
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。 加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。
@ -117,9 +118,8 @@ Python 合计 ~3700 行(+ dev.html ~600 静态)
## 下一步候选(性价比排序) ## 下一步候选(性价比排序)
1. **真 OIDC 接入 + CORS 收紧**(~1 天)—— 把 `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。**真发布给真实用户前必做**。 1. **真 OIDC 接入 + CORS 收紧**(~1 天)—— 把 `/v1/auth/login` 内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。**真发布给真实用户前必做**。
2. **`POST /v1/tasks/{id}/runs/{rid}/cancel`**(~1-2 小时)—— DESIGN §7.2 标 "待"。有了它 409 时用户可主动 cancel 当前 run 而非等流式跑完;BG 线程需要 cooperative cancel(check `Run.status` 已被改 `cancelling` 时 raise/break)。 2. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`,本地保留 subprocess、SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。多用户在线跑代码前置。
3. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`,本地保留 subprocess、SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。多用户在线跑代码前置。 3. **§7 E CLI transport 双模式**(~1.5 天)—— `cli.py chat --remote https://...` 走 HTTP 替代 in-process。dogfood ≡ 用户路径。
4. **§7 E CLI transport 双模式**(~1.5 天)—— `cli.py chat --remote https://...` 走 HTTP 替代 in-process。dogfood ≡ 用户路径。 4. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
5. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
> §7 B + D + D'(过渡 auth)+ 单活 run 锁 主体已完工。剩余路线:真 OIDC → cancel 路由 → C(Executor)→ E(CLI 双模式)→ F(deploy / billing)。原 Phase G Web UI 路线撤(DESIGN §7.9),UI 改 platform 端实现;`web/static/dev.html` 是开发期单文件 SPA,跟 platform UI 并存不冲突。 > §7 B + D + D'(过渡 auth)+ 单活 run 锁 + cancel 主体已完工。剩余路线:真 OIDC → C(Executor)→ E(CLI 双模式)→ F(deploy / billing)。原 Phase G Web UI 路线撤(DESIGN §7.9),UI 改 platform 端实现;`web/static/dev.html` 是开发期单文件 SPA,跟 platform UI 并存不冲突。

13
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-18(`POST /v1/tasks/{id}/messages` 同 task 单活 run:已有 running → 409;启动 lifespan 把孤儿 running 标 error) 最后更新:2026-05-18(`POST /v1/tasks/{id}/runs/{rid}/cancel` 协作式 cancel + `cancelled` SSE 事件 + dev SPA stop 按钮;gate 扩到 `cancelling`)
--- ---
@ -144,15 +144,16 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE),FS working_dir 保留 | 必填 | | `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE),FS working_dir 保留 | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used(供创建 task 自动补全用) | 必填 | | `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used(供创建 task 自动补全用) | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 | | `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{run_id, events_url}`;**同 task 已有 running run → 409**(单活 run 保护,UI 应 disable send 按钮直到 SSE `done`) | 必填 | | `POST /v1/tasks/{id}/messages` | `{content}` 发消息;返 `{run_id, events_url}`;**同 task 已有 running / cancelling run → 409**(单活 run 保护,UI 应 disable send 按钮直到 SSE `done`) | 必填 |
| `GET /v1/tasks/{id}/runs/{rid}/events` | SSE 流(`event: <type>` + `data: <json>`) | 必填 | | `GET /v1/tasks/{id}/runs/{rid}/events` | SSE 流(`event: <type>` + `data: <json>`) | 必填 |
| `POST /v1/tasks/{id}/runs/{rid}/cancel` | 协作式 cancel 当前 run;返 `{ok, run_id, status:"cancelling"}`;run.status != `running` → 409;LLM 同步 call 本身不可中断,最坏等当前一轮跑完 | 必填 |
| `GET /v1/tasks/{id}/files?path=` | 列子目录条目 + 面包屑 | 必填 | | `GET /v1/tasks/{id}/files?path=` | 列子目录条目 + 面包屑 | 必填 |
| `GET /v1/tasks/{id}/files/download?path=` | 下单文件 | 必填 | | `GET /v1/tasks/{id}/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/tasks/{id}/files/upload` | multipart 上传,`path` 走 form | 必填 | | `POST /v1/tasks/{id}/files/upload` | multipart 上传,`path` 走 form | 必填 |
| `POST /v1/tasks/{id}/files/delete` | body `{path}`;文件或空目录 | 必填 | | `POST /v1/tasks/{id}/files/delete` | body `{path}`;文件或空目录 | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 | | `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
**SSE 事件 schema**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{content}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;异常路径走 `error{msg}`。30s 无 event 服务端发 `: ping` 注释心跳。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。 **SSE 事件 schema**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{content}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 命中走 `cancelled{}` 后随 `done{}` 收流;异常路径走 `error{msg}`。30s 无 event 服务端发 `: ping` 注释心跳。SSE 经 nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
**SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么走 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query 路径(目前不支持,避免 token 进 access log)。 **SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么走 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query 路径(目前不支持,避免 token 进 access log)。
@ -175,8 +176,10 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `cli.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` | | `cli.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务在 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或加 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` → `{"status":"ok"}` |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` | | SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`,nginx ≥ 1.5.6 默认认。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)| | platform 端 CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头(`access-control-allow-origin` 响应头要含 platform 域名 或 `*`)|
| `POST /v1/tasks/{id}/messages` 返 409 `task already has a running run` | 上一条消息的 BG run 还没跑完(SSE 没 `done`)。等流式跑完;或服务异常下 Run 行卡 `running`,启动 reaper 会清(crash 重启 / `cli.py web` 重启)。后续 `POST /v1/tasks/{id}/runs/{rid}/cancel`(DESIGN §7.2 待办)做出来后可主动 cancel | | `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完(SSE 没 `done`)。等流式跑完;或点 dev SPA 的 stop / `POST /runs/{rid}/cancel`;服务异常下 Run 行卡 `running`/`cancelling`,启动 reaper 会清 |
| `[startup] reaped N stale running run(s)` | 上次 `cli.py web` 进程未正常 finish 留下 N 个 running Run 行,启动 lifespan 自动标 error。无需处理,info 级 | | `POST /v1/tasks/{id}/runs/{rid}/cancel` 返 409 `run not running` | run 已结束(ok/error/cancelled)或已被 cancel 进入 `cancelling`,不能重复 cancel;dev SPA 自动忽略不报错 |
| 点 stop 后流式没立刻停 | LLM 同步调用本身不可中断,最坏等当前一轮跑完(通常几十秒)。loop 进入下个 check 点(每轮 LLM 前 / 每个 tool_call 前)就退,emit `cancelled` → SSE `done` → UI 收回 stop 按钮 |
| `[startup] reaped N stale active run(s)` | 上次 `cli.py web` 进程未正常 finish 留下 N 个 `running` / `cancelling` Run 行,启动 lifespan 自动标 error。无需处理,info 级 |
| `cli.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 | | `cli.py web` 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写进 `.env` 重起 |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` | | `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 `POST /v1/auth/login` 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env | | `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |

View File

@ -7,13 +7,16 @@ from __future__ import annotations
import json import json
import time import time
from typing import Any, Dict, Optional, Tuple from typing import Any, Callable, Dict, Optional, Tuple
from .capabilities import ModelCapabilities from .capabilities import ModelCapabilities
from .llm import LLM from .llm import LLM
from .session import Session from .session import Session
_CANCELLED_TOOL_PLACEHOLDER = "[cancelled by user]"
def _extract_usage(usage: Any) -> Tuple[int, int]: def _extract_usage(usage: Any) -> Tuple[int, int]:
"""从 litellm response.usage 提 (prompt_tokens, completion_tokens)。""" """从 litellm response.usage 提 (prompt_tokens, completion_tokens)。"""
if not usage: if not usage:
@ -36,6 +39,7 @@ class AgentLoop:
capabilities: ModelCapabilities, capabilities: ModelCapabilities,
sink: Optional[Any] = None, sink: Optional[Any] = None,
max_iterations: Optional[int] = None, max_iterations: Optional[int] = None,
cancel_check: Optional[Callable[[], bool]] = None,
) -> None: ) -> None:
self.llm = llm self.llm = llm
self.tools = tools self.tools = tools
@ -43,15 +47,37 @@ class AgentLoop:
self.caps = capabilities self.caps = capabilities
self.max_iterations = max_iterations or capabilities.max_iterations self.max_iterations = max_iterations or capabilities.max_iterations
self.sink = sink self.sink = sink
# 协作式 cancel:web 层注入 `lambda: broker.is_cancelled(run_id)`;
# CLI 路径不设(None → 永不 cancel)。LLM 调用本身是 litellm 同步阻塞、不可中断,
# check 点放在每轮 LLM 前、tool_calls 之间;一次 LLM call 最坏卡几十秒。
self.cancel_check = cancel_check
def _emit(self, event: dict) -> None: def _emit(self, event: dict) -> None:
if self.sink is not None: if self.sink is not None:
self.sink.emit(event) self.sink.emit(event)
def _is_cancelled(self) -> bool:
return bool(self.cancel_check and self.cancel_check())
def _fill_cancelled_tool_results(self, remaining: list) -> None:
"""给未执行的 tool_call 补 cancelled tool result,保 LiteLLM 协议完整。
每个 assistant tool_call 必须有对应的 tool message,否则 resume LLM 报错
"""
for tc in remaining:
self.session.append({
"role": "tool",
"tool_call_id": tc.id,
"content": _CANCELLED_TOOL_PLACEHOLDER,
})
def run(self, user_message: str) -> str: def run(self, user_message: str) -> str:
self.session.append({"role": "user", "content": user_message}) self.session.append({"role": "user", "content": user_message})
for _ in range(self.max_iterations): for _ in range(self.max_iterations):
if self._is_cancelled():
self._emit({"type": "cancelled"})
return "[cancelled]"
self._emit({"type": "llm_start"}) self._emit({"type": "llm_start"})
start = time.monotonic() start = time.monotonic()
response = self.llm.chat( response = self.llm.chat(
@ -80,7 +106,11 @@ class AgentLoop:
self._emit({"type": "done"}) self._emit({"type": "done"})
return content or "" return content or ""
for tc in tool_calls: for i, tc in enumerate(tool_calls):
if self._is_cancelled():
self._fill_cancelled_tool_results(tool_calls[i:])
self._emit({"type": "cancelled"})
return "[cancelled]"
result = self._execute_tool_call(tc) result = self._execute_tool_call(tc)
self.session.append( self.session.append(
{ {

View File

@ -183,10 +183,11 @@ def _enumerate_files(root: Path, current: Path) -> tuple[list[dict], list[dict],
# ─────────────────── Run 启动 + SSE 帧格式 ─────────────────── # ─────────────────── Run 启动 + SSE 帧格式 ───────────────────
def _run_agent_bg(task_id: UUID, run_id: UUID, user_id: UUID, user_message: str) -> None: def _run_agent_bg(task_id: UUID, run_id: UUID, user_id: UUID, user_message: str) -> None:
"""工作线程:`build_agent(resume=True)` → 装 WebEventSink → `agent.run` → 写 runs 状态。 """工作线程:`build_agent(resume=True)` → 装 WebEventSink + cancel_check → `agent.run` → 写 runs 状态。
sink 通过 broker.emit 桥事件回 asyncio loop;agent.run sync,所以在 to_thread sink 通过 broker.emit 桥事件回 asyncio loop;agent.run sync,所以在 to_thread
user_id 必须从 JWT 那侧透传过来 决定 memory_block 读哪个 per-user 子树 user_id 必须从 JWT 那侧透传过来 决定 memory_block 读哪个 per-user 子树
cancel_check broker.is_cancelled,loop 在工具调用之间 poll(LLM 同步调用本身不可中断)
""" """
from main import build_agent, sync_task_tokens from main import build_agent, sync_task_tokens
try: try:
@ -195,12 +196,15 @@ def _run_agent_bg(task_id: UUID, run_id: UUID, user_id: UUID, user_message: str)
session_id=str(task_id), resume=True, user_id=user_id, session_id=str(task_id), resume=True, user_id=user_id,
) )
agent.sink = WebEventSink(broker, run_id) agent.sink = WebEventSink(broker, run_id)
agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid)
agent.run(user_message) agent.run(user_message)
sync_task_tokens(task_state, agent.llm) sync_task_tokens(task_state, agent.llm)
# cancel 命中时 agent.run 提前 return + 已 emit `cancelled`;终态写 "cancelled"
final_status = "cancelled" if broker.is_cancelled(run_id) else "ok"
with session_scope() as s: with session_scope() as s:
s.execute( s.execute(
update(Run).where(Run.run_id == run_id).values( update(Run).where(Run.run_id == run_id).values(
status="ok", status=final_status,
finished_at=func.now(), finished_at=func.now(),
tokens_p=agent.llm.token_counter.prompt_tokens, tokens_p=agent.llm.token_counter.prompt_tokens,
tokens_c=agent.llm.token_counter.completion_tokens, tokens_c=agent.llm.token_counter.completion_tokens,
@ -219,6 +223,7 @@ def _run_agent_bg(task_id: UUID, run_id: UUID, user_id: UUID, user_message: str)
except Exception: except Exception:
pass # 已 emit error 给前端,DB 写失败不放大噪声 pass # 已 emit error 给前端,DB 写失败不放大噪声
finally: finally:
broker.clear_cancel(run_id)
broker.close(run_id) broker.close(run_id)
@ -271,13 +276,13 @@ def create_app() -> FastAPI:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
broker.bind_loop(asyncio.get_running_loop()) broker.bind_loop(asyncio.get_running_loop())
# Stale-run reaper:上次进程 crash 留下的 "running" 行已无 BG 线程继续, # Stale-run reaper:上次进程 crash 留下的 "running" / "cancelling" 行已无 BG 线程
# 启动时标 error,让对应 task 重新可发消息(否则 409 gate 永挂)。 # 继续,启动时标 error,让对应 task 重新可发消息(否则 409 gate 永挂)。
# TODO 真生产 multi-worker:换 heartbeat / lease,只 reap 自家 worker 的孤儿。 # TODO 真生产 multi-worker:换 heartbeat / lease,只 reap 自家 worker 的孤儿。
with session_scope() as s: with session_scope() as s:
result = s.execute( result = s.execute(
update(Run) update(Run)
.where(Run.status == "running") .where(Run.status.in_(("running", "cancelling")))
.values( .values(
status="error", status="error",
error="server restarted before run finished", error="server restarted before run finished",
@ -285,7 +290,7 @@ def create_app() -> FastAPI:
) )
) )
if result.rowcount: if result.rowcount:
print(f"[startup] reaped {result.rowcount} stale running run(s)") print(f"[startup] reaped {result.rowcount} stale active run(s)")
yield yield
app = FastAPI( app = FastAPI(
@ -652,13 +657,13 @@ def create_app() -> FastAPI:
raise HTTPException(404, f"task not found: {tid}") raise HTTPException(404, f"task not found: {tid}")
active = s.execute( active = s.execute(
select(Run.run_id) select(Run.run_id)
.where(Run.task_id == tid, Run.status == "running") .where(Run.task_id == tid, Run.status.in_(("running", "cancelling")))
.limit(1) .limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if active is not None: if active is not None:
raise HTTPException( raise HTTPException(
409, 409,
f"task already has a running run ({active}); wait for it to finish", f"task already has an active run ({active}); wait for it to finish or cancel",
) )
s.add(Run(run_id=run_id, task_id=tid, status="running", started_at=func.now())) s.add(Run(run_id=run_id, task_id=tid, status="running", started_at=func.now()))
# commit 后 lock 释放;BG 线程接管(sink 通过 broker 把 event 桥回 asyncio loop) # commit 后 lock 释放;BG 线程接管(sink 通过 broker 把 event 桥回 asyncio loop)
@ -668,6 +673,50 @@ def create_app() -> FastAPI:
"events_url": f"/v1/tasks/{tid}/runs/{run_id}/events", "events_url": f"/v1/tasks/{tid}/runs/{run_id}/events",
} }
# ───────────── Run cancel ─────────────
@app.post("/v1/tasks/{task_id}/runs/{run_id}/cancel", status_code=202, tags=["runs"])
def cancel_run(
task_id: str,
run_id: str,
user_id: UUID = Depends(require_user),
):
"""向当前 run 发协作式 cancel 信号。
- 校验 task 归属 user + run 归属 task;否则 404
- run.status 不是 `running` 409(已结束 / cancelling 不能重复 cancel)
- `cancelling`(过渡态),BG 线程 loop 在工具调用之间 poll 看见即退;
退出后 finally 写终态 `cancelled`(或异常路径 `error`)
- LLM 同步调用本身不可中断,最坏要等当前 LLM call 跑完(通常几十秒内)
"""
try:
tid = UUID(task_id)
rid = UUID(run_id)
except ValueError:
raise HTTPException(404, "invalid id")
with session_scope() as s:
run = s.execute(
select(Run)
.join(Task, Task.task_id == Run.task_id)
.where(
Run.run_id == rid,
Run.task_id == tid,
Task.user_id == user_id,
)
.with_for_update()
).scalar_one_or_none()
if run is None:
raise HTTPException(404, f"run not found: {rid}")
if run.status != "running":
raise HTTPException(
409,
f"run not running (status={run.status}); cannot cancel",
)
s.execute(
update(Run).where(Run.run_id == rid).values(status="cancelling")
)
broker.request_cancel(rid)
return {"ok": True, "run_id": str(rid), "status": "cancelling"}
# ───────────── SSE events ───────────── # ───────────── SSE events ─────────────
@app.get("/v1/tasks/{task_id}/runs/{run_id}/events", tags=["runs"]) @app.get("/v1/tasks/{task_id}/runs/{run_id}/events", tags=["runs"])

View File

@ -20,6 +20,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import threading
from collections import defaultdict from collections import defaultdict
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
@ -30,6 +31,10 @@ class RunBroker:
self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set) self._subs: dict[UUID, set[asyncio.Queue]] = defaultdict(set)
# 已经发完 done 的 run — 后来订阅者直接收到 done,避免无限等 # 已经发完 done 的 run — 后来订阅者直接收到 done,避免无限等
self._done: set[UUID] = set() self._done: set[UUID] = set()
# cancel signal per-run。AgentLoop 在 BG 线程里 poll is_cancelled() 决定是否退;
# request_cancel 可在 BG 还没 register 时调用(setdefault),BG 启动后第一次
# check 即看到。run 完成在 finally 里 clear_cancel 回收。
self._cancel_flags: dict[UUID, threading.Event] = {}
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
@ -83,6 +88,22 @@ class RunBroker:
def is_done(self, run_id: UUID) -> bool: def is_done(self, run_id: UUID) -> bool:
return run_id in self._done return run_id in self._done
# ─────────────── cancel signaling ───────────────
def request_cancel(self, run_id: UUID) -> None:
"""主线程(HTTP handler)发的 cancel 信号 — BG 线程 poll is_cancelled() 看见即退。
setdefault:即便 BG 还没注册 flag 也能 set,BG 启动后第一次 check 立刻看见
"""
self._cancel_flags.setdefault(run_id, threading.Event()).set()
def is_cancelled(self, run_id: UUID) -> bool:
ev = self._cancel_flags.get(run_id)
return bool(ev and ev.is_set())
def clear_cancel(self, run_id: UUID) -> None:
"""run 真正退出(BG finally)清掉 flag,避免 dict 无限增长。"""
self._cancel_flags.pop(run_id, None)
# 进程内单例 — FastAPI lifespan 里 bind_loop;agent / sink / SSE handler 共享。 # 进程内单例 — FastAPI lifespan 里 bind_loop;agent / sink / SSE handler 共享。
broker = RunBroker() broker = RunBroker()

View File

@ -126,6 +126,7 @@
.msg.user { background: var(--user-bg); align-self: flex-end; } .msg.user { background: var(--user-bg); align-self: flex-end; }
.msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; } .msg.assistant, .msg.system, .msg.tool, .msg.error { background: var(--asst-bg); align-self: flex-start; }
.msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); } .msg.error { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.cancelled-badge { margin-top: 8px; padding: 4px 10px; font-size: 12px; color: var(--accent); background: var(--accent-soft); border: 1px dashed var(--accent); border-radius: 4px; display: inline-block; }
.msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: monospace; } .msg .role { font-size: 11px; color: var(--muted); margin-bottom: 2px; font-family: monospace; }
.msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; } .msg .body { word-wrap: break-word; font-size: 14px; line-height: 1.55; }
.msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; } .msg .body.streaming::after { content: "▌"; color: var(--accent); animation: blink 1s infinite; }
@ -311,6 +312,7 @@
<div class="row"> <div class="row">
<span class="hint" id="chat-hint">ready</span> <span class="hint" id="chat-hint">ready</span>
<span style="flex:1;"></span> <span style="flex:1;"></span>
<button type="button" class="small danger" id="chat-cancel" style="display:none;" title="停止当前流式回复(协作式 cancel,最长等 LLM 当前一轮跑完)">stop</button>
<button type="submit" class="primary" id="chat-send">send</button> <button type="submit" class="primary" id="chat-send">send</button>
</div> </div>
</form> </form>
@ -365,6 +367,7 @@ const state = {
taskMeta: null, taskMeta: null,
filesPath: "", filesPath: "",
evtSrc: null, evtSrc: null,
currentRunId: null, // 当前流式中的 run_id;用于 stop 按钮发 cancel
// task list 分页 + 筛选 // task list 分页 + 筛选
taskPage: 1, taskPage: 1,
taskPageSize: 20, taskPageSize: 20,
@ -726,6 +729,8 @@ async function sendMessage() {
const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content }); const r = await api("POST", `/v1/tasks/${state.taskId}/messages`, { content });
$("chat-input").value = ""; $("chat-input").value = "";
state.currentRunId = r.run_id;
$("chat-cancel").style.display = "";
streamSse(r.events_url, asstCard); streamSse(r.events_url, asstCard);
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
@ -735,6 +740,25 @@ async function sendMessage() {
} }
} }
async function cancelCurrentRun() {
if (!state.taskId || !state.currentRunId) return;
const btn = $("chat-cancel");
btn.disabled = true;
$("chat-hint").textContent = "cancelling…";
try {
await api("POST", `/v1/tasks/${state.taskId}/runs/${state.currentRunId}/cancel`);
// 不重置 state.currentRunId / 按钮 — 等 SSE 的 cancelled / done 走完一并清
} catch (e) {
if (e.status === 401) { logout(); return; }
// 409 = 已结束 / 已 cancelling,不算错;其他贴 toast
if (e.status !== 409) appendErrorCard("cancel: " + e.message);
btn.disabled = false;
$("chat-hint").textContent = "ready";
}
}
$("chat-cancel").addEventListener("click", cancelCurrentRun);
function streamSse(url, asstCard) { function streamSse(url, asstCard) {
// EventSource 不支持自定义 header,token 走 query string(?token=...) // EventSource 不支持自定义 header,token 走 query string(?token=...)
// 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头 // 这里 SSE 走 same-origin,token 经 URL 传给后端 — 但当前后端只读 Authorization 头
@ -744,6 +768,8 @@ function streamSse(url, asstCard) {
async function fetchSse(url, asstCard) { async function fetchSse(url, asstCard) {
const body = asstCard.querySelector(".body"); const body = asstCard.querySelector(".body");
const ctx = { acc: "", body, pending: false };
try {
const r = await fetch(url, { const r = await fetch(url, {
headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" }, headers: { "Authorization": "Bearer " + state.token, "Accept": "text/event-stream" },
}); });
@ -751,10 +777,7 @@ async function fetchSse(url, asstCard) {
const reader = r.body.getReader(); const reader = r.body.getReader();
const dec = new TextDecoder(); const dec = new TextDecoder();
let buf = ""; let buf = "";
// 流式 markdown:累积 raw 文本 → rAF 节流重渲染整段 body
const ctx = { acc: "", body, pending: false };
$("chat-hint").textContent = "streaming…"; $("chat-hint").textContent = "streaming…";
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
@ -772,11 +795,17 @@ async function fetchSse(url, asstCard) {
} }
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU) // 最终定稿 + 代码高亮(流式中不 highlight,省 CPU)
body.innerHTML = renderMd(ctx.acc); body.innerHTML = renderMd(ctx.acc);
body.classList.remove("streaming");
highlightIn(asstCard); highlightIn(asstCard);
} finally {
body.classList.remove("streaming");
$("chat-send").disabled = false; $("chat-send").disabled = false;
$("chat-hint").textContent = "ready"; $("chat-hint").textContent = "ready";
// 刷新 task meta + messages(拿真实持久化的) state.currentRunId = null;
const cb = $("chat-cancel");
cb.style.display = "none";
cb.disabled = false;
}
// 刷新 task meta + messages(拿真实持久化的);失败路径已退出,这里不再跑
loadTaskList(); loadTaskList();
await loadMessages(); await loadMessages();
} }
@ -825,6 +854,11 @@ function handleSseEvent(ev, asstCard, ctx) {
det.className = "tool-call"; det.className = "tool-call";
det.innerHTML = `<summary>tool_result</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`; det.innerHTML = `<summary>tool_result</summary><pre>${escapeHtml(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2))}</pre>`;
asstCard.appendChild(det); asstCard.appendChild(det);
} else if (t === "cancelled") {
const badge = document.createElement("div");
badge.className = "cancelled-badge";
badge.textContent = "已停止(stopped by user)";
asstCard.appendChild(badge);
} else if (t === "error") { } else if (t === "error") {
const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data); const msg = (ev.data && (ev.data.msg || ev.data.error)) || JSON.stringify(ev.data);
appendErrorCard(msg); appendErrorCard(msg);