🧹 Jarvis Phase 2.5:给 Agent 做一次「大扫除」

🧹 Jarvis Phase 2.5:给 Agent 做一次「大扫除」

当 jarvis --help 显示 13 个子命令全部正常注册时,我终于确认:这次重构没有翻车。

这是 Jarvis-Agent 的第 2.5 阶段——一个没有新功能的版本。听起来无聊?但这可能是整个项目中最重要的一次迭代。

为什么要停下来打扫?

Phase 2 写完后,我做了一次完整的代码审查。结果让我有点不好意思:

问题严重程度描述
🔴 线程安全P0_recent_changes 在 watchdog 线程和 async 主循环之间裸读写
🔴 死代码P07 个文件完全没有被引用,还占着工程目录
🟡 CLI 巨石P1cli.py 膨胀到 1909 行,什么逻辑都往里塞
🟡 遗留模块P1Phase 0 的 config.py、main.py 还躺在 src/ 里

看完这份清单,我意识到一个事实:

如果不在 Phase 3 之前把这些债还掉,后面每加一个功能都要踩一次坑。

于是我决定:Phase 2.5,不写新功能,只还债。


🔒 第一刀:线程安全

这个 bug 最隐蔽,也最危险。

JarvisEventHandler 是 watchdog 的文件事件处理器。它有一个 _recent_changes 列表,用来收集文件变化。问题是:watchdog 的回调跑在独立线程里,而主循环是 asyncio。两边同时读写同一个列表,没有任何保护。

你可能说"Python 有 GIL,列表操作是原子的"——确实,list.append() 是原子的,但 get_and_clear_changes() 里是先读后清,这个组合操作不是原子的:

# ❌ 修复前:两步操作之间可能被插入
def get_and_clear_changes(self):
    changes = self._recent_changes.copy()  # 读
    self._recent_changes.clear()           # 清 ← 读和清之间可能有新写入
    return changes

修复很简单,加一把锁:

import threading

class JarvisEventHandler(FileSystemEventHandler):
    def __init__(self):
        self._recent_changes = []
        self._lock = threading.Lock()  # ✅ 新增
    
    def _record_change(self, event_type, path):
        with self._lock:  # ✅ 写入加锁
            self._recent_changes.append({...})
    
    def get_and_clear_changes(self):
        with self._lock:  # ✅ 读+清原子化
            changes = self._recent_changes.copy()
            self._recent_changes.clear()
            return changes

三行代码,消除一个 race condition。这种 bug 可能跑一万次都不出问题,但出一次就是数据丢失。


🗑️ 第二刀:清除死代码

接下来是最爽的部分——删代码。

我用 grep -r 搜索每个模块的导入和引用情况,找出了七个完全没有被使用的文件:

文件来历行数状态
src/main.pyPhase 0 的 FastAPI 入口180 行🗑️ 已删除
src/config.pyPhase 0 的 Pydantic Settings120 行🗑️ 已删除
src/llm/client.py早期直调 DeepSeek/Claude API200 行🗑️ 已删除
src/memory/database.pyPhase 1 的纯 SQLite 方案250 行🗑️ 已删除
src/memory/models.py旧数据模型80 行🗑️ 已删除
src/proactive/scheduler.py未实现的定时调度150 行🗑️ 已删除
src/proactive/__init__.py空包5 行🗑️ 已删除

另外还删掉了 cli.py 中的 serve 命令——那是 Phase 0 遗留的 FastAPI serve 逻辑,从来没有被调用过。

总计删除约 1000 行代码。删完之后项目目录清爽多了:没有的引用的模块全部消失,src/proactive/ 整个目录都不存在了。

删代码比写代码更需要勇气——你得确认每一行真的没人用。但删完之后的轻松感,无与伦比。


📦 第三刀:CLI 模块化

这是工作量最大的部分。

cli.py 是整个项目的 CLI 入口,承载了 13 个子命令、聊天循环、Tab 补全、流式输出、守护进程管理……全部堆在一个文件里,1909 行。

找一个函数?先 Ctrl+F。改一个命令?担心影响其他命令。这不是一个可以继续进化的架构。

分拆策略

我按职责边界把 cli.py 拆成了 6 个模块:

src/cli/                       # 📦 从 1 个文件 → 6 个模块
├── __init__.py          # 141 行  app 定义 + main callback
├── common.py            # 180 行  常量、路径、状态查询
├── chat.py              # 335 行  聊天循环、补全器、streaming
├── daemon_cmds.py       # 272 行  start/rest/status
├── memory_cmds.py       # 310 行  recall/think/insights
└── explore_cmds.py      # 415 行  init/explore/projects/discoveries/skills

关键设计决策

1. 保持入口不变

pyproject.toml 里的入口是 jarvis = "src.cli:app"。把 cli.py 变成 cli/ 包之后,__init__.py 导出 app 对象,入口零改动。所有用户的 jarvis 命令无感升级。

