Cat Blog
HomeBlogTools
Search
Language
Choose site style
Choose accent color
Click Effect
Theme

Cat Blog · Updated regularly. Source code is available on GitHub.

Index 16: Team Protocols — 关机握手 / 计划审批

August 13th, 2026
AI智能体Kotlin后端

Series

使用Kotlin从0开发一个ClaudeCode

Series

使用Kotlin从0开发一个ClaudeCode

Progress 16 / 21

使用Kotlin从0开发一个ClaudeCode

Previous in series

Index 15: Agent Teams — MessageBus / 收件箱 / 权限冒泡

Next in series

Index 17: Autonomous Agents — 空闲循环 / 自动认领

目标

s15 给了命名 teammate 的通信能力(MessageBus + 收件箱)与权限冒泡,但两个协作协议缺失,导致协作体验"半成品":

  1. 关机没有握手 — s15 的 /team stop 只是关闭 inbox,teammate 被硬切,没有机会收尾、汇报最终状态。想象一个跑了一下午的研究员,被 stop 一下切断,任何成果都没留下。
  2. 计划无法审批 — teammate 要执行多步计划(重构模块、删一批文件、做迁移)时,主智能体无法在执行前审阅。要么全自动(危险),要么只能靠逐条权限冒泡(碎、繁琐)。

Index 16 引入团队协议层,对齐 Claude Code 的多智能体协作协议:

  1. ShutdownHandshake — 关机握手状态机(REQUESTED → ACKNOWLEDGED → COMPLETED/FAILED),配合 TeamMessage.kind=SHUTDOWN 控制消息,实现优雅关机:请求关闭 → teammate 确认 → 收尾一跑 → farewell 汇报 → 才终止
  2. PlanApproval — teammate 提出结构化计划(标题+步骤+理由),经 PlanBroker 挂起,主用户审阅后批准/拒绝,唤醒后 teammate 带着决策继续

完成后的效果——关机不再丢失成果,计划在执行前过目:

TEXT
$ 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 的直接复用基础:

TEXT
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 自治可加心跳/超时

核心设计与实现

架构全景

TEXT
                    ┌─────────────────────────────────────────┐
                    │              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 + PlanBroker
  • repl → 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.ktteammate 工具 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(关机握手状态机)

KOTLIN
// 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(控制消息载体)

KOTLIN
// 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)

KOTLIN
// 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:

KOTLIN
// 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(计划审批)

KOTLIN
// 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 }
KOTLIN
// 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 PermissionBrokers16 PlanBroker
请求体PermissionRequest(工具调用)PlanProposal(结构化计划)
决策PermissionDecision(4 态含 always 记忆)PlanDecision(2 态单次)
语义工具是否可执行(安全)计划是否可行(意图)
记忆ApprovalStore 记住偏好无记忆,每次单次裁决

两个 broker 并存而非抽象成一个泛化 broker——「不过度设计」,且语义确实不同(安全 vs 意图)。

ReadLinePlanDecider(审阅方)

KOTLIN
// 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 子命令扩展:

TEXT
/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):

KOTLIN
// ReplLoop.kt
private val planDecider: PlanDecider = ReadLinePlanDecider()   // /team plans + 回合间共用
private lateinit var planBroker: PlanBroker                    // s16
private lateinit var shutdownHandshake: ShutdownHandshake      // s16
KOTLIN
// 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 回合后依次执行):

KOTLIN
// ReplLoop.kt —— 主 while 循环内
drainNotifications()          // s13: 后台完成通知
drainTeamMessages()           // s15: teammate 发给 main 的消息
drainPermissionRequests()     // s15: teammate 冒泡的权限请求
drainPlanRequests()           // s16: teammate 提出的计划
KOTLIN
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.runSHUTDOWN 消息排队,当前轮跑完后处理(优雅)
propose_plan 缺参/空步骤工具返回 isError,不提交 broker
teammate 挂起等计划审批无超时;主回合间 resolveAll 唤醒
EOF 读入(计划审阅)ReadLinePlanDecider 返回 REJECTED

与 s15 的衔接

s16 复用 s15 的三块基础:消费协程(新增 SHUTDOWN 分支)、PermissionBroker 挂起范式(PlanBroker 镜像)、TeamFactory.stop 优雅停止(升级为 requestShutdown 握手)。TeamMessage.kind 是对 s15 消息模型的向后兼容扩展(默认 MESSAGE,既有调用无感)。


端到端:关机握手生命周期

TEXT
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

端到端:计划审批生命周期

TEXT
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 会话的生命周期如何交互?三条规则:

  1. 会话退出 = 全体取消:finally { teamScope.cancel() } 取消所有 teammate 消费协程。正在收尾跑(ACKNOWLEDGED)的 teammate 收到 CancellationException,runShutdownHandshake 中的 loop.run 被中断——farewell 可能来不及发出。这是会话退出的语义:整体关闭,不等待单个握手完成。
  2. 握手状态保留:ShutdownHandshake 是内存态,会话退出即消失(无持久化)。这与 s12 TaskStore(磁盘持久化)形成对比——握手是会话内协议,跨会话的"待关机清单"不属于 s16 职责。
  3. /team stop 与 /team shutdown 的时序:stop 发起请求(异步),shutdown 查询进度。用户可能看到 REQUESTED(刚发起)、ACKNOWLEDGED(收尾中)、COMPLETED(完成)、FAILED(收尾异常)。/team list 对非 COMPLETED 的握手显示 handshake=xxx,让进行中的关机可视化。
