Files
Agent-Squad/squad/mailbox.go
weichen 6d76111a16
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)
Why: Q2 acceptance requires leader runs to detect stuck delegation loops and
cap replans instead of spinning to the turn limit, and a mailbox primitive
whose messages can never resolve a pending permission approval (acceptance 3).

Evidence: squad conformance tests (leader_mailbox_conformance_test.go):
ledger stuck->replan, replan cap finalize, mailbox delivery after hold,
mailbox-cannot-approve-permission (structural + actor second defense),
post guards; full suite green with -race.

Impact: LeaderState gains Progress/StuckTurns/Replans/MaxReplans/ReplanNote
(persisted in leader.json); RunState gains Mailbox (persisted in
runstate.json); RespondDecision gains Actor - non-human actors are rejected
on choice gates; step briefs and leader task briefs include pending mail.

Refs: Q2 plan WP2 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 00:34:46 +00:00

93 lines
2.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package squad
import (
"context"
"fmt"
"strings"
"time"
)
// mailbox.go — 邮箱原语(Q2 WP2):运行内点对点消息。
// 安全边界(验收三的构造性保证):投递路径永不经过 Respond/gate——
// 消息只在步骤启动时并入任务书;挂起中的人工门(permission/confirmation/
// quality_review)不可能被邮箱消息决议。RespondDecision.Actor 提供
// 第二道防线:非 human actor 的 choice 决议一律拒绝。
// MailMessage 是一条运行内消息。To 是角色 key,"*" = 广播给之后的每个步骤。
type MailMessage struct {
ID string `json:"id"`
From string `json:"from"`
To string `json:"to"`
Text string `json:"text"`
At time.Time `json:"at"`
Delivered bool `json:"delivered,omitempty"`
}
// PostMessage 向一个运行投递邮箱消息。运行执行中(锁被占)返回 ErrRunBusy
// ——挂起等待人工时与回合间隙可投递。消息随 RunState 持久化,重启不丢。
// 本方法不触碰任何 HITL 门(验收三:邮箱永不决议挂起的审批)。
func (e *SquadEngine) PostMessage(ctx context.Context, runID string, msg MailMessage) error {
if strings.TrimSpace(msg.Text) == "" {
return fmt.Errorf("mailbox: 消息正文为空")
}
if strings.TrimSpace(msg.To) == "" {
return fmt.Errorf("mailbox: 收件人必填(角色 key 或 *)")
}
release, err := e.acquire(ctx, runID)
if err != nil {
return err
}
defer release()
wsDir := e.runDir(runID)
st, err := e.loadRun(wsDir)
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
if msg.ID == "" {
msg.ID = newRequestID()
}
if msg.At.IsZero() {
msg.At = time.Now()
}
st.mu.Lock()
st.Mailbox = append(st.Mailbox, msg)
err = st.save()
st.mu.Unlock()
if err != nil {
return err
}
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID, Text: fmt.Sprintf("邮箱消息 %s → %s", msg.From, msg.To), At: time.Now()})
return nil
}
// takeMailFor 取出发给某角色的未投递消息(含广播),标记已投递。
// 调用方负责随后 st.save()(或在临界区内)。
func takeMailFor(st *RunState, role string) []MailMessage {
var out []MailMessage
for i := range st.Mailbox {
m := &st.Mailbox[i]
if m.Delivered {
continue
}
if m.To != role && m.To != "*" {
continue
}
m.Delivered = true
out = append(out, *m)
}
return out
}
// formatMailSection 渲染任务书里的邮箱段(空则返回空串)。
func formatMailSection(msgs []MailMessage) string {
if len(msgs) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n## 邮箱消息\n")
for _, m := range msgs {
fmt.Fprintf(&b, "- [%s] %s:%s\n", m.At.Format(time.RFC3339), m.From, m.Text)
}
return b.String()
}