2. 共享状态下沉

JARVIS_HOME、VERSION、get_config_path() 这些常量和工具函数,下沉到 common.py,其他模块 from .common import 即可。

3. 循环依赖预防

聊天模块可能需要调用 daemon 命令(比如用户在聊天中输入 /start),daemon 模块不需要知道聊天的存在。依赖方向是单向的:

__init__.py → 所有子模块
chat.py → common.py, daemon_cmds.py, memory_cmds.py, explore_cmds.py
daemon_cmds.py → common.py
memory_cmds.py → common.py
explore_cmds.py → common.py

Before vs After

指标BeforeAfter
文件数1 个6 个
最大文件1909 行415 行
平均文件1909 行276 行
找函数Ctrl+F 全文搜按职责打开对应文件
改命令担心影响全局修改对应模块即可

🔧 附赠:两个新功能

趁着重构,顺手实现了两个一直想做但没做的小功能:

jarvis projects:真正能用了

Phase 1 写了这个命令的签名,但实现是空的。现在它会读取 discoveries.json,提取所有被发现的项目:

$ jarvis projects

📂 已发现的项目 (3个)

  1. 📦 Jarvis-Agent (python)
     路径: /Users/polly/.../Jarvis-Agent
     发现时间: 2026-02-05 02:39

  2. 📦 Master-Translator-MCP-Server (python)
     路径: /Users/polly/.../Master-Translator-MCP-Server
     发现时间: 2026-02-05 02:40

  3. 📦 www.polly.com (zola)
     路径: /Users/polly/.../www.polly.com
     发现时间: 2026-02-05 02:41

jarvis init:自动创建人格

初始化时自动调用 MemoryWriter.init_persona(),在 ~/.jarvis/memory/persona.md 创建人格骨架文件。Jarvis 的"灵魂"从第一次启动就开始生长。


🫀 PID 管理:优雅地停止

Phase 2 有一个恼人的 bug:jarvis -r(停止 daemon)不生效。

根本原因是 daemon 跑在子进程里,-r 命令只是设置了一个状态文件,但 daemon 的 asyncio.sleep(300) 不会被打断——它要等 5 分钟才能检查到停止信号。

修复方案:PID 文件 + SIGTERM 信号。

# 启动时写入 PID
pid_path = get_pid_path()
pid_path.write_text(str(daemon_process.pid))

# 停止时发送 SIGTERM
def _do_rest():
    pid = int(pid_path.read_text())
    os.kill(pid, signal.SIGTERM)

Daemon 端注册信号处理器,收到 SIGTERM 后立即取消 sleep:

async def run_daemon():
    loop = asyncio.get_event_loop()
    stop_event = asyncio.Event()
    
    def handle_signal():
        stop_event.set()
    
    loop.add_signal_handler(signal.SIGTERM, handle_signal)
    
    while not stop_event.is_set():
        await heartbeat()
        # 可被信号打断的等待
        try:
            await asyncio.wait_for(stop_event.wait(), timeout=300)
        except asyncio.TimeoutError:
            pass  # 正常超时,继续循环

效果:jarvis -r 后 daemon 立即停止,不再需要等待下一个心跳周期。


📊 Phase 2.5 总结

最终的代码统计:

指标Phase 2 结束Phase 2.5 结束变化
总代码量~4800 行3794 行-1000 行
文件数~20 个 .py14 个 .py-6 个
最大文件1909 行 (cli.py)666 行 (daemon.py)-65%
死代码7 个文件0✅
线程安全问题1 个0✅

Git 统计:19 files changed, 1742 insertions(+), 2512 deletions(-)

删掉的比写的多——这正是一次成功重构的标志。


💭 反思:为什么 Phase 2.5 很重要

软件开发有个说法:"代码的熵增是不可逆的"。

每次加功能,代码都会变复杂一点。如果不定期做清理,复杂度会指数增长,直到你不敢再动它。

Jarvis-Agent 还在进化的早期。Phase 3(工具调用)和 Phase 4(Skill 自生成)都是大工程。如果带着 1909 行的巨石 CLI 和七个死代码文件去做这些,每一步都会更难。

Phase 2.5 就像是一次「深呼吸」——停下来,整理好行装,再出发。

一个有趣的数字

整个 Phase 2.5 从开始到结束,大约花了 3 小时。但如果不做这次清理,这些技术债会在接下来的每个 Phase 中反复出现,累计浪费的时间可能是 30 小时。

还债的最佳时机,永远是现在。


🔮 Phase 3 预告

Phase 2.5 让 Jarvis 的代码库变得干净、模块化。接下来,Phase 3 的目标是让 Jarvis 学会动手做事:

  • 🦾 Tool Registry:工具注册与发现
  • 🔌 MCP 集成:复用已有的 MCP Server
  • 📋 任务规划:分解复杂任务,逐步执行

从"能想"到"能做"——这才是 Agent 真正的进化方向。


相关文章:

Comments