TEXT
$ /team list
Team members (2):
  researcher  [IDLE]  handshake=ACKNOWLEDGED   ← 正在收尾
  reviewer    [COMPLETED]                      ← 已完成(握手也 COMPLETED,不显示)

与 s15 的对比总览

维度s15 Agent Teamss16 Team Protocols
通信MessageBus + 收件箱(双向消息)消息加 kind(控制消息 SHUTDOWN)
停止stop() 关 inbox(硬切)requestShutdown() 握手(优雅收尾)
审批权限冒泡(工具级 ASK)计划审批(意图级 APPROVED/REJECTED)
挂起范式PermissionBroker submit/awaitPlanBroker 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 待审计数

测试设计上的坑(记录如下):

  1. REQUESTED 是瞬态,不能同步断言。requestShutdown 同步投递消息,但消费协程在 Dispatchers.Default 上立即处理(FakeLLM 返回飞快)——握手可能在测试线程检查时已是 COMPLETED。修复:直接 awaitCondition { handshake.status == COMPLETED },不断言中间的 REQUESTED。与 s15 踩过的 PENDING 瞬态坑同族。

  2. json DSL 完全限定名解析不到扩展函数。kotlinx.serialization.json.buildJsonObject { put("title", "x") }——put(key, String) 是包级扩展函数,需 import 才在作用域内;完全限定调用只解析到成员 put(key, JsonElement),String 参数报错。修复:import put/add/buildJsonArray 等 DSL 扩展。

  3. 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. 瞬时状态断言竞态(见测试坑 1)——handshake.status == REQUESTED 在 requestShutdown 后立即断言是 racy,消费协程可能已推进到 COMPLETED。
  2. runBlocking { launch { submitAndAwait } } 死锁(s15 踩过的坑复现)——plan 测试里 submit 与 resolveAll 必须放同一 runBlocking,否则挂起协程永不返回。这次在写 PlanBrokerTest 时直接规避,未再触发。
  3. ReplContext 字段扩散——每 Index 加字段导致 5 个既有命令测试都要改(s16 又加了 3 个字段)。教训:makeTeamFields() 共享 helper 把字段集中,但 ReplContext 本身字段仍多。后续 Index 可考虑聚合 team 相关字段进一个 holder。

实现细节:farewell 路由窗口

runShutdownHandshake 里有一个时序要求:farewell 必须赶在 finally { messageBus.unregister(name) } 之前发出。

KOTLIN
// 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(紧急)优雅丢不了成果,硬停是逃生口
关机无超时握手停留 REQUESTEDs16 不引入;s17 自治可加心跳

改动足迹与增量统计

新增文件(git status 视角):

TEXT
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):

TEXT
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 提出重构计划,随后关停团队):

TEXT
> 派生一个 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 留下了什么基础:

  1. s17 Autonomous Agents — 两个协议的"审阅方/唤醒方"都是可替换的注入点:PlanDecider 可换成主 LLM 决策、主 inbox 的回合间 drain 可升级为 IdleLoop 自动续跑。ShutdownHandshake 的 REQUESTED 超时检测可接入 s17 的心跳。
  2. s18 Worktree Isolation — 关机握手的 farewell 是"成果收尾"的载体,s18 可让收尾跑把改动提交到 teammate 专属 worktree。
  3. s20 Comprehensive Agent — requestShutdown + resolveAll 的"请求-回填"双向通道,是"全机制归到一个循环"的骨架参考。

未完事项:计划审阅不支持"带修改意见拒绝"(二元决策,teammate 只能重提);关机无超时;主 LLM 作为 PlanDecider 的实现在 s17。

Table of Contents

Current section:目标

  • 1. 目标
  • 2. 为什么需要
  • 3. 现实问题
  • 4. 设计原则
  • 5. 核心设计与实现
  • 6. 架构全景
  • 7. 逐类拆解
  • 8. 错误处理
  • 9. 与 s15 的衔接
  • 10. 端到端:关机握手生命周期
  • 11. 端到端:计划审批生命周期
  • 12. 会话退出与握手交互
  • 13. 与 s15 的对比总览
  • 14. 测试策略
  • 15. 开发过程记录
  • 16. 设计矛盾 1:协议层依赖方向——protocol 不能依赖 team
  • 17. 设计矛盾 2:计划审批的审阅方——用户还是主 LLM?
  • 18. 设计矛盾 3:两个 broker 是否泛化成一个?
  • 19. 设计矛盾 4:优雅关机 vs 硬停——两者并存
  • 20. 开发中发现并修复的问题
  • 21. 实现细节:farewell 路由窗口
  • 22. 关键取舍汇总
  • 23. 改动足迹与增量统计
  • 24. 真实对话示例:完整协作回合
  • 25. ── 回合间:researcher 完成调查,汇报给 main ──
  • 26. ── 用户让 main 指示 researcher 深入,同时 reviewer 提出计划 ──
  • 27. ── 回合间 drain:计划审阅 ──
  • 28. ── reviewer 被批准,开始执行;researcher 汇报缓存发现 ──
  • 29. ── 用户决定收工,优雅关停两个 teammate ──
  • 30. ── 回合间:两个 farewell 汇报 ──
  • 31. ── 验证握手状态 ──
  • 32. 下一站
Back to top

Related Posts

View all posts
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

August 21st, 2026

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

August 13th, 2026

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

August 13th, 2026

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。