目标
s15 给了命名 teammate 的通信能力(MessageBus + 收件箱)与权限冒泡,但两个协作协议缺失,导致协作体验"半成品":
- 关机没有握手 — s15 的
/team stop只是关闭 inbox,teammate 被硬切,没有机会收尾、汇报最终状态。想象一个跑了一下午的研究员,被stop一下切断,任何成果都没留下。 - 计划无法审批 — teammate 要执行多步计划(重构模块、删一批文件、做迁移)时,主智能体无法在执行前审阅。要么全自动(危险),要么只能靠逐条权限冒泡(碎、繁琐)。
Index 16 引入团队协议层,对齐 Claude Code 的多智能体协作协议:
ShutdownHandshake— 关机握手状态机(REQUESTED → ACKNOWLEDGED → COMPLETED/FAILED),配合TeamMessage.kind=SHUTDOWN控制消息,实现优雅关机:请求关闭 → teammate 确认 → 收尾一跑 → farewell 汇报 → 才终止PlanApproval— teammate 提出结构化计划(标题+步骤+理由),经PlanBroker挂起,主用户审阅后批准/拒绝,唤醒后 teammate 带着决策继续
完成后的效果——关机不再丢失成果,计划在执行前过目:
$ cat-code
> /team stop researcher ← 优雅关机(不再硬切)
Shutdown requested for 'researcher' (handshake: REQUESTED)
> /team messages ← 回合间 drain 主收件箱
📨 researcher -> main: shutdown farewell —— 已收尾:s14 调查完成,发现 nextFire 缓存 2 处问题
> /team shutdown researcher
handshake status: COMPLETED
> /team plans ← teammate 提出的待审计划
┌─ Plan Review ───────────────────────────────────
│ From: reviewer
│ Title: refactor compile module
│ Rationale: 旧实现无法扩展
│ Steps:
│ 1. 抽取 Codec 接口
│ 2. 迁移调用方
│ 3. 删旧实现
├──────────────────────────────────────────────────
│ [y] Approve — proceed with the plan
│ [n] Reject — teammate adjusts and re-proposes
└──────────────────────────────────────────────────
> n ← 拒绝
> /team messages
📨 reviewer -> main: 计划被拒绝,已调整步骤后重新提出为什么需要
现实问题
s15 的两个缺口,s16 逐一补上:
- 硬切丢成果 — s15 的
stop()关闭 inbox,消费协程receive()返回 null 直接 break。teammate 的 AgentLoop 历史里可能有重要的中间结论,被一刀切掉。长寿命 teammate 需要"收尾确认"环节。 - 执行前无审阅 — teammate 的工具权限逐条冒泡(s15),但"我要按这三步重构"这种整体意图无法审批。逐条批准太碎(10 步要 10 次确认),完全不批太危险。需要一次计划级审批。
依赖图表明 s16 是 s15 的协议层,s15 的 PermissionBroker 挂起/回填范式是 s16 的直接复用基础:
s06 Subagent ──→ s15 AgentTeams ──→ s16 TeamProtocols ──→ s17 AutonomousAgents
(通信+冒泡) (关机握手+计划审批) (空闲循环/自动认领)设计原则
| 原则 | 取舍依据 |
|---|---|
| protocol 是纯协议层 | 不依赖 team 类型,避免 team→protocol→team 循环;只有 data/enum/状态机/队列 |
| 关机用控制消息,不走 LLM 上下文 | TeamMessage.kind=SHUTDOWN 由消费协程拦截,收尾跑用专用 prompt——SHUTDOWN 本身不进 LLM 历史 |
| 计划审批复用 s15 挂起范式 | PlanBroker 与 PermissionBroker 同构(ConcurrentLinkedQueue + CompletableDeferred),但请求体/决策语义不同 |
| 审阅方注入(可测) | PlanDecider 解耦"谁来决策",ReplContext 注入,生产用 ReadLinePlanDecider,测试用假实现——避免 readLine 阻塞测试 |
| 保留硬停 | requestShutdown(优雅,默认)与 stop()(硬切,紧急)并存 |
| 关机无超时 | 握手停留 REQUESTED 文档记录;s17 自治可加心跳/超时 |
核心设计与实现
架构全景
┌─────────────────────────────────────────┐
│ ReplLoop (主) │
│ ┌─────────────┐ ┌────────────────┐ │
│ │ PlanBroker │ │ ShutdownHandshake│ │
│ │ (drain 审批)│ │ (状态机,按名字) │ │
│ └──────▲──────┘ └───────▲────────┘ │
│ 回合间 drain requestShutdown│
│ (用户审阅计划) (/team stop) │
└─────────┼──────────────────┼─────────────┘
│ resolveAll │ request(name)
┌───────────────┴──────────┬───────┴──────────┐
│ TeamFactory │ │
│ spawn: 注册 ProposePlanTool(broker, name) │
│ 消费协程处理 kind=SHUTDOWN 消息: │
│ ack → wrap-up run → farewell→ main → 退出│
└───────────────┬──────────┘ │
│ SHUTDOWN TeamMessage │
┌────────▼────────┐ │
│ teammate 的 inbox│◄── requestShutdown │
└─────────────────┘ │
│ 消费协程 │
▼ │
ProposePlanTool.submitAndAwait(proposal)│
│ 挂起 │
└───── PlanBroker.pending ──────┘模块依赖(新增 team → protocol):
protocol纯协议层:ShutdownHandshake(纯状态机)、PlanBroker(纯队列)、PlanProposal/PlanDecision/ShutdownStatus(纯 data/enum)、ProposePlanTool(protocol → tool)team → protocol:TeamFactory用ShutdownHandshake+PlanBrokerrepl → protocol:TeamCommand/ReplLoop用PlanBroker+ShutdownHandshake+PlanDecider- 无循环依赖(
protocol看不到team的任何类型)
新文件一览(src/main/kotlin/com/sepcai/code/protocol/ 6 个 + repl/ReadLinePlanDecider.kt):
| 文件 | 职责 |
|---|---|
PlanProposal.kt | 计划提案 data class(id/memberName/title/steps/rationale/deferred) |
PlanDecision.kt | 决策枚举 APPROVED / REJECTED |
PlanBroker.kt | 主侧计划审批队列 + PlanDecider fun interface |
ProposePlanTool.kt | teammate 工具 propose_plan,挂起等审批 |
ShutdownStatus.kt | 握手状态枚举 REQUESTED/ACKNOWLEDGED/COMPLETED/FAILED |
ShutdownHandshake.kt | 按名字的握手状态机(ConcurrentHashMap) |
repl/ReadLinePlanDecider.kt | 主用户审阅计划的 PlanDecider 实现(打印 + readLine) |
修改文件:TeamMessage.kt(+kind/MessageKind)、TeamFactory.kt(+握手处理/+requestShutdown/+ProposePlanTool 注册)、TeamCommand.kt(+plans/+shutdown,stop 改握手)、ReplLoop.kt + ReplCommand.kt(+planBroker/+shutdownHandshake/+planDecider)。
逐类拆解
ShutdownHandshake(关机握手状态机)
// ShutdownHandshake.kt —— 纯状态机,不依赖 team 类型
class ShutdownHandshake {
private val states = ConcurrentHashMap<String, ShutdownStatus>()
fun request(name: String): ShutdownStatus {
return states.compute(name) { _, existing ->
when (existing) {
null, ShutdownStatus.COMPLETED, ShutdownStatus.FAILED -> ShutdownStatus.REQUESTED
else -> existing // 已在 REQUESTED/ACKNOWLEDGED,保持(幂等)
}
} ?: ShutdownStatus.REQUESTED
}
fun acknowledge(name: String): ShutdownStatus? {
return states.computeIfPresent(name) { _, s ->
if (s == ShutdownStatus.REQUESTED) ShutdownStatus.ACKNOWLEDGED else s
}
}
fun complete(name: String): ShutdownStatus? {
return states.computeIfPresent(name) { _, s ->
if (s == ShutdownStatus.ACKNOWLEDGED) ShutdownStatus.COMPLETED else s
}
}
// fail(): ACKNOWLEDGED → FAILED;status(name): 查询
}关键设计点:
- 纯协议层:不 import 任何
com.sepcai.code.team类型——只跟踪name → ShutdownStatus。SHUTDOWN 消息的投递与收尾跑由 TeamFactory 驱动,握手状态机只做记录。这避免了team → protocol → team循环依赖。 - 幂等 + 可重启:
request在终态后再次调用可重启新一轮握手(同名字可多次开机关机);已进行中保持原状态。 - 线程安全:主线程
request(/team stop)与 teammate 消费协程acknowledge/complete/fail并发,经 ConcurrentHashMap 的原子 compute 保证。 - 非法转换忽略:
complete在 REQUESTED 上调用不生效(保持 REQUESTED)——消费协程必须先 ack。
TeamMessage.kind(控制消息载体)
// TeamMessage.kt(s16 修改)
enum class MessageKind { MESSAGE, SHUTDOWN }
data class TeamMessage(
val from: String,
val to: String,
val content: String,
val summary: String = "",
val timestamp: Long = System.currentTimeMillis(),
val kind: MessageKind = MessageKind.MESSAGE // 默认向后兼容
)为什么用 kind 而非独立通道:关机请求本质是一条发给 teammate 的消息——复用 MessageBus 的按名路由,展示总线可扩展性(控制消息与对话消息同路不同语义)。teammate 消费协程按 kind 分流:MESSAGE 走 resume 路径,SHUTDOWN 拦截执行握手。
消费协程的关机握手(TeamFactory)
// TeamFactory.kt(s16 修改)
while (coroutineContext.isActive) {
val msg = inbox.receive() ?: break // null = inbox 关闭(硬停)
if (msg.kind == MessageKind.SHUTDOWN) {
runShutdownHandshake(name, loop)
break
}
store.markRunning(name)
loop.run(formatMessage(msg))
store.markIdle(name)
}
private suspend fun runShutdownHandshake(name: String, loop: AgentLoop) {
handshake.acknowledge(name) // REQUESTED → ACKNOWLEDGED
val farewell = loop.run(SHUTDOWN_PROMPT) // 收尾跑
messageBus.send(TeamMessage(name, "main", farewell, "shutdown farewell"))
handshake.complete(name) // ACKNOWLEDGED → COMPLETED
}收尾跑用专用 prompt(SHUTDOWN_PROMPT:wrap up 并汇报最终结论),而不是把 SHUTDOWN 消息本身传给 LLM——控制消息不进 LLM 上下文。若收尾跑抛异常,外层 catch 标记 member FAILED + 握手 FAILED。
requestShutdown API:
// TeamFactory.kt
fun requestShutdown(name: String): ShutdownRequest {
val inbox = inboxes[name] ?: return ShutdownRequest.Unknown(name)
handshake.request(name)
inbox.deliver(TeamMessage(from = "main", to = name, content = "", kind = MessageKind.SHUTDOWN))
return ShutdownRequest.Requested(name)
}异步语义:立即返回(消息已入队),握手进度经 handshake 查询。这是"优雅"的关键——teammate 收尾在后台完成,主侧不阻塞。
PlanBroker + ProposePlanTool(计划审批)
// PlanBroker.kt —— 镜像 s15 PermissionBroker 的挂起/回填范式
class PlanBroker {
private val pending = ConcurrentLinkedQueue<PlanProposal>()
suspend fun submitAndAwait(proposal: PlanProposal): PlanDecision {
pending.add(proposal)
return proposal.deferred.await()
}
suspend fun resolveAll(decider: PlanDecider): Int {
var resolved = 0
while (true) {
val proposal = pending.poll() ?: break
val decision = decider.decide(proposal)
proposal.deferred.complete(decision) // 唤醒挂起的 teammate
resolved++
}
return resolved
}
}
fun interface PlanDecider { fun decide(proposal: PlanProposal): PlanDecision }// ProposePlanTool.kt(节选)
override suspend fun execute(input: JsonObject): ToolResult {
val title = input["title"]?.jsonPrimitive?.content ?: error
val steps = input["steps"]?.jsonArray?.map { it.jsonPrimitive.content } ?: error
if (steps.isEmpty()) return error("at least one step")
val proposal = PlanProposal(id = "plan_${UUID.randomUUID()}", memberName = memberName, ...)
val decision = broker.submitAndAwait(proposal) // teammate 挂起直到主用户审批
return when (decision) {
PlanDecision.APPROVED -> ToolResult("", "Plan approved: $title")
PlanDecision.REJECTED -> ToolResult("", "Plan rejected: $title. Adjust and propose again.", isError = true)
}
}与 s15 PermissionBroker 的对比(刻意不泛化):
| 维度 | s15 PermissionBroker | s16 PlanBroker |
|---|---|---|
| 请求体 | PermissionRequest(工具调用) | PlanProposal(结构化计划) |
| 决策 | PermissionDecision(4 态含 always 记忆) | PlanDecision(2 态单次) |
| 语义 | 工具是否可执行(安全) | 计划是否可行(意图) |
| 记忆 | ApprovalStore 记住偏好 | 无记忆,每次单次裁决 |
两个 broker 并存而非抽象成一个泛化 broker——「不过度设计」,且语义确实不同(安全 vs 意图)。
ReadLinePlanDecider(审阅方)
// repl/ReadLinePlanDecider.kt
class ReadLinePlanDecider : PlanDecider {
override fun decide(proposal: PlanProposal): PlanDecision {
// 打印来源/标题/理由/步骤 + y/n 提示
while (true) {
val input = readLine()
parseDecision(input)?.let { return it }
if (input == null) { println("(EOF — rejecting)"); return PlanDecision.REJECTED }
}
}
internal fun parseDecision(input: String?): PlanDecision? {
return when (input?.trim()?.lowercase()) {
"y", "yes", "approve" -> PlanDecision.APPROVED
"n", "no", "reject" -> PlanDecision.REJECTED
else -> null
}
}
}审阅方注入:PlanDecider 经 ReplContext 注入(与 UserPrompter 对称)。生产用 ReadLinePlanDecider,测试注入假实现——避免 readLine 在测试环境阻塞。
主侧接线(ReplLoop / TeamCommand)
ReplLoop:新建 planBroker + shutdownHandshake 传给 TeamFactory;回合间新增 drainPlanRequests()(planBroker.resolveAll(planDecider));ReplContext +planBroker/shutdownHandshake/planDecider。
TeamCommand 子命令扩展:
/team [list|messages|permissions|plans|stop <name>|shutdown <name>]stop <name>→factory.requestShutdown(优雅握手),打印Shutdown requested (handshake: REQUESTED)shutdown <name>→handshake.status查询plans→planBroker.resolveAll(planDecider)(与回合间 drain 同逻辑)list→ 追加显示进行中的握手状态与待审计划数
接线细节:ReplLoop / ReplContext / 测试支持
ReplLoop 成员与初始化(s15 基础上 +2):
// ReplLoop.kt
private val planDecider: PlanDecider = ReadLinePlanDecider() // /team plans + 回合间共用
private lateinit var planBroker: PlanBroker // s16
private lateinit var shutdownHandshake: ShutdownHandshake // s16// ReplLoop.start() 内,s15 team 设置处:
planBroker = PlanBroker()
shutdownHandshake = ShutdownHandshake()
val teamFactory = TeamFactory(
llmProvider = resilientProvider, config = config,
messageBus = messageBus, permissionBroker = permissionBroker,
planBroker = planBroker, handshake = shutdownHandshake, // s16 新增两参
scope = teamScope
)回合间 drain 链(每次 REPL 回合后依次执行):
// ReplLoop.kt —— 主 while 循环内
drainNotifications() // s13: 后台完成通知
drainTeamMessages() // s15: teammate 发给 main 的消息
drainPermissionRequests() // s15: teammate 冒泡的权限请求
drainPlanRequests() // s16: teammate 提出的计划private fun drainPlanRequests() {
val resolved = runBlocking { planBroker.resolveAll(planDecider) }
if (resolved > 0) logger.info { "Reviewed $resolved teammate plan(s)" }
}ReplContext +3 字段(供 TeamCommand 使用):planBroker、shutdownHandshake、planDecider——与既有的 permissionBroker/userPrompter 对称。
测试支持:TeamCtxFields 同步 +3 字段(planBroker/shutdownHandshake/planDecider),makeTeamFields() 提供 ApproveAllPlanDecider 假实现(避免命令测试阻塞 readLine)。5 个既有命令测试(Cron/Memory/Skill/Background/Task)的 ReplContext 构造用 perl 批量补上 3 字段——这是每 Index 加 ReplContext 字段的固定成本。
错误处理
| 失败场景 | 行为 |
|---|---|
/team stop 未知名字 | ShutdownRequest.Unknown,打印 "No active teammate named 'x'" |
| 收尾跑抛异常 | 外层 catch → member FAILED + handshake.fail(握手 FAILED) |
| 关机时 teammate 正在 loop.run | SHUTDOWN 消息排队,当前轮跑完后处理(优雅) |
propose_plan 缺参/空步骤 | 工具返回 isError,不提交 broker |
| teammate 挂起等计划审批 | 无超时;主回合间 resolveAll 唤醒 |
| EOF 读入(计划审阅) | ReadLinePlanDecider 返回 REJECTED |
与 s15 的衔接
s16 复用 s15 的三块基础:消费协程(新增 SHUTDOWN 分支)、PermissionBroker 挂起范式(PlanBroker 镜像)、TeamFactory.stop 优雅停止(升级为 requestShutdown 握手)。TeamMessage.kind 是对 s15 消息模型的向后兼容扩展(默认 MESSAGE,既有调用无感)。
端到端:关机握手生命周期
Step 1 用户 /team stop researcher
│ TeamCommand.requestShutdown → TeamFactory.requestShutdown(TeamCommand.kt:113)
│ ├─ ShutdownHandshake.request("researcher") → REQUESTED(ShutdownHandshake.kt:60)
│ └─ inbox.deliver(TeamMessage(kind=SHUTDOWN))(TeamFactory.kt:113)
▼
Step 2 researcher 消费协程(IDLE,挂起在 receive)被唤醒
│ msg.kind == SHUTDOWN → runShutdownHandshake(TeamFactory.kt:154)
│ ├─ handshake.acknowledge → ACKNOWLEDGED
│ ├─ loop.run(SHUTDOWN_PROMPT) ← 收尾跑,LLM 汇总成果
│ ├─ messageBus.send(farewell → main) ← farewell 入主 inbox
│ └─ handshake.complete → COMPLETED
│ break → markCompleted → finally unregister
▼
Step 3 用户 /team messages(回合间 drain)
│ 📨 researcher -> main: shutdown farewell —— 已收尾:s14 调查完成…
▼
Step 4 用户 /team shutdown researcher → handshake.status = COMPLETED端到端:计划审批生命周期
Step 1 reviewer 调 propose_plan(title="重构 compile 模块", steps=[...], rationale=…)
│ ProposePlanTool.execute → PlanBroker.submitAndAwait(ProposePlanTool.kt:71)
│ ├─ PlanProposal 入 broker 队列
│ └─ reviewer 协程挂起在 deferred.await()
▼
Step 2 主 REPL 回合间 drainPlanRequests()(ReplLoop.kt:471)
│ PlanBroker.resolveAll(planDecider)(PlanBroker.kt:78)
│ ├─ ReadLinePlanDecider.decide(proposal) ← 打印计划 + 用户 y/n
│ └─ deferred.complete(APPROVED/REJECTED) ← 唤醒 reviewer
▼
Step 3 reviewer 恢复
│ propose_plan 返回 "Plan approved: ..." / "Plan rejected: ..."
│ AgentLoop 带着决策继续(批准则执行步骤,拒绝则调整重提)两个生命周期共享同一个时间维度:主 REPL 回合间 drain。这是 s15 权限冒泡确立、s16 沿用的核心节奏——后台 teammate 的异步审批请求在主回合边界被同步感知。
会话退出与握手交互
关机握手与整个 REPL 会话的生命周期如何交互?三条规则:
- 会话退出 = 全体取消:
finally { teamScope.cancel() }取消所有 teammate 消费协程。正在收尾跑(ACKNOWLEDGED)的 teammate 收到CancellationException,runShutdownHandshake中的loop.run被中断——farewell 可能来不及发出。这是会话退出的语义:整体关闭,不等待单个握手完成。 - 握手状态保留:
ShutdownHandshake是内存态,会话退出即消失(无持久化)。这与 s12 TaskStore(磁盘持久化)形成对比——握手是会话内协议,跨会话的"待关机清单"不属于 s16 职责。 - /team stop 与 /team shutdown 的时序:
stop发起请求(异步),shutdown查询进度。用户可能看到REQUESTED(刚发起)、ACKNOWLEDGED(收尾中)、COMPLETED(完成)、FAILED(收尾异常)。/team list对非 COMPLETED 的握手显示handshake=xxx,让进行中的关机可视化。
$ /team list
Team members (2):
researcher [IDLE] handshake=ACKNOWLEDGED ← 正在收尾
reviewer [COMPLETED] ← 已完成(握手也 COMPLETED,不显示)与 s15 的对比总览
| 维度 | s15 Agent Teams | s16 Team Protocols |
|---|---|---|
| 通信 | MessageBus + 收件箱(双向消息) | 消息加 kind(控制消息 SHUTDOWN) |
| 停止 | stop() 关 inbox(硬切) | requestShutdown() 握手(优雅收尾) |
| 审批 | 权限冒泡(工具级 ASK) | 计划审批(意图级 APPROVED/REJECTED) |
| 挂起范式 | PermissionBroker submit/await | PlanBroker submit/await(同构) |
| 审阅方 | 主用户(ReadLinePrompter) | 主用户(ReadLinePlanDecider,可替换) |
| 新包 | team/(14 文件) | protocol/(6 文件)+ 1 个 repl 审阅方 |
| 依赖 | team → subagent, task | 新增 team → protocol |
| 状态机 | TeamStore(PENDING→RUNNING↔IDLE→终态) | ShutdownHandshake(REQUESTED→ACK→终态) |
| 生命周期 | teammate 跑完进 IDLE | 关机进 COMPLETED(握手追踪全过程) |
测试策略
| 测试类 | 覆盖点 |
|---|---|
ShutdownHandshakeTest (13) | 状态机 request→ack→complete、非法转换忽略、终态不倒退、终态后可重启、按名独立、并发安全 |
PlanBrokerTest (6) | submitAndAwait 挂起/唤醒、drainPending 快照、resolveAll FIFO、多轮 resolve |
ProposePlanToolTest (6) | 提案字段正确、挂起直到 resolve、APPROVED/REJECTED 返回、缺参/空步骤、全链路 |
ReadLinePlanDeciderTest (3) | parseDecision 映射 y/yes/approve、n/no/reject、无效输入 null |
TeamFactoryTest 扩展 (+3) | requestShutdown→收尾→farewell→COMPLETED+握手 COMPLETED、未知名字、propose_plan 集成(TOOL_USE→挂起→审批→恢复) |
TeamCommandTest 扩展 (+5) | /team plans 审阅、/team stop 握手、/team shutdown 查询、list 待审计数 |
测试设计上的坑(记录如下):
-
REQUESTED是瞬态,不能同步断言。requestShutdown同步投递消息,但消费协程在Dispatchers.Default上立即处理(FakeLLM 返回飞快)——握手可能在测试线程检查时已是 COMPLETED。修复:直接awaitCondition { handshake.status == COMPLETED },不断言中间的 REQUESTED。与 s15 踩过的PENDING瞬态坑同族。 -
json DSL 完全限定名解析不到扩展函数。
kotlinx.serialization.json.buildJsonObject { put("title", "x") }——put(key, String)是包级扩展函数,需 import 才在作用域内;完全限定调用只解析到成员put(key, JsonElement),String 参数报错。修复:importput/add/buildJsonArray等 DSL 扩展。 -
propose_plan不会误触权限冒泡。teammate 的 PermissionPipeline 规则(Dangerous + Path)对非 bash/非路径工具返回 ALLOW——propose_plan 直接通过,无需验证这个前提就能写集成测试。
开发过程记录
设计矛盾 1:协议层依赖方向——protocol 不能依赖 team
初版设想 ShutdownHandshake 直接操作 TeamMessage/TeamInbox,放进 protocol 包。审查发现这会产生循环依赖:team → protocol(TeamFactory 用握手)+ protocol → team(握手用 TeamMessage)= 环。
取舍:ShutdownHandshake 收缩为纯状态机(只跟踪 name → ShutdownStatus),SHUTDOWN 消息的投递与收尾跑留在 TeamFactory。protocol 层因此只有 data/enum/状态机/队列/工具,单向依赖 team → protocol。代价是握手逻辑分散在两处(状态机在 protocol,投递在 team),但依赖清晰无环。这是"依赖方向优先于代码聚合"的取舍。
设计矛盾 2:计划审批的审阅方——用户还是主 LLM?
Claude Code 里子智能体的计划由主智能体审批。cat-code 的主智能体是 REPL 助手,让它审批 teammate 计划需要"唤醒主 LLM 决策",复杂度高(主循环由用户输入驱动,无空闲决策挂点)。
取舍:本期由主用户审阅(ReadLinePlanDecider,与权限冒泡一致)。主 LLM 决策留后续(s17 自治可注入主 LLM 作为 PlanDecider 实现)。同时把 PlanDecider 做成 fun interface 注入——审阅方可替换,未来把 ReadLinePlanDecider 换成 MainLlmPlanDecider 即可,broker 与工具零改动。
设计矛盾 3:两个 broker 是否泛化成一个?
PermissionBroker(s15)与 PlanBroker(s16)结构几乎相同(队列 + deferred 挂起/回填)。初版考虑抽象成通用 ApprovalBroker<T>。
取舍:不泛化,两个独立实现。理由:①请求体与决策语义不同(工具安全 vs 计划意图,4 态 vs 2 态);②泛化需要类型擦除/泛型接口,增加抽象成本;③「不过度设计」原则——重复一点结构,换来每处语义清晰。代码注释互相对照两个 broker 的差异。
设计矛盾 4:优雅关机 vs 硬停——两者并存
requestShutdown(优雅)与 stop(硬切)都保留。为什么?优雅是默认(收尾 + farewell),但 teammate 卡死在无限 loop.run 时优雅失效(握手停留 REQUESTED)。硬停(关 inbox)作为紧急逃生口。/team stop 用优雅,硬停留内部/测试。
开发中发现并修复的问题
- 瞬时状态断言竞态(见测试坑 1)——
handshake.status == REQUESTED在 requestShutdown 后立即断言是 racy,消费协程可能已推进到 COMPLETED。 runBlocking { launch { submitAndAwait } }死锁(s15 踩过的坑复现)——plan 测试里 submit 与 resolveAll 必须放同一 runBlocking,否则挂起协程永不返回。这次在写 PlanBrokerTest 时直接规避,未再触发。- ReplContext 字段扩散——每 Index 加字段导致 5 个既有命令测试都要改(s16 又加了 3 个字段)。教训:
makeTeamFields()共享 helper 把字段集中,但 ReplContext 本身字段仍多。后续 Index 可考虑聚合 team 相关字段进一个 holder。
实现细节:farewell 路由窗口
runShutdownHandshake 里有一个时序要求:farewell 必须赶在 finally { messageBus.unregister(name) } 之前发出。
// TeamFactory.kt —— 顺序敏感
messageBus.send(TeamMessage(name, "main", farewell, "shutdown farewell")) // ① 先发
handshake.complete(name) // ② 标记完成
break // ③ 退出循环
store.markCompleted(name) // ④ markCompleted
// finally { messageBus.unregister(name) } // ⑤ 注销,之后 send 报 UnknownRecipient如果 ① 和 ⑤ 顺序颠倒(比如把 farewell 放到 finally 之后),farewell 会因名字已注销而路由失败——MessageBus.send 返回 UnknownRecipient,teammate 的成果汇报丢失。这个窗口在测试里显式验证:mainInbox.poll() 能取到 farewell,说明 ① 确实发生在 ⑤ 之前。
收尾跑失败的路径:若 ① 的 loop.run(SHUTDOWN_PROMPT) 抛异常,异常传播到外层 catch——member 标记 FAILED,且因握手处于 ACKNOWLEDGED,handshake.fail 把握手置 FAILED。/team shutdown 能看到 FAILED,区分"正常关机"与"收尾失败"。
关键取舍汇总
| 决策 | 选择 | 理由 |
|---|---|---|
| protocol 不依赖 team | 纯状态机/队列/工具 | 避免 team→protocol→team 循环 |
| 关机消息载体 | TeamMessage.kind=SHUTDOWN | 复用 MessageBus 路由,控制消息不进 LLM 上下文 |
| 计划审阅方 | 主用户(PlanDecider 注入可换) | 与权限冒泡一致;主 LLM 留 s17 |
| 两 broker 并存 | PermissionBroker + PlanBroker | 语义不同,不过度设计 |
| 优雅 vs 硬停 | requestShutdown(默认)+ stop(紧急) | 优雅丢不了成果,硬停是逃生口 |
| 关机无超时 | 握手停留 REQUESTED | s16 不引入;s17 自治可加心跳 |
改动足迹与增量统计
新增文件(git status 视角):
protocol/ (6 个新文件)
├── PlanProposal.kt # 计划提案 data class
├── PlanDecision.kt # 决策枚举
├── ShutdownStatus.kt # 握手状态枚举
├── ShutdownHandshake.kt # 握手状态机
├── PlanBroker.kt # 审批队列 + PlanDecider
└── ProposePlanTool.kt # teammate 工具
repl/ReadLinePlanDecider.kt # 主用户审阅方修改文件(8 个 main + 9 个 test):
main: TeamMessage.kt(+kind)、TeamFactory.kt(+握手/+requestShutdown)、
TeamCommand.kt(+plans/+shutdown)、ReplLoop.kt、ReplCommand.kt
test: ShutdownHandshakeTest(+13)、PlanBrokerTest(+6)、ProposePlanToolTest(+6)、
ReadLinePlanDeciderTest(+3)、TeamFactoryTest(+3)、TeamCommandTest(+5)、
TestTeamSupport(+3 字段)、另 5 个命令测试(ReplContext 补字段)测试增量:s16 新增 ~36 个用例(s15 结束时全量 610,s16 结束约 646)。全量 ./gradlew test 绿。
本次实现的实际成本:核心协议逻辑(protocol 6 文件 + 握手分支)约半天;接线与测试改造(ReplContext 字段扩散到 5 个既有命令测试)占了剩余时间——ReplContext 每 Index 加字段的维护成本开始显现,s20 前值得考虑聚合。
真实对话示例:完整协作回合
把两个协议放进一个真实场景(主智能体派生 researcher + reviewer,reviewer 提出重构计划,随后关停团队):
> 派生一个 researcher 调查编译模块的性能瓶颈,再让 reviewer 基于报告提出优化计划
🤖 spawn teammate 'researcher' ...
🤖 spawn teammate 'reviewer' ...
# ── 回合间:researcher 完成调查,汇报给 main ──
📨 researcher -> main: 瓶颈在编译器的符号表查找,O(n) 线性扫描
# ── 用户让 main 指示 researcher 深入,同时 reviewer 提出计划 ──
> 让 researcher 看下符号表的缓存策略;reviewer 提个优化计划
📨 reviewer -> main: (propose_plan 挂起中)
# ── 回合间 drain:计划审阅 ──
┌─ Plan Review ───────────────────────────────────
│ From: reviewer
│ Title: 为符号表加哈希索引
│ Rationale: 将 O(n) 查找降为 O(1)
│ Steps:
│ 1. 实现 HashIndex 结构
│ 2. 接入 SymbolTable 查询路径
│ 3. 跑基准对比
└──────────────────────────────────────────────────
> y
1 plan(s) reviewed.
# ── reviewer 被批准,开始执行;researcher 汇报缓存发现 ──
📨 researcher -> main: 已找到现有缓存,SymbolTable 有 3 处未命中路径
# ── 用户决定收工,优雅关停两个 teammate ──
> /team stop researcher
Shutdown requested for 'researcher' (handshake: REQUESTED)
> /team stop reviewer
Shutdown requested for 'reviewer' (handshake: REQUESTED)
# ── 回合间:两个 farewell 汇报 ──
📨 researcher -> main: shutdown farewell —— 调查完成:符号表瓶颈 + 缓存路径分析,共 2 项发现
📨 reviewer -> main: shutdown farewell —— 优化进行到步骤 2,HashIndex 已实现,基准待跑
# ── 验证握手状态 ──
> /team shutdown researcher
handshake status: COMPLETED
> /team shutdown reviewer
handshake status: COMPLETED
> exit
Stopping team scope and closing main inbox
Goodbye! 🐾这个示例串联了 s16 的全部机制:计划审批(reviewer 的提案被批准后执行)与关机握手(两个 teammate 收尾后 farewell,成果不丢失)。对比 s15 的硬切——这里研究员和审查员的中间成果都留下来了,这是"关机握手"的核心价值。
下一站
s16 为阶段四后续 Index 留下了什么基础:
- s17 Autonomous Agents — 两个协议的"审阅方/唤醒方"都是可替换的注入点:
PlanDecider可换成主 LLM 决策、主 inbox 的回合间 drain 可升级为IdleLoop自动续跑。ShutdownHandshake的 REQUESTED 超时检测可接入 s17 的心跳。 - s18 Worktree Isolation — 关机握手的 farewell 是"成果收尾"的载体,s18 可让收尾跑把改动提交到 teammate 专属 worktree。
- s20 Comprehensive Agent —
requestShutdown+resolveAll的"请求-回填"双向通道,是"全机制归到一个循环"的骨架参考。
未完事项:计划审阅不支持"带修改意见拒绝"(二元决策,teammate 只能重提);关机无超时;主 LLM 作为 PlanDecider 的实现在 s17。


