feat: 任务实时链路 SSE (#61)

host.py:回合通知摘要成链路条目,增量追加到 <state>/host-traces/<task>.jsonl(上限 5000 行);dispatch/run/goal/delegate 设链路文件
cloud/server.py:GET /tasks/{id}/trace 快照 + /trace/stream SSE(snapshot → entry → done;worktree 在别机时空链路 + 说明)
console:详情链路区,运行中 fetch 流式渲染(EventSource 不能带 X-API-Key 头),终态走快照;DOM 上限 1000 条
单测:test_host_trace 7 + test_task_trace 6;host 49、cloud 525、deploy 14 全过

Co-authored-by: host <host@test.local>
This commit is contained in:
openqjandhost authored and GitHub committed 2026-10-27 02:27:15 +08:00
1 parent 6022f1dada
commit 799f7c3edb
6 files changed
+696

No files matched your search

+121
View File
@@ -118,6 +118,72 @@ def default_codex_bin():
return "codex"
# 实时链路:单条通知里 item 的上限行数(防失控回合把文件写爆)。
TRACE_MAX_LINES = 5000
def _summarize_trace_item(method, params):
"""app-server 通知 → 链路条目列表(实时链路用)。
只取过程事实,不取大文本:命令截 500 字、消息截 300 字、
思考摘要截 200 字;fileChange 一项一变化拆多条。
返回 [] 表示该通知不进链路(userMessage 等)。
ts 取通知的 completedAtMs/startedAtMs(毫秒 epoch),
没有时为 None,服务端展示时按无时间处理。
"""
item = (params or {}).get("item")
if not isinstance(item, dict):
return []
item_type = item.get("type")
item_id = item.get("id") or ""
ts = params.get("completedAtMs") or params.get("startedAtMs")
if item_type == "commandExecution":
return [{"kind": "exec", "id": item_id, "ts": ts,
"command": (item.get("command") or "")[:500],
"cwd": item.get("cwd") or "",
"exitCode": item.get("exitCode"),
"durationMs": item.get("durationMs")}]
if item_type == "fileChange":
entries = []
for ch in item.get("changes") or []:
if not isinstance(ch, dict):
continue
path = ch.get("path") or ""
entries.append({"kind": "file",
"id": item_id + ":" + path, "ts": ts,
"path": path, "change": ch.get("kind") or "",
"status": item.get("status") or ""})
return entries
if item_type == "agentMessage":
text = item.get("text") or ""
if not text:
return []
return [{"kind": "message", "id": item_id, "ts": ts,
"phase": item.get("phase") or "", "text": text[:300]}]
if item_type == "reasoning":
summary = item.get("summary") or item.get("content") or []
text = " ".join(s for s in summary if isinstance(s, str)).strip()
if not text:
return []
return [{"kind": "reasoning", "id": item_id, "ts": ts,
"summary": text[:200]}]
if item_type == "mcpToolCall":
return [{"kind": "tool", "id": item_id, "ts": ts,
"server": item.get("server") or "",
"tool": item.get("tool") or "",
"status": item.get("status") or ""}]
if item_type == "functionCallOutput":
return [{"kind": "call", "id": item_id, "ts": ts,
"name": item.get("name") or ""}]
if item_type == "plan":
text = item.get("text") or ""
if not text:
return []
return [{"kind": "plan", "id": item_id, "ts": ts,
"text": text[:300]}]
return []
class Server:
"""单个 app-server 进程,多 thread 并发。"""
@@ -133,8 +199,48 @@ class Server:
self.events = []
self.events_lock = threading.Lock()
self.restart_count = 0
# 实时链路文件:set_trace_file 未调时为 None,回合本身
# 不受影响(写链路失败也绝不打断 _read_loop)。
self.trace_path = None
self._trace_lines = 0
self._start()
def set_trace_file(self, state_path, task_name):
"""链路文件定到 <state_dir>/host-traces/<task_name>.jsonl。
state_dir 与 state 文件同目录:本地 worker 是
/opt/codex-app-server/work,容器内是 /app/work(经挂载
落到宿主同一目录),服务端按同一规则读。目录建不出
时 trace_path 保持 None,不影响回合。
"""
try:
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(task_name))
trace_dir = os.path.join(
os.path.dirname(os.path.abspath(state_path)),
"host-traces")
os.makedirs(trace_dir, exist_ok=True)
self.trace_path = os.path.join(trace_dir, safe + ".jsonl")
self._trace_lines = 0
except Exception:
self.trace_path = None
def _append_trace(self, msg):
"""通知 → 摘要条目 → 追加写链路文件(每事件开合一次文件,
进程被杀也不丢已写行;行数到 TRACE_MAX_LINES 即停)。"""
if self.trace_path is None or \
self._trace_lines >= TRACE_MAX_LINES:
return
entries = _summarize_trace_item(msg.get("method"),
msg.get("params") or {})
if not entries:
return
with open(self.trace_path, "a", encoding="utf-8") as f:
for entry in entries:
if self._trace_lines >= TRACE_MAX_LINES:
break
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
self._trace_lines += 1
def _start(self):
env = dict(os.environ)
if self.codex_home:
@@ -206,6 +312,11 @@ class Server:
thread_id = params["thread"].get("id")
with self.events_lock:
self.events.append(msg)
if self.trace_path is not None:
try:
self._append_trace(msg)
except Exception:
pass
if msg.get("method") == "turn/completed" and thread_id:
q = self.turn_queues.get(thread_id)
if q is not None:
@@ -1708,6 +1819,16 @@ def main():
return
srv = Server(codex_bin=args.codex_bin, codex_home=args.codex_home,
approval=args.approval)
# 实时链路:只给跑 turn 的命令设链路文件(dispatch/run/goal/
# delegate);只读命令不设。名字映射与 worker 派发一致:
# dispatch/goal/delegate 用 --name,run 用 --task。
_trace_name = None
if args.cmd in ("dispatch", "goal", "delegate"):
_trace_name = getattr(args, "name", None)
elif args.cmd == "run":
_trace_name = getattr(args, "task", None)
if _trace_name:
srv.set_trace_file(args.state, _trace_name)
try:
srv.initialize()
if args.cmd == "dispatch":