目标
s15/s16 给了多智能体协作的通信与协议,但所有任务仍需用户手动派发——用户得盯住 /task 和 /team,逐个催。有一类需求它覆盖不了:用户不在终端前时,系统自己干活。"把符号表优化任务交给闲着的审查员"、"任务清单里 3 个就绪任务自动分给空闲 teammate"——这些需要的是自治,不是手动调度。
Index 17 引入自治层,对齐 Claude Code 的 autonomous/loop 模式:
AutoClaimer— 自动认领:就绪任务(s12TaskScheduler.readyTasks)→ 空闲 teammate(s15 IDLE 成员),派发后标记 IN_PROGRESS,teammate 处理完回到 IDLE 时自动标记 COMPLETEDIdleLoop— 空闲循环:后台协程周期性驱动 AutoClaimer,仅在主 REPL 空闲时 tick,把自治活动经 s13 通知队列展示给用户/auto命令 — 自治模式的开关与状态
完成后的效果——用户建好任务、派生好 teammate,走开一会儿,回来发现任务已自动做完:
$ cat-code
> /task add t3 跑基准测试
> 派生一个研究员和审查员
🤖 spawn teammate 'researcher' ...
🤖 spawn teammate 'reviewer' ...
# ── 用户走开 30 秒,IdleLoop 后台自动认领 ──
# 回来输入任意字符,回合间 drain:
🔔 auto - task t1 claimed by researcher
🔔 auto - task t3 claimed by reviewer
🔔 auto - task t1 completed by researcher
> /task list
Tasks (3):
t1 [COMPLETED] 调研符号表缓存 ← 研究员空闲时自动做完
t2 [PENDING] 实现哈希索引 ← 等更多空闲队友或主智能体继续
t3 [COMPLETED] 跑基准测试
> /auto
Autonomous agents: ON (tick every 10s)
claimed: 2 completed: 2
> /auto off
Autonomous agents: OFF为什么需要
现实问题
s12 给了持久任务队列(TaskRecord + readyTasks),s15 给了空闲 teammate 池(IDLE 成员),s16 给了关机/计划协议。但它们之间是断的:
- 就绪任务没人领 —
/task列出[PENDING] 调研符号表,用户得手动挑一个 teammate 说"去做 t1"。任务在队列里躺着,空闲队友在 IDLE 挂着,中间没有自动连接。 - 空闲队友闲置 — teammate 跑完初始任务进入 IDLE 等消息,没有新指令就一直闲着。而任务队列里可能积压 5 个就绪任务。
- 用户必须在场 — 所有派发都发生在用户输入之后。用户走开 = 系统停摆,尽管任务和队友都"ready"。
依赖图表明 s17 是任务系统与团队系统的汇合点:
s05 TodoWrite -> s12 TaskSystem -> s13 BackgroundTasks -> s14 CronScheduler
↘ s17 AutonomousAgents
s06 Subagent ──→ s15 AgentTeams ──→ s16 TeamProtocols ──→ s17 AutonomousAgents设计原则
| 原则 | 取舍依据 |
|---|---|
| 完成检测用 IDLE 状态推断 | teammate 消费协程处理任务消息后自然 IDLE——不需要给 teammate 加"任务完成"协议,消费循环零改动 |
| 空闲循环是真·后台 | IdleLoop 是后台协程(非回合间 drain),用户不在时也干活——这才是"自治" |
| busy 门控保护主循环 | 主 REPL 处理回合时 IdleLoop 跳过 tick,避免后台自治与主 AgentLoop 抢注意力 |
| 只认领不派生 | AutoClaimer 把任务派给现有 IDLE 队友;spawn 仍是主智能体的职责 |
| 活动经 s13 通知展示 | 认领/完成推 NotificationQueue,用户下回合 drain 感知——复用既有异步感知通道 |
| TaskStore 线程安全 | s17 首次后台线程写 TaskStore,@Synchronized 单操作原子(读-改-写竞态已文档化接受) |
核心设计与实现
架构全景
┌─────────────────────────────────────────────┐
│ ReplLoop (主) │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ IdleLoop │ │ TaskStore (s12) │ │
│ │ (后台协程) │──▶│ (磁盘持久化) │ │
│ └──────┬───────┘ └─────────▲──────────┘ │
│ 仅主空闲时 tick │ update 状态 │
│ 通知 → s13 Queue │ │
└─────────┼─────────────────────┼──────────────┘
│ AutoClaimer.tick() │
│ ①完成检测 ②就绪认领 │
┌──────────────────────────┼─────────────────────┼──────────┐
│ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ TeamStore │ │ MessageBus│ │
│ │ (s15 IDLE │ │ (s15 路由) │ │
│ │ 成员查询) │ └─────┬─────┘ │
│ └─────┬─────┘ │ send │
│ │ status │ task 消息 │
│ ┌────────▼────────┐ │ │
│ │ researcher(IDLE)│◄────────────┘ │
│ │ 消费协程 resume │ "Autonomous task t1: …" │
│ └─────────────────┘ RUNNING→IDLE │
└───────────────────────────────────────────────────────────────┘模块依赖(新增 autonomous 包):
autonomous → task(TaskScheduler/TaskStore)、autonomous → team(TeamStore/MessageBus/TeamMessage)、autonomous → background(NotificationQueue)repl → autonomous:ReplLoop 创建/启动,AutoCommand 控制- 不依赖 agent 包:自治层只操作 store 与消息总线,不直接碰 AgentLoop
新文件一览(src/main/kotlin/com/sepcai/code/autonomous/ 4 个 + repl/commands/AutoCommand.kt):
| 文件 | 职责 |
|---|---|
AutonomousConfig.kt | tick 间隔、每 tick 认领上限 + DEFAULT_ 常量 |
ClaimTracker.kt | taskId→teammate 认领映射(ConcurrentHashMap 线程安全) |
AutoClaimer.kt | tick():完成检测 + 就绪认领 + 派发,返回 ClaimResult |
IdleLoop.kt | 后台周期 tick + shouldRun 门控 + setEnabled 开关 + 通知 + 计数 |
repl/commands/AutoCommand.kt | /auto status/on/off/tasks |
修改文件:TaskStore.kt(@Synchronized,独立 refactor(s12) 提交)、ReplLoop.kt(busy 门控 + autoScope + 启动/清理)、ReplCommand.kt(ReplContext +autoClaimer/+idleLoop)、TaskToolTest.kt(flaky 修复,独立 test(s06) 提交)。
逐类拆解
AutoClaimer(自动认领)—— 自治核心
// AutoClaimer.kt(节选)
class AutoClaimer(
private val taskStore: TaskStore, // s12
private val teamStore: TeamStore, // s15
private val messageBus: MessageBus, // s15
private val tracker: ClaimTracker = ClaimTracker(),
private val config: AutonomousConfig = AutonomousConfig()
) {
fun tick(): ClaimResult {
val completed = mutableListOf<String>()
val claimed = mutableListOf<String>()
// ① 完成检测(先于②,避免本 tick 新建认领被误判完成)
for ((taskId, teammate) in tracker.snapshot()) {
when (teamStore.get(teammate)?.status) {
TeamStatus.IDLE -> {
taskStore.findById(taskId)?.let { task ->
taskStore.update(task.copy(status = TaskStatus.COMPLETED))
completed.add(taskId)
}
tracker.release(taskId)
}
TeamStatus.RUNNING -> {} // 仍在处理
else -> { // 队友被停/失败 -> 任务回 PENDING 可重认领
taskStore.findById(taskId)?.let {
taskStore.update(it.copy(status = TaskStatus.PENDING))
}
tracker.release(taskId)
}
}
}
// ② 就绪认领:就绪 PENDING 任务 → 空闲且无认领的队友
val ready = TaskScheduler.readyTasks(taskStore.loadAll())
.filter { it.status == TaskStatus.PENDING }
.take(config.maxTasksPerTick)
val idle = teamStore.all().filter {
it.status == TeamStatus.IDLE && !tracker.hasPendingAssignment(it.name)
}
for ((task, teammate) in ready.zip(idle)) {
tracker.assign(task.id, teammate.name)
taskStore.update(task.copy(status = TaskStatus.IN_PROGRESS))
messageBus.send(TeamMessage("main", teammate.name, formatTask(task), "task ${task.id}"))
claimed.add(task.id)
}
return ClaimResult(claimed, completed)
}
}关键设计点:
-
完成检测基于 IDLE 状态推断。teammate 消费协程处理任务消息(resume 路径)→ RUNNING → IDLE。AutoClaimer 观察到「已分配队友 + 队友 IDLE」即判定任务完成。teammate 不需要知道任务是 TaskRecord——它只当普通消息处理。这是"零协议成本"的自治:复用 s15 的消费循环,不加任何新消息类型。
-
tick 顺序防误判。① 完成检测在 ② 认领之前执行。本 tick 新建的认领(② 中 assign)不在 ① 的遍历快照里,绝不会在同一 tick 被误判完成。teammate 处理消息的时间远小于 tick 间隔(10s),下一 tick 时 IDLE 即已完成。
-
hasPendingAssignment防重复派发。步骤② 的空闲队友过滤排除已有认领的——一个 teammate 每个 tick 至多接一个任务(zip 配对天然保证)。 -
任务回滚。队友被停止/失败(终态)时,任务回 PENDING 可被重新认领——自治系统对队友死亡有弹性。
完成检测的正确性论证
IDLE 推断完成依赖一个关键前提:已认领任务的队友回到 IDLE 必然意味着处理完该任务。论证:
- 队友被认领时是 IDLE(步骤②只选 IDLE 成员)。
- 认领后消息立即入队:
messageBus.send把任务消息投递到队友 inbox(Channel 不丢消息)。 - 队友消费协程收到消息 → RUNNING:
markRunning(IDLE→RUNNING)→loop.run(task)→markIdle(RUNNING→IDLE)。 - 因此「队友 IDLE + 有认领」只有两种可能:刚认领未开始(本 tick 内),或处理完刚回到 IDLE。
tick 顺序消除歧义:① 完成检测在 ② 认领之前——本 tick 新建的认领(②)不在 ① 的遍历快照里,不会在同一 tick 被判定完成。所以「有认领 + IDLE」在①中出现时,必然来自上一 tick 或更早的认领,即队友已处理完。配合 tick 间隔(10s)远大于处理时间,判定可靠。
边界:队友被停止(终态)时 IDLE 推断失效——此时队友状态是 COMPLETED/FAILED,走 else 分支把任务回 PENDING。三种队友状态(IDLE/RUNNING/终态)覆盖所有情况,没有遗漏。
ClaimTracker(认领映射)
// ClaimTracker.kt —— 线程安全(ConcurrentHashMap)
class ClaimTracker {
private val assignments = ConcurrentHashMap<String, String>()
fun assign(taskId: String, teammate: String)
fun release(taskId: String)
fun teammateFor(taskId: String): String?
fun hasPendingAssignment(teammate: String): Boolean // 防重复派发
fun snapshot(): Map<String, String> // 遍历用拷贝
}IdleLoop(空闲循环)
// IdleLoop.kt(节选)
class IdleLoop(
private val autoClaimer: AutoClaimer,
private val notifications: NotificationQueue, // s13
private val config: AutonomousConfig = AutonomousConfig(),
private val delayMillis: Long = config.tickIntervalMillis,
private val shouldRun: () -> Boolean = { true } // 主 REPL 空闲门控
) {
fun start(scope: CoroutineScope) {
if (job?.isActive == true) return
job = scope.launch {
while (isActive) {
if (enabled.get() && shouldRun()) {
val result = autoClaimer.tick()
result.claimed.forEach { id ->
claimedCount.incrementAndGet()
notifications.push(BackgroundNotification(id, BackgroundStatus.RUNNING, "autonomous task $id claimed"))
}
result.completed.forEach { id ->
completedCount.incrementAndGet()
notifications.push(BackgroundNotification(id, BackgroundStatus.COMPLETED, "autonomous task $id completed"))
}
}
delay(delayMillis)
}
}
}
fun setEnabled(enabled: Boolean) // /auto on|off
fun isEnabled(): Boolean
fun stop() / isRunning() / claimedCount() / completedCount() / tickIntervalMillis()
}两层门控:enabled(/auto 开关,用户显式控制)与 shouldRun(主 REPL 空闲门控,ReplLoop 注入 { !busy })。协程生命周期由 ReplLoop 管理(start/stop),自治活动由 enabled 开关控制——两者解耦:/auto off 不杀协程,只让循环空转。
AutoCommand
// AutoCommand.kt(节选)
override suspend fun execute(args: String, ctx: ReplContext): ReplCommandResult {
return when (args.trim()) {
"", "status" -> status(ctx)
"on" -> { ctx.idleLoop.setEnabled(true); println("Autonomous agents: ON"); Continue }
"off" -> { ctx.idleLoop.setEnabled(false); println("Autonomous agents: OFF"); Continue }
"tasks" -> tasks(ctx)
else -> { println("Unknown subcommand"); println(USAGE); Continue }
}
}
private fun status(ctx: ReplContext): ReplCommandResult {
val state = if (ctx.idleLoop.isEnabled()) "ON" else "OFF"
println("Autonomous agents: $state (tick every ${ctx.idleLoop.tickIntervalMillis() / 1000}s)")
println(" claimed: ${ctx.idleLoop.claimedCount()} completed: ${ctx.idleLoop.completedCount()}")
val ready = TaskScheduler.readyTasks(ctx.taskStore.loadAll()).filter { it.status == TaskStatus.PENDING }
if (ready.isNotEmpty()) println(" ${ready.size} ready task(s) awaiting claim -- /auto tasks")
return ReplCommandResult.Continue
}
private fun tasks(ctx: ReplContext): ReplCommandResult {
val ready = TaskScheduler.readyTasks(ctx.taskStore.loadAll()).filter { it.status == TaskStatus.PENDING }
val claims = ctx.autoClaimer.claims()
if (ready.isEmpty() && claims.isEmpty()) { println("No ready tasks or active claims."); return Continue }
ready.forEach { println(" ${it.id} ${it.title}") }
claims.forEach { (taskId, teammate) -> println(" $taskId -> $teammate") }
return ReplCommandResult.Continue
}子命令:/auto(状态)/on/off(开关)/tasks(就绪任务 + 认领映射)。
接线细节:ReplLoop
// ReplLoop.kt
private val busy = AtomicBoolean(false) // s17: 主 REPL 忙态门控
private lateinit var autoClaimer: AutoClaimer
private lateinit var idleLoop: IdleLoop
private lateinit var autoScope: CoroutineScope// ReplLoop.start() 内,s15 team 设置之后:
autoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
autoClaimer = AutoClaimer(taskStore, teamStore, messageBus) // 复用 s12/s15 的 store
idleLoop = IdleLoop(
autoClaimer = autoClaimer,
notifications = notificationQueue, // s13 通知队列
shouldRun = { !busy.get() } // 主 REPL 空闲门控
)
idleLoop.start(autoScope)busy 门控(主循环内):
busy.set(true)
try {
when {
input.startsWith(COMMAND_PREFIX) -> { val c = handleCommand(input); if (!c) break }
else -> handleChat(input)
}
} finally {
busy.set(false)
}退出清理(finally):idleLoop.stop() + autoScope.cancel()。
ReplContext +2 字段:autoClaimer(/auto tasks 读认领)、idleLoop(/auto status/on/off)。测试支持 TeamCtxFields 同步 +2。
错误处理
| 失败场景 | 行为 |
|---|---|
| 无就绪任务 | tick 的认领为空(no-op) |
| 无空闲队友 | 就绪任务等待,不派发(不自治 spawn) |
| 队友被停/失败(有认领) | 任务回 PENDING,可被重新认领 |
| 任务已删除(有认领) | 释放认领,跳过 |
| /auto 重复 on/off | setEnabled 幂等 |
| IdleLoop 协程重复 start | no-op(已有活跃任务) |
| 会话退出 | idleLoop.stop + autoScope.cancel |
与 s12/s15 的衔接
- s12 → s17:
TaskScheduler.readyTasks是就绪任务的事实来源;TaskStore是任务的单一存储。s17 只读任务状态 + 更新状态,不新增任务。 - s15 → s17:
TeamStore的IDLE状态是"可认领"的信号(s15 设计IDLE时就预留了);MessageBus承担任务消息路由;teammate 消费协程的 resume 路径零改动。 - s16 → s17:s16 的关机握手与 s17 的自治正交但协作——自治派发的任务可能被关机打断(队友终态 → 任务回 PENDING)。
与 s12/s15/s16 的对比总览
| 维度 | s12 TaskSystem | s15 Agent Teams | s16 Team Protocols | s17 Autonomous |
|---|---|---|---|---|
| 核心 | 任务持久化 + 依赖调度 | 命名 teammate + 消息 | 关机握手 + 计划审批 | 空闲循环 + 自动认领 |
| 角色 | 任务队列 | 执行者池 | 协议层 | 调度桥 |
| 数据 | TaskRecord (磁盘) | TeamMember (内存) | ShutdownStatus/PlanProposal (内存) | ClaimTracker (内存) |
| 驱动 | 用户 /task | 用户/LLM 消息 | 主回合间 drain | 后台周期 tick |
| 时间维度 | 跨会话 | 会话内 | 会话内 | 会话内(用户不在场也跑) |
| 与用户交互 | /task 命令 | /team 命令 | /team plans/stop | /auto 命令 |
| 状态机 | PENDING→IN_PROGRESS→COMPLETED | PENDING→RUNNING↔IDLE | REQUESTED→ACK→COMPLETED | (复用前两者) |
| 依赖 | 独立 | → subagent, task | → team (新增) | → task, team, background |
定位:s17 是调度桥——把 s12 的任务队列(何时做)与 s15 的执行者池(谁来做的空闲成员)连接起来,在 s16 协议的保护下自动运转。它是阶段四里第一个"让系统自己动起来"的索引。
端到端:任务自治生命周期
Step 1 用户 /task add t1 "调研符号表缓存" + /team spawn 研究员
│ TaskStore.save(t1) → PENDING;teammate 消费协程跑完初始 prompt → IDLE
▼
Step 2 IdleLoop 后台 tick(主 REPL 空闲,busy=false,enabled=true)
│ AutoClaimer.tick()(AutoClaimer.kt:52)
│ ① 完成检测:无认领 → 跳过
│ ② 就绪认领:t1(PENDING) → researcher(IDLE)
│ ├─ tracker.assign("t1", "researcher")(ClaimTracker.kt:28)
│ ├─ taskStore.update(t1 → IN_PROGRESS)
│ └─ messageBus.send(TeamMessage("main", "researcher", "Autonomous task t1: …"))
│ 通知:🔔 auto - task t1 claimed
▼
Step 3 researcher 消费协程收到任务消息(resume)
│ markRunning → loop.run("Autonomous task t1: 调研符号表缓存…") → markIdle
│ researcher 处理完回到 IDLE
▼
Step 4 下一次 IdleLoop tick
│ AutoClaimer.tick()
│ ① 完成检测:t1→researcher,researcher 现在 IDLE → t1 COMPLETED
│ ├─ taskStore.update(t1 → COMPLETED)
│ └─ tracker.release("t1")
│ 通知:🔔 auto - task t1 completed
▼
Step 5 用户回来输入任意字符 → 回合间 drainNotifications()
│ 🔔 auto - task t1 claimed by researcher
│ 🔔 auto - task t1 completed by researcher
▼
Step 6 用户 /task list → t1 [COMPLETED];/auto → ON, claimed: 1 completed: 1这个生命周期把 s12(任务)、s15(团队)、s13(通知)三个系统串成一条自治链:任务就绪 → 自动认领 → 队友处理 → 自动完成 → 用户感知。中间没有任何手动步骤。
会话与线程安全
s17 首次引入后台线程写 TaskStore(IdleLoop 在 Dispatchers.Default 上 tick,/task 命令在主线程)。三个层面的处理:
- TaskStore @Synchronized(refactor(s12)):全部公开方法原子化。这是最小改造——TeamStore/MessageBus/NotificationQueue 已是 ConcurrentHashMap/CLQ 线程安全,无需改。
- 已知局限(文档化):跨操作的读-改-写(loadAll 后 update)在并发时理论上有 lost-update 窗口。AutoClaimer 只更新自己刚读到的任务,/task 通常操作不同任务——实际风险极低,接受。
- busy 门控:主 REPL 处理回合时 IdleLoop 跳过 tick,减少主线程与后台对同一批 store 的并发窗口。
会话退出:finally 里 idleLoop.stop() + autoScope.cancel()——与 s13/s14/s15 的 scope 清理一致。
测试策略
| 测试类 | 覆盖点 |
|---|---|
AutonomousConfigTest (2) | 默认值、自定义值、tickIntervalMillis |
ClaimTrackerTest (7) | assign/release/teammateFor、覆盖、hasPendingAssignment、快照独立 |
AutoClaimerTest (9) | 认领就绪任务+派发、队友 IDLE→COMPLETED、同 tick 不误判完成、无就绪/无空闲不动作、单队友单任务、队友终态→回 PENDING、maxTasksPerTick、跳过 BLOCKED |
IdleLoopTest (5) | 周期 tick 认领、通知推送、shouldRun=false 跳过、start 幂等、stop 停止 |
AutoCommandTest (6) | /auto 状态/on/off/tasks、空提示、未知子命令 |
TaskToolTest 修复 | BlockingLlm 使并发上限测试确定性(独立 test(s06) 提交) |
测试设计坑(记录如下):
-
完成检测的 tick 时序:认领任务后,同一 tick 不能误判完成(① 先于 ②)。测试断言「tick 1 只 claim、teammate 走 RUNNING→IDLE 后 tick 2 才 complete」——用模拟的 teamStore 状态转换驱动,不启动真实消费协程。
-
通知在 tick() 返回后才推送:
awaitCondition { taskStore.status == IN_PROGRESS }可能早于通知 push。修复:等notifications.hasPending()而非仅等任务状态。 -
stop 与 in-flight tick 的竞态:stop() 不中断已开始的 tick。测试等
claimedCount() >= 1(tick 的 forEach 完成)后再断言计数稳定,而非读瞬时值。 -
poll() 两次取空(AutoClaimerTest 初版踩坑):连续两次
inbox.poll()第一次消费、第二次 null。改为val msg = poll()!!再断言两个 content。 -
顺带修复 pre-existing flaky:
TaskToolTest并发上限测试依赖"第一个子代理仍 RUNNING",快速 FakeLLM 会提前完成导致 flaky。用BlockingLlm(awaitCancellation())占住名额,独立 test(s06) 提交。
开发过程记录
设计矛盾 1:完成检测——IDLE 推断 vs 显式完成协议
初版考虑给 teammate 加"任务完成"信号(teammate 处理完任务消息后发一条 DONE 消息回 main)。审查发现:
- 加显式协议需要 teammate 感知"这是任务消息"(区分普通消息与任务消息),污染消费协程。
- 而 teammate 处理消息后自然回到 IDLE 已经是事实——IDLE 状态本身就是完成信号。
取舍:用 IDLE 状态推断完成,消费循环零改动。代价是推断是"近似"的(IDLE 可能表示"处理完"或"刚派生完"),但结合认领映射(tracker 记录"这个任务分配给了这个队友"),推断是可靠的——只有被分配了任务的队友回到 IDLE 才判完成。
设计矛盾 2:空闲循环真并发 vs 回合间 drain
s15/s16 的自治活动都是回合间 drain(同步、主线程)。IdleLoop 若也做成 drain,就只是"每次用户输入后跑一次认领"——用户不在时系统仍停摆,不叫自治。
取舍:IdleLoop 是真后台协程,用户不在时也 tick。代价是引入并发写 TaskStore 的线程安全问题(@Synchronized 解决)+ busy 门控(避免与主 AgentLoop 抢注意力)。这是"自治"价值的必然成本。
设计矛盾 3:enabled 开关与协程生命周期解耦
/auto off 应该做什么?两个选择:
- 杀掉 IdleLoop 协程(stop)——但协程生命周期归 ReplLoop 管,命令里没有 scope 引用。
- 只关自治活动(setEnabled)——协程常驻空转。
取舍:setEnabled 与 start/stop 解耦。/auto on|off 只切 enabled 开关,协程常驻(ReplLoop 启动/退出控制)。这样 AutoCommand 不需要持有 scope,且反复 on/off 无协程重建开销。
设计矛盾 4:自治不派生
用户离开前建了任务但忘了派生 teammate——AutoClaimer 要不要自动 spawn 队友来干活?
取舍:不自治 spawn。派生是创建新资源的动作(LLM 调用、工具隔离),自动 spawn 可能失控(无限派队友)。AutoClaimer 只认领现有 IDLE 成员;没有空闲队友时任务等待,并可在 /auto status 提示"有就绪任务待认领"。这是"自治执行"与"自主扩容"的边界——s17 只做前者。
IdleLoop 与 CronScheduler 的对比(两个后台 ticker)
s14 的 CronScheduler 也是后台周期循环——为什么 s17 不直接复用它的模式?对比:
| 维度 | CronScheduler (s14) | IdleLoop (s17) |
|---|---|---|
| 触发条件 | 按 cron 时间规则 | 按固定 tick 间隔 + 空闲门控 |
| 触发动作 | 派发 shell 命令 | 派发任务消息给 teammate |
| 状态来源 | CronStore(磁盘) | TaskStore(磁盘)+ TeamStore(内存) |
| 活动感知 | 通知 s13 Queue | 通知 s13 Queue |
| 有状态吗 | nextFireCache(下次触发缓存) | ClaimTracker(认领映射) |
| 是否可开关 | enabled 字段(任务级) | setEnabled(系统级,/auto) |
共同骨架:start(scope) 幂等、stop() 取消、while(isActive){ tick(); delay(interval) }。s13 确立的「后台协程 + 通知队列 + scope 生命周期」模式,在 s14(cron)和 s17(自治)被复用。区别在于 tick 的内容——cron 按时间规则触发 shell,IdleLoop 按就绪度派发任务。
关键取舍汇总
| 决策 | 选择 | 理由 |
|---|---|---|
| 完成检测 | IDLE 状态推断(无新协议) | teammate 不感知 TaskRecord;消费循环零改动 |
| IdleLoop 并发 | 后台协程 + busy 门控 | 真·空闲干活;忙时不影响主 AgentLoop |
| TaskStore 并发 | @Synchronized 单操作 | 最小改造;读-改-写竞态极低风险已文档化 |
| 认领上限 | maxTasksPerTick=3 | 防止一次 tick 把队友全塞满 |
| 不自治 spawn | 只认领现有 IDLE 成员 | 派生是主智能体职责;自主扩容失控风险 |
| /auto 开关 | setEnabled 与协程解耦 | 命令无需 scope;on/off 无重建开销 |
真实对话示例:用户离开,系统自治
把自治机制放进一个完整场景——用户建任务、派生队友、走开、回来验收:
# ── 用户离开前:建任务 + 派生队友 ──
> /task add t1 调研符号表缓存
Task created: t1
> /task add t2 实现哈希索引
Task created: t2
> /task add t3 跑基准测试
Task created: t3 ← t2 阻塞于 t1,t3 阻塞于 t2(依赖链)
> 派生一个研究员和一个审查员
🤖 spawn teammate 'researcher' ...
🤖 spawn teammate 'reviewer' ...
> /team
Team members (2):
researcher [IDLE]
reviewer [IDLE]
> /task list
Tasks (3):
t1 [PENDING] 调研符号表缓存 ← 就绪
t2 [PENDING] 实现哈希索引 ← BLOCKED by t1
t3 [PENDING] 跑基准测试 ← BLOCKED by t2
# ── 用户走开。IdleLoop 每 10s tick 一次 ──
# tick1: 就绪 t1 → researcher(IDLE) 认领
# tick2: researcher 已 IDLE(处理完 t1)→ t1 COMPLETED;t2 就绪 → reviewer 认领
# tick3: reviewer 完成 t2 → t2 COMPLETED;t3 就绪 → researcher 认领
# tick4: researcher 完成 t3 → t3 COMPLETED
# ── 用户回来,输入任意字符,回合间 drain ──
> 今天进展如何?
🔔 auto - task t1 claimed by researcher
🔔 auto - task t1 completed by researcher
🔔 auto - task t2 claimed by reviewer
🔔 auto - task t2 completed by reviewer
🔔 auto - task t3 claimed by researcher
🔔 auto - task t3 completed by researcher
[Agent] 三号任务全部完成。依赖链 t1→t2→t3 已按顺序自动执行。
# ── 用户验收 ──
> /task list
Tasks (3):
t1 [COMPLETED] 调研符号表缓存
t2 [COMPLETED] 实现哈希索引
t3 [COMPLETED] 跑基准测试
> /auto
Autonomous agents: ON (tick every 10s)
claimed: 3 completed: 3
> /auto off
Autonomous agents: OFF这个示例展示了 s17 与 s12 的依赖调度联动:t1→t2→t3 的依赖链由 TaskScheduler.readyTasks 保证——每个 tick 只有就绪任务被认领,完成一个才解锁下一个。依赖感知 + 自治认领合起来,让整个任务流水线在用户离开时自动跑完。这是 s12 依赖系统 + s17 自治层的组合价值。
改动足迹与增量统计
新增文件(git status 视角):
autonomous/ (4 个新文件)
├── AutonomousConfig.kt # tick 间隔/上限配置
├── ClaimTracker.kt # 认领映射(taskId→teammate)
├── AutoClaimer.kt # tick():完成检测 + 就绪认领
└── IdleLoop.kt # 后台周期 tick + 开关 + 通知
repl/commands/AutoCommand.kt # /auto 控制命令修改文件(2 个 main + 7 个 test):
main: ReplLoop.kt(busy 门控/autoScope/启动清理)、ReplCommand.kt(ReplContext +2)
test: AutoClaimerTest(+9)、IdleLoopTest(+5)、AutoCommandTest(+6)、
ClaimTrackerTest(+7)、AutonomousConfigTest(+2)、
TestTeamSupport(+2 字段)、6 个命令测试 ReplContext 补字段独立提交(本次顺带):refactor(s12) TaskStore @Synchronized(s17 并发访问前提)、test(s06) TaskToolTest flaky 修复(BlockingLlm 确定性)。
测试增量:s17 新增 ~29 个用例(s16 结束约 646,s17 结束约 675)。全量 ./gradlew test 绿。
本次实现的实际成本:核心自治逻辑(AutoClaimer + IdleLoop)约半天;接线(ReplContext +2 字段扩散到 6 个命令测试 + ReplLoop busy 门控)占另一半。ReplContext 字段已 18 个——s20 Comprehensive Agent 前必须聚合(已写入博客"下一站"提醒)。
常见问题
Q:用户走开时任务自动被队友认领,会不会失控?
不会。三个保护:① 只认领现有 IDLE 队友,不自治 spawn;② maxTasksPerTick=3 限制单次 tick 派发量;③ enabled 开关(/auto off)可随时停。此外任务有依赖(blockedBy),只有就绪任务会被认领,依赖链天然节流。
Q:teammate 怎么知道自己在做任务?
它不知道。任务消息只是普通的 TeamMessage(MESSAGE kind),teammate 当"主智能体给它发的一条消息"处理。这是刻意的——自治层的认领逻辑完全在系统侧,teammate 不需要感知 TaskRecord。代价是 teammate 无法区分"普通指令"和"自治任务",但这对执行没有影响。
Q:自治任务的结果谁看?
teammate 处理任务消息后的回复(如有)走 send_message 到 main 收件箱,回合间 drain 展示。任务的 COMPLETED 状态在 /task list 可见,认领/完成活动在 /auto 可见。结果与状态双通道可观测。
Q:和 s06 的 TaskTool(子代理)什么关系?
s06 task 是主智能体手动派发匿名子代理(任务级)。s17 AutoClaimer 是自动把 s12 持久任务派发给 s15 命名 teammate(队列级)。两者正交:手动派发即时子任务用 s06,自动处理持久任务队列用 s17。
下一站
s17 为阶段四后续 Index 留下什么基础:
- s18 Worktree Isolation — 自治派发的任务消息是"把任务交给 teammate"的载体,s18 可让 AutoClaimer 在派发时同时为任务创建 worktree 隔离。
- s19 MCP Plugin — 自治任务可能触发 teammate 调用 MCP 工具;IdleLoop 的活动通知机制可扩展到 MCP 工具结果。
- s20 Comprehensive Agent — IdleLoop 的"后台周期 tick"将成为"全部机制归到一个循环"的运行骨架;主 inbox 自动续跑(s17 明确未做)也在这里补上。
未完事项:主 inbox 的自动续跑(teammate 消息自动注入主 AgentLoop)与同步 REPL 结构冲突,留 s20;自治任务没有并发上限保护(任务消息可能塞满队友 inbox);权限/计划审批仍需用户输入,无法完全自治。


