消息队列、Agent Loop 与并发处理机制的完整源码级分析
从用户发送一条消息到收到回复,经过以下链路:
源码位置:src/index.ts 509-538 行
onMessage 回调,传入 NewMessagestoreMessage() 写入 SQLite messages 表storeChatMetadata() 更新聊天元数据(群名、渠道、时间戳,不存消息内容)messages (
id, chat_jid, sender, sender_name, content,
timestamp, is_from_me, is_bot_message, attachments
)
chats (
jid, name, last_message_time, channel, is_group
)
消息入库和处理是解耦的。入库是同步的(收到就存),处理是异步轮询的(每 2s 查一批)。这意味着消息不会丢失,即使处理端临时不可用。
源码位置:src/index.ts 362-461 行
轮询间隔:POLL_INTERVAL = 2000ms
这是整个系统最精妙的设计之一——用两个独立的时间戳游标来保证消息不丢不重:
| 游标 | 含义 | 推进时机 |
|---|---|---|
| lastTimestamp | 消息循环"已看到"的最新消息时间 | 拉取到新消息后立即推进(处理前) |
| lastAgentTimestamp[chatJid] | Agent "已处理"的最新消息时间(按群隔离) | Agent 成功处理后推进 |
while (true) {
// 1. 拉取新消息(timestamp > lastTimestamp)
const { messages, newTimestamp } = getNewMessages(jids, lastTimestamp);
if (messages.length > 0) {
// 2. 立即推进"已看到"游标(不等处理)
lastTimestamp = newTimestamp;
saveState();
// 3. 按群分组
const messagesByGroup = groupBy(messages, 'chatJid');
for (const [chatJid, groupMessages] of messagesByGroup) {
// 4. 检查触发词(非主群需要 @机器人名)
if (needsTrigger && !hasTrigger) continue;
// 5. 取出该群所有待处理消息(从 lastAgentTimestamp 开始)
const allPending = getMessagesSince(chatJid, lastAgentTimestamp[chatJid]);
// 6. 尝试管道到活跃容器
if (queue.sendMessage(chatJid, formatted)) {
// ✅ 成功:消息通过 stdin 管道送入正在运行的容器
lastAgentTimestamp[chatJid] = lastTimestamp;
} else {
// ❌ 无活跃容器:排队等待启动
queue.enqueueMessageCheck(chatJid);
}
}
}
await sleep(2000);
}
非主群(如私聊、其他群)需要 @机器人名 才触发。但只要一批消息中任意一条包含触发词,该批次所有积压消息都会被送入处理。
源码位置:src/group-queue.ts
GroupQueue 是整个系统的调度核心,负责:同一群内消息串行化、跨群容器并发控制。
interface GroupState {
active: boolean; // 容器是否在运行
idleWaiting: boolean; // 是否在等待后续输入(空闲)
isTaskContainer: boolean; // 正在跑定时任务还是消息
runningTaskId: string; // 当前任务 ID
pendingMessages: boolean; // 有消息在等
pendingTasks: QueuedTask[];// 排队中的定时任务
process: ChildProcess; // 进程句柄
containerName: string; // Docker 容器名
retryCount: number; // 失败重试计数
}
enqueueMessageCheck(groupJid)if (state.active) {
state.pendingMessages = true; // 已有容器跑着,标记等待
return;
}
if (activeCount >= MAX_CONCURRENT_CONTAINERS) {
state.pendingMessages = true;
this.waitingGroups.push(groupJid); // 无空闲槽,排队
return;
}
this.runForGroup(groupJid, 'messages'); // 有槽位,立即启动
sendMessage(groupJid, text) — 管道消息到活跃容器// 写入 IPC 文件(原子写入:临时文件 + rename)
// 路径:/data/ipc/{groupFolder}/input/{timestamp}-{random}.json
// 仅在容器 active 且非 task 模式时有效
drainGroup() — 容器退出后的排水逻辑容器结束后:
1. 优先处理排队的定时任务(tasks > messages)
2. 再处理排队的消息
3. 如果该群没有待处理项:
→ 调用 drainWaiting() 从全局等待队列拉下一个群
同一个群/私聊,同一时间只有一个容器在跑。新消息要么通过 stdin 管道送入当前容器,要么等当前容器结束后再启动新容器处理。不会出现同一个群两个容器同时跑的情况。
lastTimestampqueue.sendMessage(chatJid, formatted)queue.sendMessage() 检测到 isTaskContainer = true → 返回 falsequeue.enqueueMessageCheck() → pendingMessages = truedrainGroup() 先检查 pendingTaskspendingMessages = true → 启动新容器处理消息activeCount >= MAX_CONCURRENT_CONTAINERSwaitingGroups 队列drainWaiting() → 弹出第 6 个群 → 启动容器如果容器正在处理消息 A,新消息 B 不需要等容器重启——直接通过 IPC 文件管道送入。这意味着 Agent 能在同一个 session 上下文中看到所有消息,避免了重启容器的开销和上下文丢失。
源码位置:src/index.ts 281-360 行, src/container-runner.ts 117-192 行
// 从 DB 加载 session ID
const sessionId = sessions[group.folder];
// 首次运行:sessionId 为空 → 容器创建新 session
// 后续运行:复用同一个 sessionId → 继承对话历史
// 容器返回新 sessionId → 持久化到 DB
if (output.newSessionId) {
sessions[group.folder] = output.newSessionId;
setSession(group.folder, output.newSessionId);
}
.claude/ 目录(挂载为容器内的 /home/node/.claude)group.folder 隔离存储容器处理完消息后不会立即退出,而是进入空闲等待状态,等待后续消息管道进来:
IDLE_TIMEOUT = 1800000ms)CONTAINER_MAX_OUTPUT_SIZE = 10MB
// 超出时(container-runner.ts 381-393):
if (chunk.length > remaining) {
stdout += chunk.slice(0, remaining);
stdoutTruncated = true;
logger.warn('Container stdout truncated due to size limit');
}
// 容器继续运行,但输出被截断
limit = 200)NanoClaw 不直接管理 Claude 的 context window。当对话超长时:
当一个 session 累积了大量操作(安装 skill、调 API、生成图片等),Claude 的 context window 接近上限时,SDK 会触发 compaction。在压缩过程中,尚未发送的回复可能被吞掉。NanoClaw 的消息队列本身是正常的——消息确实被管道进了容器——但 Agent 内部的 context 溢出导致了丢失。
源码位置:src/task-scheduler.ts
轮询间隔:SCHEDULER_POLL_INTERVAL = 60000ms(每分钟检查一次)
const loop = async () => {
const dueTasks = getDueTasks(); // WHERE next_run <= NOW AND status = 'active'
for (const task of dueTasks) {
// 通过 GroupQueue 排队(和消息共用同一套队列)
deps.queue.enqueueTask(task.chat_jid, task.id, () => runTask(task, deps));
}
setTimeout(loop, 60000);
};
| 维度 | 消息 | 定时任务 |
|---|---|---|
| 排水优先级 | 低 | 高(先排水任务再排水消息) |
| 容器关闭延迟 | 30 分钟空闲超时 | 10 秒(快速释放槽位) |
| 管道送入 | 可以管道到活跃容器 | 不可以(等容器结束后独立启动) |
| Chat 隔离 | 按 chatJid | 按 chatJid(任务绑定到创建时的群) |
如果容器在空闲等待中,有定时任务到期,会通过 closeStdin() 强制关闭空闲容器,然后启动任务容器。任务结束后,如果还有待处理消息,再启动消息容器。
源码位置:src/ipc.ts
轮询间隔:IPC_POLL_INTERVAL = 1000ms
容器内的 Agent 通过 IPC 文件与宿主通信。Agent 写文件到 /workspace/ipc/(容器内路径),宿主每秒扫描处理。
| 类型 | 说明 | 权限 |
|---|---|---|
| send_message | 发送消息到指定聊天 | 主群可发任意群,非主群仅限自己 |
| send_file | 发送文件 | /workspace/group/ 下的文件 |
| schedule_task | 创建定时任务 | 验证 cron/interval 表达式 |
| pause_task | 暂停任务 | — |
| resume_task | 恢复任务 | — |
| cancel_task | 删除任务 | — |
| update_task | 更新任务配置 | — |
| register_group | 注册新群 | 仅主群 |
| refresh_groups | 刷新群列表 | 仅主群 |
原子写入:所有 IPC 文件使用 临时文件 + rename 模式,防止读取到写了一半的文件。
源码位置:src/index.ts 467-479 行
function recoverPendingMessages(): void {
for (const [chatJid, group] of Object.entries(registeredGroups)) {
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
if (pending.length > 0) {
logger.info({ group: group.name, pendingCount: pending.length },
'Recovery: found unprocessed messages');
queue.enqueueMessageCheck(chatJid);
}
}
}
启动时调用。利用双游标的间隙(lastTimestamp 已推进但 lastAgentTimestamp 未推进的消息)来恢复未处理的消息。
| 情况 | 处理方式 | 原因 |
|---|---|---|
| Agent 成功 | 推进 lastAgentTimestamp | 消息已处理 |
| Agent 失败,未产出任何输出 | 回滚 lastAgentTimestamp | 用户没收到任何回复,重试安全 |
| Agent 失败,但已产出部分输出 | 不回滚 | 用户已收到部分回复,重试会导致重复 |
源码位置:src/container-runner.ts 60-241 行
| 挂载内容 | 主群 | 非主群/私聊 |
|---|---|---|
| 群文件夹 | ✅ /workspace/group | ✅ 仅自己的群文件夹 |
| Session | ✅ /home/node/.claude | ✅ 独立目录 |
| IPC | ✅ /workspace/ipc | ✅ 仅自己的 IPC 目录 |
| 项目源码 | ✅ 只读 | ❌ |
| .env 文件 | ❌ 用 /dev/null 遮蔽 | ❌ |
| 其他群可见性 | ✅ available_groups.json | ❌ |
| 参数 | 值 | 说明 |
|---|---|---|
| 消息轮询间隔 | 2 秒 | startMessageLoop() |
| 调度器轮询间隔 | 60 秒 | startSchedulerLoop() |
| IPC 轮询间隔 | 1 秒 | startIpcWatcher() |
| 最大并发容器 | 5(可配置) | MAX_CONCURRENT_CONTAINERS |
| 最大重试次数 | 5 次 | 指数退避: 5s, 10s, 20s, 40s, 80s |
| 容器空闲超时 | 30 分钟 | IDLE_TIMEOUT = 1800000ms |
| 容器输出上限 | 10 MB | CONTAINER_MAX_OUTPUT_SIZE |
| 消息历史上限 | 200 条/查询 | DB 查询 limit |
| 任务关闭延迟 | 10 秒 | TASK_CLOSE_DELAY_MS |
| 触发词要求 | 仅非主群 | 需 @机器人名 |
lastTimestamp(已看到)和 lastAgentTimestamp(已处理)的分离设计,确保即使 NanoClaw 在两个游标推进之间崩溃,重启后也能通过 recoverPendingMessages() 恢复未处理的消息。
新消息通过 IPC 文件管道送入活跃容器,Agent 在同一个 session 中处理,避免容器重启的开销(约 3-10 秒)和上下文断裂。
定时任务在排水时优先级高于消息,避免时间敏感的自动化任务被积压的聊天消息饿死。
所有 IPC 文件使用「写临时文件 → rename」模式,彻底杜绝读取到半写文件的问题。
Session、IPC、文件系统挂载全部按群隔离。主群有额外的只读权限访问项目源码和跨群通信能力。
回到最初的问题——在处理"私聊用户定时任务"的问题时,头像请求"打断"了回复。
NanoClaw 的消息队列不会造成打断。机制如下:
问题不在 NanoClaw,而在 Claude Agent 层面:
结论:NanoClaw 消息队列工作正常,问题出在 Agent 内部的多请求处理逻辑。