Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`。

Expand All @@ -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 保活等,手动逐条勾)。
Expand Down
22 changes: 18 additions & 4 deletions loopflow/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions loopflow/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 31 additions & 7 deletions loopflow/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 = """
Expand Down Expand Up @@ -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"))
Expand All @@ -211,10 +230,15 @@ def _render_health(h):
f'<div class="kv"><span>{_e(k)}</span>'
f'<span class="mono {cls}">{_e(v)}</span></div>'
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 = ('<div style="color:var(--muted);font-size:12px;margin-top:4px">'
'看 daemon.err.log —— agent 起不动 / 配置错了都会导致这个'
'($LOOPFLOW_HOME/daemon.err.log)</div>'
if h["state"] == "crashloop" else "")
return (f'<div class="lbl">DAEMON</div>'
f'<div class="state"><i class="dot {tone}"></i>'
f'<b class="t-{tone}">{_e(h["label"])}</b></div>{kv}')
f'<b class="t-{tone}">{_e(h["label"])}</b></div>{kv}{hint}')


def _render_counts(st):
Expand Down
3 changes: 2 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == {}
31 changes: 31 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
41 changes: 41 additions & 0 deletions tests/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down