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'
{_e(k)}' f'{_e(v)}
' for k, v, cls in rows) - tone = {"ok": "ok", "busy": "busy", "stalled": "dead", "down": "dead"}[h["state"]] + tone = {"ok": "ok", "busy": "busy", "stalled": "dead", "down": "dead", + "crashloop": "dead"}[h["state"]] + hint = ('
' + '看 daemon.err.log —— agent 起不动 / 配置错了都会导致这个' + '($LOOPFLOW_HOME/daemon.err.log)
' + if h["state"] == "crashloop" else "") return (f'
DAEMON
' f'
' - f'{_e(h["label"])}
{kv}') + f'{_e(h["label"])}{kv}{hint}') def _render_counts(st): diff --git a/tests/test_cli.py b/tests/test_cli.py index 66ac661..8c0233d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,7 +68,8 @@ def test_setup_writes_agents_config(monkeypatch, tmp_path): cli.main(["setup"]) agents = core.load_agents(str(tmp_path / "agents.toml")) assert "claude" in agents and "codex" not in agents - assert agents["claude"].startswith("claude -p") + # 绝对路径,不是裸命令名 —— launchd 最小 PATH 找不到裸名(见 core.detect_agents) + assert agents["claude"].startswith("/bin/claude -p") def test_cli_run_wires_daemon(monkeypatch, tmp_path): diff --git a/tests/test_core.py b/tests/test_core.py index a3d9f35..6648760 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -362,3 +362,18 @@ def test_event_chain_only_selects_needed_columns(db): row = core.event_chain(db, a)[0] assert set(row.keys()) == {"id", "causation_id"} + + +def test_detect_agents_writes_absolute_paths(monkeypatch): + """launchd 用最小 PATH —— agents.toml 里必须是绝对路径,裸名会 FileNotFoundError。""" + monkeypatch.setattr(core.shutil, "which", + lambda name: f"/Users/x/.local/bin/{name}") + agents = core.detect_agents() + assert agents["claude"].startswith("/Users/x/.local/bin/claude ") + assert agents["codex"].startswith("/Users/x/.local/bin/codex ") + assert "{prompt}" in agents["claude"] # 占位符还在 + + +def test_detect_agents_skips_missing(monkeypatch): + monkeypatch.setattr(core.shutil, "which", lambda name: None) + assert core.detect_agents() == {} diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 2dd18fc..ae71c32 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -110,6 +110,37 @@ def test_run_once_calls_poll(db, monkeypatch): assert calls == [{"claude": "x"}] +def test_exec_agent_returns_127_when_binary_missing(): + """agent 二进制找不到 → 127,不能让异常穿透把 daemon 打死。""" + code, out = daemon.exec_agent("/nonexistent/bin/nope {prompt}", "hi", None, None) + assert code == 127 + assert "exec 失败" in out + + +def test_missing_agent_binary_goes_to_dead_letter_not_crash(db): + """起不动 agent 是一次失败的 run(重试→死信),不是 daemon 的死。 + + 真实事故:agents.toml 里写了裸名 'claude',launchd 最小 PATH 找不到 → + FileNotFoundError 穿透 → daemon 崩 → KeepAlive 重拉 → 2 分钟崩 20 次, + 事件永远 pending/attempts=0,死信兜底够不着。 + """ + core.register_loop(db, "l", "event:kick", "ghost", "p") + eid = core.emit_event(db, "kick") + agents = {"ghost": "/nonexistent/bin/nope {prompt}"} + + for _ in range(core.MAX_ATTEMPTS + 1): + daemon.poll_once(db, agents) # 不该抛异常 + + ev = [e for e in core.list_events(db) if e["id"] == eid][0] + assert ev["status"] == "dead" # 进了死信,不是永远 pending + assert ev["exit_code"] == 127 + + +def test_run_notes_boot_time(db): + daemon.run(db, {}, once=True, sleep=lambda s: None) + assert core.get_meta(db, "started_at") is not None + + def test_run_sleeps_between_polls(db, monkeypatch): n = {"i": 0} slept = [] diff --git a/tests/test_web.py b/tests/test_web.py index f7e3b66..95558b8 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -65,6 +65,47 @@ def test_health_uses_daemon_reported_poll_interval(db): assert h["state"] == "ok" # 60×3=180s 阈值内,仍然健康 +def test_health_detects_crash_restart_loop(db): + """daemon 每几秒崩一次被 KeepAlive 拉起时,心跳永远新鲜 —— 看板不能说「运行中」。 + + 真实事故:agents.toml 裸命令名 + launchd 最小 PATH → 2 分钟崩 20 次, + 看板全程显示「运行中」。看板存在的理由就是别让这种事发生。 + """ + now = datetime(2026, 7, 13, 10, 0, 0) + core.set_meta(db, "prev_started_at", (now - timedelta(seconds=6)).isoformat(timespec="seconds")) + core.set_meta(db, "started_at", (now - timedelta(seconds=2)).isoformat(timespec="seconds")) + core.beat(db, now=now - timedelta(seconds=1)) # 心跳很新鲜 —— 陷阱就在这 + + h = web.health(db, now=now, probe=_probe()) + + assert h["state"] == "crashloop" + assert h["label"] == "崩溃重启中" + + +def test_health_ok_after_one_clean_restart(db): + """一次正常重启不是崩溃循环 —— 进程熬过一个轮询周期后告警必须消失。""" + now = datetime(2026, 7, 13, 10, 0, 0) + core.set_meta(db, "prev_started_at", (now - timedelta(hours=5)).isoformat(timespec="seconds")) + core.set_meta(db, "started_at", (now - timedelta(minutes=10)).isoformat(timespec="seconds")) + core.beat(db, now=now - timedelta(seconds=2)) + + h = web.health(db, now=now, probe=_probe()) + + assert h["state"] == "ok" + + +def test_health_ok_when_current_process_survived(db): + """上个进程是崩的,但当前这个已经活过一个轮询周期 —— 循环结束了,不该再报警。""" + now = datetime(2026, 7, 13, 10, 0, 0) + core.set_meta(db, "prev_started_at", (now - timedelta(seconds=65)).isoformat(timespec="seconds")) + core.set_meta(db, "started_at", (now - timedelta(seconds=60)).isoformat(timespec="seconds")) + core.beat(db, now=now - timedelta(seconds=1)) + + h = web.health(db, now=now, probe=_probe()) + + assert h["state"] == "ok" # uptime 60s > stale_after(15s) → 循环已结束 + + def test_probe_process_parses_pgrep_output(monkeypatch): import subprocess