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:
1 parent
6022f1dada
commit
799f7c3edb
6 files changed
+696
No files matched your search
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user