diff --git a/CLAUDE.md b/CLAUDE.md index aee697f..ebbfb3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **`daemon.py`** — 常驻轮询。`poll_once` 是核心状态机(见下);`exec_agent` 用 `subprocess.run` 起真 L3(`{prompt}` token 单参替换,超时返回 124)。`run()` 循环调 `poll_once`,`sleep`/`once` 可注入便于测试。 - **`cli.py`** — argparse 子命令(`run`/`setup`/`loop`/`wf`/`emit`/`status`/`web`/`ack`/`replay`/`history`)+ launchd plist 生成。 - **`mcp.py`** — FastMCP server,14 个工具让交互态 agent 管 Loop。**只管理不执行**,跑 Loop 仍靠 daemon。每工具用 `closing(_conn())` 独立连接(避免 FD 泄漏)。 -- **`web.py`** — 只读看板。`loopflow web` 起 stdlib `http.server`(只绑 127.0.0.1:8787),服务端拼 HTML,零依赖零构建。`render_page(conn, now=None, probe=None)` 是纯函数(测试直接调,不起 server);`health()` 判四态(ok/busy/stalled/down)。`Handler` 每请求用 `closing(core.connect(...))` 开关连接(`with sqlite3.connect(...)` 只提交不关连接,MCP 踩过同一个 FD 泄漏坑)。因果链渲染按趟 memo(`_chain_cached`)+ 长链截断(`_clip_chain`,头3+本条±2+尾3),否则一条 runaway loop 攒出的长链会让看板每次刷页发几十万条 SQL。**只读不写**——ack/replay/emit/增删 loop 仍走 CLI。 +- **`web.py`** — 只读看板。`loopflow web` 起 stdlib `http.server`(只绑 127.0.0.1:8787),服务端拼 HTML,零依赖零构建。`render_page(conn, now=None, probe=None)` 是纯函数(测试直接调,不起 server);`health()` 判五态(ok/busy/stalled/down/crashloop)。`Handler` 每请求用 `closing(core.connect(...))` 开关连接(`with sqlite3.connect(...)` 只提交不关连接,MCP 踩过同一个 FD 泄漏坑)。因果链渲染按趟 memo(`_chain_cached`)+ 长链截断(`_clip_chain`,头3+本条±2+尾3),否则一条 runaway loop 攒出的长链会让看板每次刷页发几十万条 SQL。**只读不写**——ack/replay/emit/增删 loop 仍走 CLI。 它们共享同一 DB:`LOOPFLOW_HOME`(默认 `~/.loopflow`)/`loopflow.db`。 @@ -38,6 +38,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co `loopflow setup` 在 macOS 生成 `~/Library/LaunchAgents/com.loopflow.daemon.plist`(用 `loopflow` **绝对路径** + `KeepAlive`/`RunAtLoad`)。ProgramArguments 必须绝对路径 —— launchd 用最小 PATH,相对名找不到会退 78。非 macOS 用 systemd / nohup 常驻 `loopflow run`。 +`agents.toml` 的命令模板也必须是绝对路径 —— 和 plist 的 ProgramArguments 同一个坑(launchd 最小 PATH)。`detect_agents()` 已经这么做了。 + ## 测试约定 `conftest.py` 提供 `db` fixture(tmp_path 建库)。测试注入 `run=` 假 agent、`sleep=`/`once=` 控制循环,避免真起 subprocess/常驻。自动化够不到的实盘验收见 `docs/acceptance-manual.md`(真 `claude -p` headless 契约、launchd 保活等,手动逐条勾)。 diff --git a/loopflow/core.py b/loopflow/core.py index b8e0a38..41bd36f 100644 --- a/loopflow/core.py +++ b/loopflow/core.py @@ -178,6 +178,17 @@ def set_running(conn, loop_id, now=None): (now or datetime.now()).isoformat(timespec="seconds")) +def note_boot(conn, now=None): + """记录本次进程启动时刻,并把上一次的挪到 prev_started_at。 + + 看板据此识别崩溃重启循环:心跳区分不了「健康轮询」和「每 2 秒崩一次 + 又被 launchd KeepAlive 拉起来」—— 新进程一启动就写心跳,心跳永远新鲜。 + """ + now = now or datetime.now() + set_meta(conn, "prev_started_at", get_meta(conn, "started_at")) + set_meta(conn, "started_at", now.isoformat(timespec="seconds")) + + def emit_event(conn, type, payload=None, dedup_key=None, causation_id=None): try: cur = conn.execute( @@ -362,8 +373,11 @@ def write_agents_config(path, agents): def detect_agents(): found = {} - if shutil.which("claude"): - found["claude"] = "claude -p --dangerously-skip-permissions {prompt}" - if shutil.which("codex"): - found["codex"] = "codex exec {prompt}" + # 绝对路径:launchd 用最小 PATH(/usr/bin:/bin:/usr/sbin:/sbin), + # 裸名 'claude' 在 ~/.local/bin 里找不到 → FileNotFoundError → daemon 崩。 + # plist 的 ProgramArguments 早就吃过这个亏,agent 命令模板同理。 + if p := shutil.which("claude"): + found["claude"] = f"{p} -p --dangerously-skip-permissions {{prompt}}" + if p := shutil.which("codex"): + found["codex"] = f"{p} exec {{prompt}}" return found diff --git a/loopflow/daemon.py b/loopflow/daemon.py index e204fcb..43b7079 100644 --- a/loopflow/daemon.py +++ b/loopflow/daemon.py @@ -15,6 +15,11 @@ def exec_agent(template, prompt, workdir, timeout): return p.returncode, p.stdout except subprocess.TimeoutExpired as e: return 124, (e.stdout or "") + except OSError as e: + # 起不动 agent(二进制找不到 / 没执行权限 / workdir 不存在)→ 127(shell 惯例)。 + # 必须在这里兜住:异常穿透会把 daemon 打死,launchd KeepAlive 无限重拉 → + # 崩溃循环,而事件的 attempts 从没 bump 过,连死信都进不去。 + return 127, f"exec 失败: {e}" def _match_loop(conn, ev): @@ -91,6 +96,7 @@ def run(conn, agents, poll_interval=None, sleep=time.sleep, once=False): core.set_running(conn, None) # 新进程 = 没有 agent 在跑;清掉上次崩溃遗留的标记 core.set_meta(conn, "poll_interval", str(poll_interval)) # 看板据此算陈旧阈值, # 否则各进程各读各的 env 会误报停摆 + core.note_boot(conn) while True: poll_once(conn, agents) if once: diff --git a/loopflow/web.py b/loopflow/web.py index 551c881..542ca2e 100644 --- a/loopflow/web.py +++ b/loopflow/web.py @@ -58,14 +58,30 @@ def health(conn, now=None, probe=None): pid, launchd = probe() interval = int(core.get_meta(conn, "poll_interval") or core.POLL_INTERVAL) + stale_after = interval * STALE_FACTOR fresh = bool(beat) and ( (now - datetime.fromisoformat(beat)).total_seconds() - < interval * STALE_FACTOR) + < stale_after) + + started = core.get_meta(conn, "started_at") + prev = core.get_meta(conn, "prev_started_at") + + uptime = ((now - datetime.fromisoformat(started)).total_seconds() + if started else None) + # ponytail: 启发式 —— 上个进程活不过一个轮询周期 = 它是崩的;当前进程也还年轻 = 循环还在继续。 + # 当前进程熬过 stale_after 后告警自动消失(那就不是循环了,是一次正常重启)。 + prev_life = ((datetime.fromisoformat(started) - datetime.fromisoformat(prev)).total_seconds() + if started and prev else None) + crashloop = (prev_life is not None and prev_life < stale_after + and uptime is not None and uptime < stale_after) # 顺序不可换:daemon 跑 agent 时崩掉会把 running 标记留在库里, # 只看 running 会误报 busy —— 所以「没进程且心跳陈旧」必须先判。 + # crashloop 必须排在 busy/ok 之前 —— 崩溃循环里心跳是新鲜的,不然会被 ok 吃掉。 if not fresh and not pid: state, label = "down", "未运行" + elif crashloop: + state, label = "crashloop", "崩溃重启中" elif running: state, label = "busy", f"正在执行 {running}" elif fresh: @@ -76,7 +92,8 @@ def health(conn, now=None, probe=None): return {"state": state, "label": label, "beat": beat, "beat_ago": _ago(beat, now), "pid": pid, "launchd": launchd, - "running": running, "running_ago": _ago(since, now)} + "running": running, "running_ago": _ago(since, now), + "uptime": _ago(started, now)} CSS = """ @@ -198,9 +215,11 @@ def _clip_chain(chain, focus_id): def _render_health(h): rows = [("心跳", h["beat_ago"] or "无记录", "t-dead" if h["state"] in - ("stalled", "down") else ""), - ("进程", f"PID {h['pid']}" if h["pid"] else "未找到", - "" if h["pid"] else "t-dead")] + ("stalled", "down") else "")] + if h["uptime"]: + rows.append(("启动", h["uptime"], "t-dead" if h["state"] == "crashloop" else "")) + rows.append(("进程", f"PID {h['pid']}" if h["pid"] else "未找到", + "" if h["pid"] else "t-dead")) if h["launchd"] is not None: rows.append(("launchd", "已加载" if h["launchd"] else "未加载", "" if h["launchd"] else "t-dead")) @@ -211,10 +230,15 @@ def _render_health(h): f'