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
93 lines
2.7 KiB
Go
93 lines
2.7 KiB
Go
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()
|
||
}
|