fix(web): make task progress run-scoped and recoverable

This commit is contained in:
caoqianming 2026-08-17 11:07:57 +08:00
parent 79d48b98c9
commit 30c12f9808
14 changed files with 260 additions and 61 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 对话步骤进度改为按每轮任务保存完整计划;长任务、刷新或网络重连后可恢复当前步骤,不再因历史分页或首个实时事件错过而出现进度消失、串到上一轮或无法完成。
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。 - 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y` - 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`

View File

@ -199,7 +199,7 @@ Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/
Export GET /v1/tasks/{id}/export(docx) Export GET /v1/tasks/{id}/export(docx)
``` ```
**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。 **SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ progress_snapshot{run_id,steps}(当前 user message 即 run 边界,从 messages 投影恢复)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / warn{msg}(熔断·重复拦截·折叠失败等运行时提醒)/ context_fold{phase,...}(§8.8 Phase 2 折叠 start/done)/ cancelled / error / done`。`task_progress` 每次提交完整步骤快照,前端整体替换;旧 `set_plan/update_step` 仅在历史投影时兼容。fan-out:每订阅独立 queue;迟到订阅先从 PG 恢复当前 run 最新进度,终态迟到订阅立收 done。普通直播事件不持久化(messages 走 PG)。
**版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。 **版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。
### 7.3 认证 ### 7.3 认证
@ -234,7 +234,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
- working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。 - working_dir 存相对 ROOT posix 串,读写统一过 `core/paths.py`;入口 `validate_task_name` 拒空/`/\NUL`/`.` 起头。
- `auto_title_pending`(0023)只是一轮 UI 命名闸,不是 task 状态机;旧创建入口/存量行恒 false快速入口首发后消费人工改名优先清闸。 - `auto_title_pending`(0023)只是一轮 UI 命名闸,不是 task 状态机;旧创建入口/存量行恒 false快速入口首发后消费人工改名优先清闸。
- **0004 简化**:runs 表只写不读、run_id 单活 run 下全冗余 → 合并 `run_status/run_error` 入 tasks。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。 - **0004 简化**:runs 表只写不读、独立 run 实体在单活形态下冗余 → 合并 `run_status/run_error` 入 tasks;需要前端关联本轮时直接复用该轮 user message UUID 为 `run_id`,不恢复 runs 表。**0006**:`tasks.model_profile` 为 source-of-truth(PATCH 切、下条 send 生效);usage_events 重建 v2 多态形态,统计 source-of-truth;tasks 三列保留作粗概览。run_status 终态:ok 收回 idle,error(出错)与 cancelled(用户停止)是持久终态 —— 前端 `renderPersistedRunTerminal` 据此在每次重渲后补持久卡(扛过收尾 loadMessages 整屏重建),刷新/切任务仍在;下次起新 run(post_message 写 running)覆盖清掉。
- **0029 消息序号**:`tasks.next_message_idx` 在 task 行锁下统一分配 `messages.idx`Web、agent 与渠道追加不再各自维护序号或依赖冲突重试;分配时仍与 `max(idx)` 校准,允许蓝绿发布窗口内旧实例继续写入。清空消息与计数器在同一事务归零。 - **0029 消息序号**:`tasks.next_message_idx` 在 task 行锁下统一分配 `messages.idx`Web、agent 与渠道追加不再各自维护序号或依赖冲突重试;分配时仍与 `max(idx)` 校准,允许蓝绿发布窗口内旧实例继续写入。清空消息与计数器在同一事务归零。
- **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。 - **No-subtask**:同 user 下前缀互含即拒(归一 posix 后 Python 端比对);同 working_dir 允许。
- **文件面板先备料**:user_root 与普通目录都可显式新建直接子目录;创建成功后前端进入该目录,用户可先上传/选入资料,再把顶层空目录选作新对话 working_dir。目录 leaf 复用 `validate_task_name`,不允许借 UI 创建点目录或路径式名称。 - **文件面板先备料**:user_root 与普通目录都可显式新建直接子目录;创建成功后前端进入该目录,用户可先上传/选入资料,再把顶层空目录选作新对话 working_dir。目录 leaf 复用 `validate_task_name`,不允许借 UI 创建点目录或路径式名称。

4
RUN.md
View File

@ -363,7 +363,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
| `GET/POST /v1/admin/external-system-definitions` | 管理员列出或新增可信外部系统目录 | admin | | `GET/POST /v1/admin/external-system-definitions` | 管理员列出或新增可信外部系统目录 | admin |
| `PUT/DELETE /v1/admin/external-system-definitions/{id}` | 管理员编辑、停用或删除目录项;已有用户连接时拒绝删除 | admin | | `PUT/DELETE /v1/admin/external-system-definitions/{id}` | 管理员编辑、停用或删除目录项;已有用户连接时拒绝删除 | admin |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传;另带 `artifact_refs`(助手产物)与 `attachment_refs`(用户附件)。两者均以 `null` 表示旧消息、`[]` 表示新消息明确为空、非空数组表示 task-relative 结构化引用 | 必填 | | `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传;另带 `artifact_refs`(助手产物)与 `attachment_refs`(用户附件)。两者均以 `null` 表示旧消息、`[]` 表示新消息明确为空、非空数组表示 task-relative 结构化引用 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content, attachments?:[{path,kind,label?}], image_model?=""}` 发消息;`attachments` 路径由后端按当前 task working_dir 校验并规范化,允许纯附件消息;旧客户端省略该字段继续兼容。返 `{events_url}`**`run_status` 是 running/cancelling → 409**UI 应 disable send 直到 SSE `done` | 必填 | | `POST /v1/tasks/{id}/messages` | `{content, attachments?:[{path,kind,label?}], image_model?=""}` 发消息;`attachments` 路径由后端按当前 task working_dir 校验并规范化,允许纯附件消息;旧客户端省略该字段继续兼容。返 `{events_url,run_id}`,其中 `run_id` 是本轮 user message UUID**`run_status` 是 running/cancelling → 409**UI 应 disable send 直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 | | `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 | | `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 |
| `GET /v1/procs` | 当前用户全部后台进程(bg proc,§8.12;shell/run_python `background=true` 启动);纯文件系统读取,前端运行条 5s 轮询用 | 必填 | | `GET /v1/procs` | 当前用户全部后台进程(bg proc,§8.12;shell/run_python `background=true` 启动);纯文件系统读取,前端运行条 5s 轮询用 | 必填 |
@ -384,7 +384,7 @@ $env:ZCBOT_EVAL_TOKEN = "<dedicated-eval-user-jwt>"
| `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 | | `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 |
| `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 | | `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 |
**SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{delta}` / `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` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。 **SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):建连时若当前 run 已发布计划,先补 `progress_snapshot{run_id,steps}``run_start{}``llm_start{}``text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。`task_progress` 新协议每次携带完整 `steps`,客户端整体替换;消息分页响应也附加 `progress_snapshot`,刷新不依赖当前 30 条窗口。30s 无 event 服务端发 `: ping` 心跳。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)。

View File

@ -51,6 +51,23 @@ test("tool calls can apply progress updates on top of previous task progress", (
]); ]);
}); });
test("a full snapshot replaces the prior run plan instead of merging it", () => {
const previous = [
{ id: "old", title: "上一轮计划", status: "completed" },
];
const updated = applyProgressAction(previous, {
steps: [
{ id: "s1", title: "分析需求", status: "completed" },
{ id: "s2", title: "执行修改", status: "in_progress" },
],
});
assert.deepEqual(updated, [
{ id: "s1", title: "分析需求", status: "completed" },
{ id: "s2", title: "执行修改", status: "in_progress" },
]);
});
test("a completed step force-completes earlier dangling steps (monotonic heal)", () => { test("a completed step force-completes earlier dangling steps (monotonic heal)", () => {
const steps = [ const steps = [
{ id: "s1", title: "摄取素材", status: "in_progress" }, { id: "s1", title: "摄取素材", status: "in_progress" },

View File

@ -43,6 +43,7 @@ class ClaimRunTests(unittest.TestCase):
self.assertEqual(claim.metadata, {"marker": "ok"}) self.assertEqual(claim.metadata, {"marker": "ok"})
added = session.add.call_args.args[0] added = session.add.call_args.args[0]
self.assertEqual(claim.run_id, added.message_id)
self.assertEqual(added.task_id, tid) self.assertEqual(added.task_id, tid)
self.assertEqual(added.idx, 7) self.assertEqual(added.idx, 7)
self.assertEqual( self.assertEqual(

View File

@ -0,0 +1,52 @@
from __future__ import annotations
import json
import unittest
from web.task_progress import project_progress_payloads
def _call(args: dict) -> dict:
return {
"role": "assistant",
"tool_calls": [{
"function": {
"name": "task_progress",
"arguments": json.dumps(args, ensure_ascii=False),
},
}],
}
class TaskProgressProjectionTests(unittest.TestCase):
def test_latest_full_snapshot_replaces_prior_snapshot(self) -> None:
steps, seen = project_progress_payloads([
_call({"steps": [
{"id": "old", "title": "旧计划", "status": "in_progress"},
]}),
_call({"steps": [
{"id": "s1", "title": "分析", "status": "completed"},
{"id": "s2", "title": "实现", "status": "in_progress"},
]}),
])
self.assertTrue(seen)
self.assertEqual([step["id"] for step in steps], ["s1", "s2"])
def test_legacy_updates_remain_replayable(self) -> None:
steps, seen = project_progress_payloads([
_call({"action": "set_plan", "steps": [
{"id": "s1", "title": "分析", "status": "in_progress"},
{"id": "s2", "title": "实现", "status": "pending"},
]}),
_call({"action": "update_step", "step": {
"id": "s2", "status": "completed",
}}),
])
self.assertTrue(seen)
self.assertEqual([step["status"] for step in steps], ["completed", "completed"])
if __name__ == "__main__":
unittest.main()

View File

@ -5,17 +5,18 @@ from tools.task_progress import TaskProgressTool
class TaskProgressToolTests(unittest.TestCase): class TaskProgressToolTests(unittest.TestCase):
def test_schema_exposes_set_update_and_clear_actions(self) -> None: def test_schema_requires_complete_steps_snapshot(self) -> None:
schema = TaskProgressTool().schema schema = TaskProgressTool().schema
fn = schema["function"] fn = schema["function"]
self.assertEqual(fn["name"], "task_progress") self.assertEqual(fn["name"], "task_progress")
action_enum = fn["parameters"]["properties"]["action"]["enum"] params = fn["parameters"]
self.assertEqual(action_enum, ["set_plan", "update_step", "clear"]) self.assertEqual(params["required"], ["steps"])
self.assertNotIn("action", params["properties"])
self.assertNotIn("step", params["properties"])
def test_execute_returns_short_ui_only_result(self) -> None: def test_execute_returns_short_ui_only_result(self) -> None:
out = TaskProgressTool().execute( out = TaskProgressTool().execute(
action="set_plan",
steps=[ steps=[
{"id": "s1", "title": "理解需求", "status": "completed"}, {"id": "s1", "title": "理解需求", "status": "completed"},
{"id": "s2", "title": "实现功能", "status": "in_progress"}, {"id": "s2", "title": "实现功能", "status": "in_progress"},
@ -23,19 +24,9 @@ class TaskProgressToolTests(unittest.TestCase):
) )
data = json.loads(out) data = json.loads(out)
self.assertEqual(data, {"ok": True, "action": "set_plan", "step_count": 2}) self.assertEqual(data, {"ok": True, "step_count": 2})
self.assertLess(len(out), 80) self.assertLess(len(out), 80)
def test_execute_normalizes_update_step_without_echoing_title(self) -> None:
out = TaskProgressTool().execute(
action="update_step",
step={"id": "s2", "title": "实现功能", "status": "completed"},
)
data = json.loads(out)
self.assertEqual(data, {"ok": True, "action": "update_step", "step_id": "s2"})
self.assertNotIn("实现功能", out)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -7,7 +7,7 @@ arguments for Web rendering and is compacted out of older LLM context.
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Any from typing import Any, ClassVar
from .base import Tool from .base import Tool
@ -15,24 +15,23 @@ from .base import Tool
class TaskProgressTool(Tool): class TaskProgressTool(Tool):
name = "task_progress" name = "task_progress"
description = ( description = (
"Publish or update a concise user-visible progress checklist for the current task. " "Publish the complete current user-visible progress checklist for this run. "
"Use only for meaningful multi-step work: set the plan once, update a step when it " "Use only for meaningful multi-step work. Every call must include the full checklist, "
"starts or completes, and when all work is done mark the final step completed (do NOT " "including unchanged steps; never send a partial step patch. Keep stable step ids while "
"clear). Use clear only when the plan is no longer relevant. This is a UI progress " "revising the plan, allow at most one in_progress step, and mark every step completed "
"signal, not a work product." "before a successful final answer. This is a UI progress signal, not a work product."
) )
parameters = { parameters: ClassVar[dict[str, Any]] = {
"type": "object", "type": "object",
"additionalProperties": False, "additionalProperties": False,
"properties": { "properties": {
"action": { "explanation": {
"type": "string", "type": "string",
"enum": ["set_plan", "update_step", "clear"], "description": "Optional short reason when the plan materially changes.",
"description": "set_plan replaces the checklist; update_step changes one step; clear removes it.",
}, },
"steps": { "steps": {
"type": "array", "type": "array",
"description": "Required for set_plan. Keep to 3-7 user-meaningful steps.", "description": "The complete current checklist. Keep to 3-7 user-meaningful steps.",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": False, "additionalProperties": False,
@ -47,32 +46,14 @@ class TaskProgressTool(Tool):
"required": ["id", "title", "status"], "required": ["id", "title", "status"],
}, },
}, },
"step": {
"type": "object",
"description": "Required for update_step.",
"additionalProperties": False,
"properties": {
"id": {"type": "string"},
"title": {"type": "string"},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
}, },
}, "required": ["steps"],
"required": ["id", "status"],
},
},
"required": ["action"],
} }
def execute(self, **kwargs: Any) -> str: def execute(self, **kwargs: Any) -> str:
action = str(kwargs.get("action") or "")
out: dict[str, Any] = {"ok": True, "action": action}
if action == "set_plan":
steps = kwargs.get("steps") steps = kwargs.get("steps")
out["step_count"] = len(steps) if isinstance(steps, list) else 0 out: dict[str, Any] = {
elif action == "update_step": "ok": True,
step = kwargs.get("step") "step_count": len(steps) if isinstance(steps, list) else 0,
if isinstance(step, dict) and step.get("id"): }
out["step_id"] = str(step["id"])
return json.dumps(out, ensure_ascii=False, separators=(",", ":")) return json.dumps(out, ensure_ascii=False, separators=(",", ":"))

View File

@ -42,6 +42,7 @@ from ..run_lifecycle import (
schedule_claimed_run, schedule_claimed_run,
) )
from ..schemas import MessageRequest, OptimizePromptRequest from ..schemas import MessageRequest, OptimizePromptRequest
from ..task_progress import latest_run_progress
from ..userfiles import load_user_root from ..userfiles import load_user_root
@ -137,9 +138,11 @@ def register_message_routes(app, *, require_user) -> None:
.where(Message.task_id == tid, Message.idx > last_idx) .where(Message.task_id == tid, Message.idx > last_idx)
.limit(1) .limit(1)
).first() is not None ).first() is not None
progress_snapshot = latest_run_progress(s, tid)
return { return {
"has_more": has_more, "has_more": has_more,
"has_more_after": has_more_after, "has_more_after": has_more_after,
"progress_snapshot": progress_snapshot,
"messages": [ "messages": [
{ {
"idx": r.idx, "idx": r.idx,
@ -320,7 +323,10 @@ def register_message_routes(app, *, require_user) -> None:
)) ))
app.state.aux_tasks.add(title_task) app.state.aux_tasks.add(title_task)
title_task.add_done_callback(app.state.aux_tasks.discard) title_task.add_done_callback(app.state.aux_tasks.discard)
return {"events_url": f"/v1/tasks/{tid}/events"} return {
"events_url": f"/v1/tasks/{tid}/events",
"run_id": str(claim.run_id),
}
@app.post("/v1/tasks/{task_id}/cancel", status_code=202, tags=["tasks"]) @app.post("/v1/tasks/{task_id}/cancel", status_code=202, tags=["tasks"])
def cancel_task( def cancel_task(
@ -638,6 +644,12 @@ def register_message_routes(app, *, require_user) -> None:
return return
q = broker.subscribe(tid) q = broker.subscribe(tid)
try: try:
# Durable catch-up for events emitted before this subscriber existed.
# Subscribing first keeps updates emitted during the DB read queued.
with session_scope() as s:
snapshot = latest_run_progress(s, tid)
if snapshot is not None:
yield sse_event("progress_snapshot", snapshot)
while True: while True:
try: try:
ev = await asyncio.wait_for(q.get(), timeout=30.0) ev = await asyncio.wait_for(q.get(), timeout=30.0)

View File

@ -8,7 +8,7 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from uuid import UUID from uuid import UUID, uuid4
from sqlalchemy import select, update from sqlalchemy import select, update
@ -44,6 +44,7 @@ PrepareClaim = Callable[[Any, Task], tuple[dict[str, Any], dict[str, Any]]]
class RunClaim: class RunClaim:
"""抢占成功后返回给调用方的领域元数据。""" """抢占成功后返回给调用方的领域元数据。"""
run_id: UUID
metadata: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict)
@ -79,7 +80,9 @@ def claim_run_with_message(
# task 行锁串行化同一 task 的 idx 分配,无需额外序列表或 advisory lock。 # task 行锁串行化同一 task 的 idx 分配,无需额外序列表或 advisory lock。
next_idx = allocate_message_idx(s, task_id, locked_task=task) next_idx = allocate_message_idx(s, task_id, locked_task=task)
run_id = uuid4()
s.add(Message( s.add(Message(
message_id=run_id,
task_id=task_id, task_id=task_id,
idx=int(next_idx), idx=int(next_idx),
payload={"role": "user", "content": user_message}, payload={"role": "user", "content": user_message},
@ -92,7 +95,7 @@ def claim_run_with_message(
**extra_values, **extra_values,
} }
s.execute(update(Task).where(Task.task_id == task_id).values(**values)) s.execute(update(Task).where(Task.task_id == task_id).values(**values))
return RunClaim(metadata=dict(metadata)) return RunClaim(run_id=run_id, metadata=dict(metadata))
def schedule_claimed_run( def schedule_claimed_run(

View File

@ -1023,6 +1023,7 @@ function alignedEarlierLimit(firstIdx) {
async function loadMessages({ render = true } = {}) { async function loadMessages({ render = true } = {}) {
const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`); const data = await api("GET", `/v1/tasks/${state.taskId}/messages?limit=${MSG_PAGE}`);
state.loadedMessages = data.messages || []; state.loadedMessages = data.messages || [];
state.taskProgressSnapshot = data.progress_snapshot || null;
state.msgHasMore = !!data.has_more; state.msgHasMore = !!data.has_more;
state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false state.msgHasMoreNewer = !!data.has_more_after; // 尾部窗口通常为 false
state.msgLoadingEarlier = false; state.msgLoadingEarlier = false;
@ -1454,7 +1455,7 @@ function ensureRunningTaskSubscribed(taskId, url, seed = {}) {
curSeg: null, curSeg: null,
cancelling: seed.run_status === "cancelling", cancelling: seed.run_status === "cancelling",
workingDir: seed.working_dir || "", workingDir: seed.working_dir || "",
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), progressSteps: [],
}; };
state.liveRuns.set(taskId, run); state.liveRuns.set(taskId, run);
state.streaming = true; state.streaming = true;
@ -1798,7 +1799,9 @@ function renderMessages(msgs, { stickBottom = true } = {}) {
} }
if (stickBottom) wrap.scrollTop = wrap.scrollHeight; if (stickBottom) wrap.scrollTop = wrap.scrollHeight;
setTaskProgress(state.taskId, currentProgressSteps); const snapshotSteps = state.taskProgressSnapshot && Array.isArray(state.taskProgressSnapshot.steps)
? state.taskProgressSnapshot.steps : null;
setTaskProgress(state.taskId, snapshotSteps === null ? currentProgressSteps : snapshotSteps);
upgradeMediaArtifacts(wrap); upgradeMediaArtifacts(wrap);
renderPersistedRunTerminal(); // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这) renderPersistedRunTerminal(); // 上次 run error/cancelled 终态 → 末尾补持久卡(所有重渲路径统一走这)
renderLiveRunIfVisible(); renderLiveRunIfVisible();
@ -2741,8 +2744,10 @@ async function sendMessage(overrideText) {
cancelling: false, cancelling: false,
workingDir: state.taskMeta && state.taskMeta.working_dir, workingDir: state.taskMeta && state.taskMeta.working_dir,
autoTitleEligible: !attachmentOnly, autoTitleEligible: !attachmentOnly,
progressSteps: cloneProgressSteps(state.taskProgressByTask.get(taskId)), runId: r.run_id || "",
progressSteps: [],
}; };
setTaskProgress(taskId, []);
// 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」) // 预建的空占位 .body 即首个文字段(首字到达前显示「思考中」)
run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false }; run.curSeg = { el: asstCard.querySelector(".body"), acc: "", pending: false };
setRunPhase(run, "llm"); // POST 返回即起跳:覆盖 build_agent + 首轮 TTFT 的空窗 setRunPhase(run, "llm"); // POST 返回即起跳:覆盖 build_agent + 首轮 TTFT 的空窗
@ -3035,6 +3040,12 @@ function handleSseEvent(ev, asstCard, ctx) {
if (nearBottom) stream.scrollTop = stream.scrollHeight; if (nearBottom) stream.scrollTop = stream.scrollHeight;
}); });
} }
} else if (t === "progress_snapshot") {
const snapshot = ev.data || {};
if (ctx.runId && snapshot.run_id && ctx.runId !== snapshot.run_id) return;
if (!ctx.runId && snapshot.run_id) ctx.runId = snapshot.run_id;
ctx.progressSteps = cloneProgressSteps(snapshot.steps);
setTaskProgress(ctx.taskId, ctx.progressSteps);
} else if (t === "tool_call") { } else if (t === "tool_call") {
const fn = (ev.data && ev.data.name) || "?"; const fn = (ev.data && ev.data.name) || "?";
const args = (ev.data && ev.data.args) || ""; const args = (ev.data && ev.data.args) || "";

View File

@ -39,7 +39,9 @@ export function applyProgressAction(progress, args) {
if (!args || typeof args !== "object") return current; if (!args || typeof args !== "object") return current;
const action = args.action || ""; const action = args.action || "";
if (action === "clear") return []; if (action === "clear") return [];
if (action === "set_plan") { // Current protocol sends a complete snapshot on every call. Legacy set_plan
// has the same shape, so both formats intentionally converge here.
if (Array.isArray(args.steps)) {
const planned = Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : []; const planned = Array.isArray(args.steps) ? args.steps.map(normalizeProgressStep).filter(Boolean) : [];
return enforceMonotonicProgress(planned); return enforceMonotonicProgress(planned);
} }

View File

@ -55,6 +55,7 @@ export const state = {
streaming: false, // 兼容旧判断:任一 task 是否在流式中 streaming: false, // 兼容旧判断:任一 task 是否在流式中
liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本 liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本
taskProgressByTask: new Map(), // task_id -> 历史消息重放后的当前进度步骤 taskProgressByTask: new Map(), // task_id -> 历史消息重放后的当前进度步骤
taskProgressSnapshot: null, // 当前 task 最近一轮的分页外完整进度快照
// 消息分页(尾部窗口 + 向上滚动加载更早):切 task 默认只拉最近一批, // 消息分页(尾部窗口 + 向上滚动加载更早):切 task 默认只拉最近一批,
// 顶部 sentinel 进视口自动往前补。loadedMessages 是当前已加载的升序窗口, // 顶部 sentinel 进视口自动往前补。loadedMessages 是当前已加载的升序窗口,
// renderMessages 对它做纯函数渲染(时序累积逻辑无需改)。 // renderMessages 对它做纯函数渲染(时序累积逻辑无需改)。

126
web/task_progress.py Normal file
View File

@ -0,0 +1,126 @@
"""Project the latest run-scoped progress snapshot from append-only messages."""
from __future__ import annotations
import json
from collections.abc import Iterable
from typing import Any
from uuid import UUID
from sqlalchemy import select
from core.storage.models import Message
_VALID_STATUSES = {"pending", "in_progress", "completed"}
def _normalize_step(value: Any) -> dict[str, str] | None:
if not isinstance(value, dict):
return None
step_id = str(value.get("id") or "").strip()
title = str(value.get("title") or "").strip()
status = str(value.get("status") or "pending")
if not step_id or not title:
return None
if status not in _VALID_STATUSES:
status = "pending"
return {"id": step_id, "title": title, "status": status}
def _heal_monotonic(steps: list[dict[str, str]]) -> list[dict[str, str]]:
last_completed = max(
(i for i, step in enumerate(steps) if step["status"] == "completed"),
default=-1,
)
return [
{**step, "status": "completed"} if i < last_completed else dict(step)
for i, step in enumerate(steps)
]
def apply_progress_args(
current: list[dict[str, str]], args: Any,
) -> list[dict[str, str]]:
"""Apply current full snapshots plus legacy set/update/clear calls."""
if not isinstance(args, dict):
return [dict(step) for step in current]
action = args.get("action") or ""
if action == "clear":
return []
if isinstance(args.get("steps"), list):
normalized: list[dict[str, str]] = []
for raw in args["steps"]:
step = _normalize_step(raw)
if step is not None:
normalized.append(step)
return _heal_monotonic(normalized)
if action != "update_step" or not isinstance(args.get("step"), dict):
return [dict(step) for step in current]
raw = args["step"]
step_id = str(raw.get("id") or "").strip()
if not step_id:
return [dict(step) for step in current]
next_steps: list[dict[str, str]] = []
found = False
for step in current:
if step["id"] != step_id:
next_steps.append(dict(step))
continue
found = True
status = str(raw.get("status") or step["status"])
next_steps.append({
"id": step_id,
"title": str(raw.get("title") or step["title"]).strip(),
"status": status if status in _VALID_STATUSES else "pending",
})
if not found:
normalized = _normalize_step(raw)
if normalized is not None:
next_steps.append(normalized)
return _heal_monotonic(next_steps)
def project_progress_payloads(
payloads: Iterable[dict[str, Any]],
) -> tuple[list[dict[str, str]], bool]:
steps: list[dict[str, str]] = []
seen = False
for payload in payloads:
if not isinstance(payload, dict) or payload.get("role") != "assistant":
continue
for call in payload.get("tool_calls") or []:
function = call.get("function") if isinstance(call, dict) else None
if not isinstance(function, dict) or function.get("name") != "task_progress":
continue
raw_args = function.get("arguments") or "{}"
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except (TypeError, json.JSONDecodeError):
args = {}
steps = apply_progress_args(steps, args)
seen = True
return steps, seen
def latest_run_progress(session, task_id: UUID) -> dict[str, Any] | None:
"""Return the latest user message id and that run's projected plan."""
run = session.execute(
select(Message.message_id, Message.idx)
.where(
Message.task_id == task_id,
Message.payload["role"].astext == "user",
)
.order_by(Message.idx.desc())
.limit(1)
).first()
if run is None:
return None
payloads = session.execute(
select(Message.payload)
.where(Message.task_id == task_id, Message.idx > run.idx)
.order_by(Message.idx)
).scalars().all()
steps, seen = project_progress_payloads(payloads)
if not seen:
return None
return {"run_id": str(run.message_id), "steps": steps}