Note
Mailbox 通信:文件锁、未读消息与 Agent 唤醒
How PseudoClaude persists per-agent mailboxes, serializes concurrent writers, injects unread messages as reminders, and wakes idle team members.
Team Member 的 Assistant 回复只属于自己的 Conversation,不能假设 Lead 或其他成员会自动看到。协作必须经过一个明确通道,既能让同时运行的 goroutine 写入,也能让成员暂时空闲后再读取。
PseudoClaude 采用每个 Agent 一个 JSON Mailbox:SendMessage 解析收件人并追加消息,Runner 在下一轮请求前读取未读项、渲染 Reminder,再把这些索引标为已读。它是本机文件协议,不是远端消息队列。
Message 同时承载文本和控制类型
Mailbox 定义四种已知类型,但底层结构允许附加 Payload:
type MessageType string
const (
MessageText MessageType = "text"
MessageShutdownRequest MessageType = "shutdown_request"
MessageShutdownResponse MessageType = "shutdown_response"
MessagePlanApprovalResponse MessageType = "plan_approval_response"
)
type Message struct {
From string `json:"from"`
To string `json:"to"`
Type MessageType `json:"type"`
Summary string `json:"summary"`
Content string `json:"content,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
Timestamp time.Time `json:"timestamp"`
Read bool `json:"read"`
}
type IndexedMessage struct {
Index int `json:"index"`
Message Message `json:"message"`
}
每个收件人使用独立文件:
~/.PseudoClaude/teams/<team>/inboxes/
<lead-agent-id>.json
<member-agent-id>.json
Box 是只追加数组,IndexedMessage 的 Index 就是数组下标,供 MarkRead 定位。已读消息不删除,因此文件会随 Team 生命周期持续增长;当前没有分页、压缩、归档或大小上限。
MessageType 并没有严格解析函数。SendMessage 只在空值时设为 text,其他任意字符串也会被写入。Shutdown 类型当前只是消息标签,没有 Mailbox 层自动终止成员的处理器;Plan Approval 也只有发送权限检查,不构成完整审批状态机。
Write 是锁内 read-modify-write
追加消息先补默认值,再在收件人自己的锁中更新 Box:
func (s *Store) Write(
ctx context.Context,
agentID string,
msg Message,
) error {
if msg.Type == "" {
msg.Type = MessageText
}
if msg.Timestamp.IsZero() {
msg.Timestamp = time.Now()
}
msg.Read = false
return s.withLock(ctx, agentID, func(box *Box) error {
box.Messages = append(box.Messages, msg)
return nil
})
}
func (s *Store) withLock(
ctx context.Context,
agentID string,
mutate func(*Box) error,
) error {
path := s.path(agentID)
release, err := filelock.Acquire(
ctx, path+".lock", filelock.Options{},
)
if err != nil {
return err
}
defer release()
box, err := s.readBox(agentID)
if err != nil {
return err
}
if err := mutate(box); err != nil {
return err
}
return atomicWriteJSON(path, box)
}
不同收件人拥有不同锁,可以并行写;同一收件人的并发写会串行,避免两个 goroutine 都读取旧 Box 后覆盖对方。测试用 10 个 goroutine 同时写一个 Agent,最终仍得到 10 条合法 JSON 消息。
Store.path 直接把 agentID + ".json" Join 到 InboxDir,不执行名称校验。生产收件人通常来自 Team Registry 中生成的安全 AgentID,但底层 Store 若被其他调用方传入 ../x 一类 ID,会有路径越界风险。
文件锁使用 O_EXCL 和陈旧回收
锁不是进程内 mutex,而是一个真实 .lock 文件:
func Acquire(
ctx context.Context,
path string,
opts Options,
) (func(), error) {
if ctx == nil {
ctx = context.Background()
}
opts = normalize(opts)
for attempt := 0; attempt < opts.Attempts; attempt++ {
file, err := os.OpenFile(
path,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
0o600,
)
if err == nil {
_, _ = file.WriteString(time.Now().Format(time.RFC3339Nano))
_ = file.Close()
return func() { _ = os.Remove(path) }, nil
}
if !errors.Is(err, os.ErrExist) {
return nil, err
}
_ = removeIfStale(path, opts.StaleAge)
if attempt == opts.Attempts-1 {
break
}
timer := time.NewTimer(jitter(opts.MinDelay, opts.MaxDelay))
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, os.ErrExist
}
默认尝试 10 次,每次随机等待 5 到 100 毫秒;ModTime 超过 10 秒的锁会被移除。它允许不同进程共享 Team 文件,也避免进程崩溃后永久锁死。
这不是带租约续期的分布式锁。如果一次合法写入超过 StaleAge,另一个 Writer 可能删掉仍在使用的锁;网络文件系统上的 O_EXCL 和 Rename 语义也取决于具体文件系统。当前 JSON 很小,设计基于本地短临界区。
原子 Rename 让无锁读取看到旧版或新版
锁内更新不会直接截断目标文件,而是写同目录临时文件后 Rename:
func atomicWriteJSON(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*.json")
if err != nil {
return err
}
tmpPath := tmp.Name()
ok := false
defer func() {
if !ok {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
ok = true
return nil
}
Read 和 ReadUnread 不获取锁。因为 Writer 最后才 Rename,Reader 通常看到完整旧文件或完整新文件,不会看到正在写的半截 JSON。实现没有 fsync 文件和父目录,突然断电后的持久性仍取决于操作系统与文件系统。
MarkRead 也走同一个 withLock,按索引把 Read 改为 true。新消息只追加在末尾,因此旧索引在并发追加后仍指向同一项;两个消费者仍可能同时读取同一批未读消息,然后分别 MarkRead,所以交付语义不是 exactly-once。
SendMessage 负责收件人解析与空闲恢复
Tool 可以从 Team Context 取默认 Team,也允许 Lead 在外部显式传 team_name:
recipients, err := t.recipients(name, args.To, env)
if err != nil {
return teamFailure("SendMessage", err)
}
resumed := map[string]string{}
for _, recipient := range recipients {
msg := mailbox.Message{
From: from,
To: recipient.AgentID,
Type: msgType,
Summary: args.Summary,
Content: args.Content,
Payload: args.Payload,
}
if err := store.Write(ctx, recipient.AgentID, msg); err != nil {
return teamFailure("SendMessage", err)
}
if shouldResume(recipient) {
taskID, err := t.Manager.ResumeMember(
ctx, name, recipient.AgentID,
)
if err != nil {
return teamFailure("SendMessage", err)
}
resumed[recipient.Name] = taskID
}
}
to 可以是 Member Name、AgentID、broadcast、* 或 all。Broadcast 会读取最新 Member 列表并排除发送者自己的 AgentID;Lead 也作为一个 Member,所以成员广播会包含 Lead。
只有 plan_approval_response 检查 env.Team.IsLead。其他控制类型和任意未知类型都可以由成员发送。循环中若先写成功若干收件人、后一个失败,不会回滚前面的消息,因此 Broadcast 不是原子多播。
收件人若是 IsActive=false 的 in-process Member,Tool 在写信后调用 ResumeMember。它通过 Task Manager 复用该成员的 Runner/Conversation,启动一轮固定 Wake Prompt;tmux/iTerm2 成员没有接通 Wake,活动成员也只收信,不会向正在运行的 goroutine实时注入。
Runner 在模型请求前消费未读消息
Team Member 每一轮构建 Reminder 时调用 TeamRunContext.Reminder:
func (c *TeamRunContext) Reminder() string {
if c == nil || c.Inbox == nil || c.AgentID == "" {
return ""
}
messages, err := c.Inbox.ReadUnread(c.AgentID)
if err != nil {
return fmt.Sprintf(
"<incoming-messages>\nFailed to read team mailbox: %v\n</incoming-messages>",
err,
)
}
if len(messages) == 0 {
return ""
}
indices := make([]int, 0, len(messages))
var b strings.Builder
b.WriteString("<incoming-messages>\n")
b.WriteString(
"Unread team messages. Plain assistant replies are not sent " +
"to teammates; use SendMessage for reports or collaboration.\n",
)
for _, indexed := range messages {
indices = append(indices, indexed.Index)
// 渲染 from/to/type/summary/content
}
b.WriteString("\n</incoming-messages>")
if err := c.Inbox.MarkRead(c.AgentID, indices); err != nil {
b.WriteString(fmt.Sprintf(
"\n<system-reminder>Failed to mark team messages read: %v</system-reminder>",
err,
))
}
return b.String()
}
显式提醒“普通回复不会发给队友”,强制模型通过 SendMessage 建立可审计通信。消息在 Reminder 返回时就标为已读,不等待 Provider 成功接收、模型处理或回执;如果随后的模型请求失败,这批消息不会自动再次注入。
Lead 使用类似的 LeadReminder 读取所有 Team 的 Lead Box。TUI 每两秒 HasLeadMail peek 一次,peek 不标已读;若界面空闲且发现消息,就自动提交协调回合。真正构造请求时 LeadReminder 才渲染并 MarkRead。
完整流程
发送者调用 SendMessage
-> 解析 Team 与 recipient/broadcast
-> 检查 plan_approval_response 只能由 Lead 发送
-> 为每个收件人获取 <agent>.json.lock
-> 读取 Box,追加 unread Message
-> 写临时 JSON,Rename 覆盖目标
-> 收件人空闲且 in-process:恢复 Task Runner
成员下一轮模型请求
-> ReadUnread(agentID)
-> 渲染 <incoming-messages>
-> 锁内 MarkRead(indices)
-> Reminder 发送给 Provider
-> 模型必须显式调用 SendMessage 回报
Lead TUI
-> 每两秒 HasLeadMail peek
-> 空闲时自动触发协调回合
-> LeadReminder 读取并 MarkRead
测试验证了什么
Mailbox 测试覆盖写入、时间戳、未读索引、MarkRead、10 goroutine 并发追加和陈旧锁回收;Filelock 测试覆盖获取、释放、冲突和 stale removal;SendMessage 测试覆盖点对点、广播、Lead-only Plan Response 和空闲成员恢复;TeamContext/LeadMail/TUI 测试确认 Reminder 内容、MarkRead 和两秒唤醒入口。
现有测试没有覆盖无效 AgentID 路径、未知 MessageType、Broadcast 部分成功、活动成员实时收信、Reminder 后 Provider 失败的消息重投、Mailbox 无限增长或网络文件系统语义。
小结
PseudoClaude 用可检查的本地文件代替隐式共享 Conversation:每个 Agent 有独立 Box,文件锁序列化 Writer,原子 Rename 保护无锁 Reader,Unread Reminder 把通信送回模型上下文。空闲进程内成员还能在收到新信后复用原 Conversation 继续工作。
下一篇完成多 Agent 主线,解释每个成员和可选子 Agent 怎样获得独立 Git worktree/branch,CWD 如何切换,以及自动清理为什么必须保守处理未提交改动和本地提交。