目标
s06 的子智能体解决了"主智能体派发一个推理子任务后继续工作"的问题,但有一类工作它处理不了:长跑的 shell 进程。./gradlew test 跑 40 秒、npm run dev 一直不退出、find / -name '*.log' 扫描半分钟--这些不是"派给一个 LLM 去推理"的任务,而是"启动一个进程、等它跑完、顺便看看输出"。让 BashTool 同步等 40 秒会冻住整个对话;用子智能体去包裹一个 shell 命令更是杀鸡用牛刀。
Index 13 引入后台 shell 执行 + 完成通知机制,对齐 Claude Code 的 Bash run_in_background / TaskOutput / TaskStop:
BackgroundRunner-- 在协程里派发sh -c进程,流式收集输出,状态机驱动生命周期NotificationQueue-- 线程安全的通知队列,异步完成在回合间被同步感知- 三个 LLM 工具 --
background_bash(派发)、background_output(查状态/读输出)、background_stop(取消) /bg命令 -- REPL 里手动管理后台任务- 回合间 drain -- 每轮对话后把积压的完成通知打印出来
完成后的效果--长跑命令不再阻塞对话,且完成时自动被告知:
> 帮我跑一下全量测试,顺便看看 src/main 里有没有 TODO
[Agent] 我把测试放到后台跑,同时帮你搜 TODO。
[Tool] background_bash(command="./gradlew test")
Background task started: bg-1
[Tool] bash(command="grep -rn TODO src/main")
src/main/kotlin/.../AgentLoop.kt:42 // TODO s11 streaming
[Agent] 后台测试已启动(bg-1),TODO 找到 1 处。测试跑完我会告诉你。
> /bg
Background tasks (1):
bg-1 [RUNNING] ./gradlew test
> /bg output bg-1
=== bg-1 (RUNNING) ===
> Task :compileKotlin
> Task :compileTestKotlin
> Task :test
🔔 bg-1 - completed (exit code 0) ← 回合间自动 drain 出来的通知
> 测试通过了吗?
[Agent] 通过了(exit code 0)。为什么需要
现实问题
s02 的 BashTool 是同步阻塞的:process.waitFor(120s) 卡住协程,期间智能体既不能推理也不能调别的工具。这在三种场景下很难受:
- 长跑命令 --
./gradlew test、docker build、pytest动辄几十秒到几分钟。同步等 = 对话冻结,LLM 的工具调用超时(120s)还可能强杀进程 - 常驻进程 --
npm run dev、python -m http.server根本不会退出。BashTool的waitFor会一直等到超时再destroyForcibly,等于白等 120 秒 - 可监控的进度 -- 想在跑测试时同时搜代码,得开两个终端。智能体在单线程 REPL 里做不到"一边等一边干别的"
而依赖图表明,s13 是阶段三的执行层基石:
s05 TodoWrite -> s12 TaskSystem -> s13 BackgroundTasks -> s14 CronScheduler
↘ s17 AutonomousAgentss12 让任务持久化了,但"执行"这一步仍是空的--/task ready 列出能跑的任务,然后呢?s13 给出了"跑一个 shell 命令且不阻塞"的执行原语;s14 的定时器会触发命令、s17 的自治智能体会认领任务并派发到后台。没有 s13,s14/s17 的"执行"就只能同步阻塞。
与 s06 子智能体的分工
这是设计时最容易混淆的点--子智能体也是"后台派发 + 轮询查询"模型,为什么不能复用?因为两者执行的东西根本不同:
| 维度 | s06 Subagent | s13 BackgroundTask |
|---|---|---|
| 执行内容 | 一个 LLM 推理循环(多轮工具调用) | 一条 shell 命令(一个 OS 进程) |
| 返回值 | LLM 的最终文本回复 | 进程退出码 + stdout/stderr 输出 |
| 生命周期 | 协程内纯 Kotlin,无可控外部资源 | 绑定一个活进程,需 destroyForcibly 清理 |
| 进度可见 | 黑盒(只能问"好了没") | 流式输出可随时 tail |
| 持久化 | 内存态(会话结束丢) | 内存态(活进程无法跨会话恢复) |
| 取消方式 | 取消协程(协作式) | 强杀进程(destroyForcibly) |
子智能体是"派一个会思考的分身去干活";后台任务是"启动一个不会思考的进程并盯着它"。两者互补,不互相替代。
设计原则
- 内存态、不持久化 -- 后台任务代表活进程,进程无法跨会话恢复。这与 s12
TaskStore(持久化)形成刻意对比:s12 存的是"任务意图"(重启后还能继续),s13 存的是"运行实例"(重启即清理)。会话结束时backgroundScope.cancel()强杀所有遗留进程 - 状态机驱动 + 只从 RUNNING 转换 -- 借鉴 s06
SubagentStore的computeIfPresent校验,cancel 与自然结束的竞态由"先到者扣减计数、后到者 no-op"自然消解 - 流式输出 + 上限截断 -- 逐行读取进程输出并 append 到任务记录,运行中即可
background_output看进度;但yes/find /这类无限输出会撑爆内存,故设maxOutputChars上限,超出截断并置outputTruncated标志 - 通知队列而非 Channel/Flow -- 完成通知是"尽力展示"的副作用,不参与控制流。用
ConcurrentLinkedQueue+ 回合间主动drain最简单;实时打断式提醒留给后续 Index - 严格迭代 -- 不做输出落盘、不做实时通知打断、不做跨会话进程托管;这些留给 s17/s20 按需扩展
核心设计与实现
架构全景
┌─────────────────────────────────────────────────────┐
│ ReplLoop.start() │
│ backgroundScope = SupervisorJob + Dispatchers.IO │
│ notificationQueue = NotificationQueue() │
│ backgroundRunner = BackgroundRunner(scope, queue) │
└────────────────┬────────────────────────────────────┘
│ 注册
┌────────────────────┼────────────────────┐
│ │ │
background_bash background_output background_stop
(派发→id) (查状态/读输出) (cancel 强杀)
│ │ │
└────────────────────┼────────────────────┘
▼
┌───────────────────────┐
│ BackgroundRunner │ ← 核心
│ ConcurrentHashMap<id, BackgroundTask>
│ ConcurrentHashMap<id, Process> (活进程引用)
│ AtomicInteger runningCount / idCounter
└───┬───────────────┬───┘
scope.launch│ │ cancel()
(每任务一协程) │ │ destroyForcibly
┌─────────────────▼─┐ ┌────▼──────────────────┐
│ sh -c <command> │ │ 仅从 RUNNING 转换 │
│ redirectErrorStream│ │ computeIfPresent 校验 │
│ 逐行 readLine │ │ → CANCELLED + notify │
│ → appendOutput │ └───────────────────────┘
│ waitFor → complete │
│ exit0→COMPLETED │
│ exit≠0→FAILED │
└─────────┬───────────┘
│ 终态时 push
▼
┌──────────────────┐
│ NotificationQueue│ ← ConcurrentLinkedQueue
│ push / drain │
└─────────┬────────┘
│ 回合间 drain
▼
ReplLoop.drainNotifications()
打印 "🔔 bg-1 - completed (exit code 0)"模块依赖(遵守 agent 包不依赖增强层的约束):
repl -> background, tool, agent, ...
background -> tool (Tool 接口), llm (无), kotlinx.coroutinesbackground 包只依赖 tool 接口(为了实现 LLM 工具),不依赖 agent/permission/hooks--它是可插拔的执行层。
核心模型:BackgroundStatus + BackgroundTask
// background/BackgroundStatus.kt
enum class BackgroundStatus { RUNNING, COMPLETED, FAILED, CANCELLED }状态机(KDoc 原文):
launch -> RUNNING -> { 自然结束(exit 0) -> COMPLETED
| 自然结束(exit≠0) -> FAILED
| cancel -> CANCELLED }两个刻意的设计点:
- 没有 PENDING/QUEUED -- 与 s12
TaskStatus(有 PENDING/BLOCKED)不同,后台任务一经launch立即 RUNNING(进程已启动)。并发上限在 launch 前检查,超限直接拒绝,不入队等待。因为"等一个空位再启动 shell"没有意义--要么现在能跑,要么告诉调用方稍后再试 - 没有 BLOCKED -- 后台任务无依赖概念(依赖是 s12 TaskSystem 的事),不存在"等别人完成才能跑"
// background/BackgroundTask.kt
data class BackgroundTask(
val id: String, // "bg-N",自增计数器生成
val command: String, // shell 命令原文
val status: BackgroundStatus = BackgroundStatus.RUNNING,
val output: String = "", // 累积输出,超 maxOutputChars 截断
val outputTruncated: Boolean = false,
val exitCode: Int? = null, // 仅终态有值
val startedAt: Long = System.currentTimeMillis(),
val finishedAt: Long? = null, // 仅终态有值
val error: String? = null // 仅异常 FAILED 有值;非零退出码用 exitCode 表达
)error 与 exitCode 的分工值得注意:非零退出码(如 exit 7)走 FAILED 但 error = null,用 exitCode = 7 表达失败原因;只有进程启动失败/协程异常这类"没拿到退出码"的情况才写 error。这让 background_output 能区分"命令自己失败了"和"执行框架失败了"。
线程安全:BackgroundTask 是不可变 data class。所有可变状态(状态推进、输出追加)由 BackgroundRunner 通过 ConcurrentHashMap.computeIfPresent 原子更新,调用方读到的始终是某一时刻的快照--这点直接借鉴 s06 SubagentStore。
BackgroundConfig:资源约束
// background/BackgroundConfig.kt
data class BackgroundConfig(
val maxConcurrent: Int = DEFAULT_MAX_CONCURRENT, // 5
val maxOutputChars: Int = DEFAULT_MAX_OUTPUT_CHARS // 64 * 1024
)maxConcurrent-- 并发上限,与 s06maxConcurrentSubagents对齐(默认 5)。超限launch返回Rejected,不排队maxOutputChars-- 单任务输出缓冲上限 64 KiB。yes/cat /dev/urandom/find /会产生无限输出,不设上限会把内存撑爆。超出后截断并置outputTruncated,调用方看到标志知道"有更多输出没保留"
maxConcurrent 由 AgentConfig.maxConcurrentBackground 注入(与 maxConcurrentSubagents 并列),保持配置入口统一。
BackgroundRunner:核心执行器
这是 s13 的算法核心。三类职责:派发、状态推进、取消。
派发:launch
// background/BackgroundRunner.kt:62
fun launch(command: String): BackgroundLaunchResult {
if (command.isBlank()) {
return BackgroundLaunchResult.Rejected("command must not be blank")
}
if (runningCount.get() >= config.maxConcurrent) {
return BackgroundLaunchResult.Rejected(
"max concurrent background tasks (${config.maxConcurrent}) reached; " +
"wait for existing tasks to finish or stop them"
)
}
val id = "bg-${idCounter.incrementAndGet()}"
val task = BackgroundTask(id = id, command = command)
tasks[id] = task
runningCount.incrementAndGet() // ← 同步递增,在 launch 协程外,避免竞态
logger.info { "Launched background task: $id (command='${command.take(80)}')" }
scope.launch { /* 见下 */ }
return BackgroundLaunchResult.Success(id)
}关键点:runningCount 在 scope.launch 之前同步递增。如果放在协程内部递增,launch 返回后协程可能还没调度,此时另一个 launch 调用读到的 runningCount 还是旧值,会突破上限。同步递增保证并发检查正确。
返回值用密封类 BackgroundLaunchResult(Success / Rejected)而非抛异常--拒绝是正常业务流程("并发满了,稍后再试"),不是错误,调用方应据此给 LLM 友好提示而非 stack trace。
执行协程:流式输出 + 自然结束
// background/BackgroundRunner.kt:80
scope.launch {
try {
val process = ProcessBuilder("sh", "-c", command)
.redirectErrorStream(true) // 合并 stderr 到 stdout,统一收集
.start()
processes[id] = process
// 流式读取:逐行 append,便于运行中 background_output 取看进度
val reader = process.inputStream.bufferedReader()
while (true) {
val line = reader.readLine() ?: break // 进程结束/被强杀 -> EOF
appendOutput(id, line)
}
val exitCode = process.waitFor()
// exit 0 -> COMPLETED,非 0 -> FAILED,由 complete 内部按 exitCode 区分
complete(id, exitCode)
} catch (e: CancellationException) {
fail(id, "coroutine cancelled")
throw e // 遵守结构化并发
} catch (e: Exception) {
fail(id, e.message ?: "Unknown error")
} finally {
processes.remove(id)
}
}四个细节:
redirectErrorStream(true)-- 合并 stderr 到 stdout,与 s02BashTool一致。后台任务只暴露一个输出流,简化模型- 逐行
readLine流式收集 -- 不等进程结束就 append,所以background_output在任务 RUNNING 时能看到"已经输出的部分"。这是后台执行相对于同步BashTool的核心增值 readLine() ?: break-- 进程自然结束或被destroyForcibly强杀都会让流 EOF,readLine返回 null,循环退出。cancel 和自然结束复用同一条输出收集路径CancellationException重新抛出 -- 与 s06TaskTool一致,遵守结构化并发:scope 取消时传播,不吞掉
为什么用 Dispatchers.IO:readLine 和 waitFor 都是阻塞调用,放在 IO 线程池(专为阻塞 I/O 设计)而非 Default(CPU 密集)。ReplLoop 为后台任务单独建 backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),与子智能体的 Default scope 隔离,互不干扰。SupervisorJob 保证单个任务失败不传播给其他任务。
状态推进:complete / fail(只从 RUNNING 转换)
// background/BackgroundRunner.kt:155
private fun complete(id: String, exitCode: Int) {
val status = if (exitCode == 0) BackgroundStatus.COMPLETED else BackgroundStatus.FAILED
tasks.computeIfPresent(id) { _, task ->
if (task.status == BackgroundStatus.RUNNING) {
runningCount.decrementAndGet()
task.copy(status = status, exitCode = exitCode, finishedAt = System.currentTimeMillis())
} else {
task // 已被 cancel 置 CANCELLED,no-op
}
}
val msg = if (exitCode == 0) "completed (exit code 0)" else "failed (exit code $exitCode)"
notify(id, status, msg)
}fail(异常路径)结构相同,只多写 error 字段。两者都遵循 s06 SubagentStore 的状态机纪律:computeIfPresent 内校验前置状态为 RUNNING 才转换并扣减 runningCount。
取消:cancel 与竞态消解
这是 s13 最微妙的地方。cancel 强杀进程后置 CANCELLED,但执行协程的 waitFor 随后也会返回(进程被杀了),它会调 complete--如果不加保护,CANCELLED 会被覆盖成 FAILED。
// background/BackgroundRunner.kt:130
fun cancel(id: String): Boolean {
processes[id]?.destroyForcibly() // 先杀进程
var cancelled = false
tasks.computeIfPresent(id) { _, task ->
if (task.status == BackgroundStatus.RUNNING) {
runningCount.decrementAndGet()
cancelled = true
task.copy(status = BackgroundStatus.CANCELLED, finishedAt = System.currentTimeMillis())
} else {
task // 已终态,no-op
}
}
if (cancelled) {
notify(id, BackgroundStatus.CANCELLED, "cancelled by user")
}
return cancelled
}竞态消解靠的是 complete/fail 的"只从 RUNNING 转换"校验:
场景:cancel 与自然结束同时发生
T1 cancel(): destroyForcibly → computeIfPresent(RUNNING→CANCELLED) → runningCount--
T2 协程: waitFor 返回(被杀的退出码) → complete() → computeIfPresent(已 CANCELLED, no-op)
结果:状态稳定为 CANCELLED,runningCount 精确扣减一次无论 T1/T2 谁先到,computeIfPresent 的状态校验保证只有先到者完成转换并扣减计数,后到者 no-op。这是 s06 SubagentStore.markCompleted/markFailed 同款手法--runningCount 永不重复扣减、状态永不被覆盖。
cancel 返回 Boolean:true 表示成功取消一个 RUNNING 任务,false 表示 id 不存在或已终态。调用方(BackgroundStopTool、/bg stop)据此给出"已取消"或"已终态,无需停止"的提示。
输出追加:appendOutput(截断保护)
// background/BackgroundRunner.kt:194
private fun appendOutput(id: String, line: String) {
tasks.computeIfPresent(id) { _, task ->
if (task.outputTruncated) {
task // 已截断,丢弃后续输出
} else {
val appended = task.output + line + "\n"
if (appended.length > config.maxOutputChars) {
task.copy(
output = appended.take(config.maxOutputChars),
outputTruncated = true
)
} else {
task.copy(output = appended)
}
}
}
}一旦 outputTruncated = true,后续所有 appendOutput 都是 no-op--避免无意义的大字符串拼接。background_output 看到 outputTruncated 会提示 "(output was truncated; showing tail)",让 LLM 知道输出不全。
NotificationQueue:通知队列
// background/NotificationQueue.kt
data class BackgroundNotification(
val taskId: String,
val status: BackgroundStatus,
val message: String, // "completed (exit code 0)"
val timestamp: Long = System.currentTimeMillis()
)
class NotificationQueue {
private val queue = ConcurrentLinkedQueue<BackgroundNotification>()
fun push(notification: BackgroundNotification) { queue.add(notification) }
fun drain(): List<BackgroundNotification> {
val result = mutableListOf<BackgroundNotification>()
while (true) {
val n = queue.poll() ?: break
result.add(n)
}
return result
}
fun hasPending(): Boolean = !queue.isEmpty()
}极简:ConcurrentLinkedQueue 无锁,多任务并发 push,REPL 主线程回合间一次 drain 取走全部。BackgroundRunner 在 complete/fail/cancel 终态时 notify -> push。
为什么不用 Channel/Flow:通知是"尽力展示"的副作用,不参与控制流(不需要背压、不需要取消传播)。用队列 + 主动 drain 最简单,也便于 /bg notifications 命令直接读取。实时中断式提醒(任务完成即打断用户输入)需要非阻塞读 + 终端控制,留给后续 Index。
三个 LLM 工具
对齐 Claude Code 的 Bash run_in_background / TaskOutput / TaskStop。工具名用常量(魔法值禁令):
| 工具 | name | isReadOnly | 作用 |
|---|---|---|---|
BackgroundBashTool | background_bash | false | 派发命令,立即返回 id |
BackgroundOutputTool | background_output | true | 查状态 + 读输出(可 tail_lines) |
BackgroundStopTool | background_stop | false | 取消运行中任务 |
isReadOnly 决定 s02 ToolRegistry 的并发策略:background_output 可与其他读工具并行;派发和取消有副作用需串行。
background_output 的状态分支返回(BackgroundOutputTool.kt):
val content = buildString {
appendLine("Task $taskId is ${task.status}.")
task.exitCode?.let { appendLine("Exit code: $it") }
task.error?.let { appendLine("Error: $it") }
if (task.outputTruncated) appendLine("(output was truncated; showing tail)")
appendLine()
appendLine("Output:")
if (output.isEmpty()) append("(none yet)") else append(output.trimEnd())
}
// FAILED(含非零退出码与异常)让 LLM 明确感知失败
val isError = task.status == BackgroundStatus.FAILEDFAILED 时 isError = true,让 LLM 在 tool_result 中明确感知失败(与 s06 QueryTaskTool 对 FAILED 的处理一致)。tail_lines 默认 50,传 0 返回全部(受 maxOutputChars 截断保护)。
权限模型:三个工具都注册进 toolRegistry,由 AgentLoop.onBeforeToolExecute 统一走 PermissionPipeline 审批。background_bash 的 command 参数会经过 DangerousCommandRule 等规则检查--后台执行不绕过权限,危险命令照样被拦。
/bg 命令
/bg 列出所有后台任务及状态
/bg list 同上
/bg show <id> 显示任务详情(command/status/exit/时间戳)
/bg output <id> [lines] 查看输出(默认尾部 20 行)
/bg stop <id> 取消运行中的任务
/bg notifications 显示并清空待处理完成通知/bg(空参)等价于 /bg list--最常用的"扫一眼后台在跑啥"零参数即可。命令实现直接消费 BackgroundRunner 的 listAll/get/cancel 和 NotificationQueue.drain,无额外状态。
单行简报格式:bg-1 [RUNNING] echo hello(命令超 50 字符截断加 ...,终态附 exit=N)。
ReplLoop 集成
三处接入(repl/ReplLoop.kt):
1. 构造 scope + runner + 注册工具(start() 内):
// repl/ReplLoop.kt:177
// s13: 后台任务执行(内存态 shell 进程 + 通知队列)
// 独立 scope + Dispatchers.IO:后台任务多为阻塞进程 I/O,与子智能体(Default)隔离
backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
notificationQueue = NotificationQueue()
backgroundRunner = BackgroundRunner(
scope = backgroundScope,
notifications = notificationQueue,
config = BackgroundConfig(maxConcurrent = config.maxConcurrentBackground)
)
toolRegistry.register(BackgroundBashTool(backgroundRunner))
toolRegistry.register(BackgroundOutputTool(backgroundRunner))
toolRegistry.register(BackgroundStopTool(backgroundRunner))backgroundRunner 与 notificationQueue 加入 ReplContext,供 /bg 命令访问:
// repl/ReplCommand.kt
data class ReplContext(
...,
val backgroundRunner: BackgroundRunner, // s13
val notificationQueue: NotificationQueue // s13
)2. 回合间 drain 通知:
// repl/ReplLoop.kt:251(主循环 when 块之后)
// s13: 回合间 drain 后台完成通知,让异步结束被同步感知
drainNotifications()// repl/ReplLoop.kt:326
private fun drainNotifications() {
val pending = notificationQueue.drain()
if (pending.isEmpty()) return
println()
for (n in pending) {
println("🔔 ${n.taskId} - ${n.message}")
}
println()
}每个 REPL 回合(对话或命令)结束后 drain 一次。后台任务在协程里异步结束,通知积压在队列,本方法一次性取出打印。
已知局限:readLine() 阻塞等待用户输入,期间到达的通知要等到下一次交互后才显示。用户空闲时可用 /bg notifications 主动查看,或 /bg 看实时状态。实时打断式提醒(通知到达即输出)需要非阻塞读 + 终端控制,留给后续 Index。
3. 退出时清理:
// repl/ReplLoop.kt:259(finally 块)
// s13: 取消后台任务 scope,强杀其下所有 shell 进程
backgroundScope.cancel()scope.cancel() 取消所有子协程,协程的 finally { processes.remove(id) } 执行,进程的 destroyForcibly 由 JVM 的 Process 清理机制兜底。会话结束 = 后台进程清零,无残留。
错误处理
| 失败场景 | 行为 | 谁处理 |
|---|---|---|
| 空命令 | launch 返回 Rejected("command must not be blank") | BackgroundRunner |
| 并发达上限 | launch 返回 Rejected("...concurrent limit reached") | BackgroundRunner |
| 进程非零退出 | complete 置 FAILED + exitCode,error = null,推送通知 | BackgroundRunner |
| 进程启动失败/异常 | fail 置 FAILED + error,推送通知 | BackgroundRunner |
| 输出超上限 | appendOutput 截断至 maxOutputChars,置 outputTruncated | BackgroundRunner |
| cancel 已终态任务 | cancel 返回 false,状态不变 | BackgroundRunner |
| cancel 未知 id | cancel 返回 false | BackgroundRunner |
| 查询未知 id | 工具返回 isError=true "task not found" | BackgroundOutputTool/BackgroundStopTool |
| 协程被 scope 取消 | fail("coroutine cancelled") 后重新抛 CancellationException | BackgroundRunner |
| 会话退出 | backgroundScope.cancel() 清理所有协程/进程 | ReplLoop |
端到端流程
一次完整的后台任务生命周期(LLM 驱动 + 通知):
用户: "帮我跑全量测试,跑完告诉我结果"
AgentLoop 推理 → 调用 background_bash
[Tool] background_bash(command="./gradlew test")
└─ BackgroundRunner.launch:
├─ runningCount(0) < 5 ✓
├─ id = bg-1, task = RUNNING, runningCount → 1
├─ scope.launch { sh -c "./gradlew test"; 流式收集; complete }
└─ 返回 "Background task started: bg-1"
[Agent] 测试在后台跑(bg-1),跑完我会告诉你。
主智能体继续推理 → 可能调别的工具(grep TODO 等)
...对话继续,不阻塞...
后台协程(并发):
sh -c "./gradlew test" 输出逐行 append 到 bg-1.output
...40 秒后...
process.waitFor() = 0
complete("bg-1", 0):
├─ computeIfPresent: RUNNING → COMPLETED, exitCode=0, runningCount → 0
└─ notify → queue.push(COMPLETED, "completed (exit code 0)")
下一轮 REPL 回合结束 → drainNotifications():
🔔 bg-1 - completed (exit code 0) ← 用户看到通知
用户: "测试结果怎么样?"
AgentLoop 推理 → 调用 background_output 拿完整输出
[Tool] background_output(task_id="bg-1")
└─ 返回 "Task bg-1 is COMPLETED. Exit code: 0. Output: > Task :test BUILD SUCCESSFUL..."
[Agent] 测试全部通过(exit code 0)。取消一个常驻进程:
用户: "启动 dev server"
[Tool] background_bash(command="npm run dev")
Background task started: bg-2
...server 一直跑,不退出...
用户: "/bg output bg-2" ← 看进度
=== bg-2 (RUNNING) ===
VITE v5.0.0 ready in 312 ms
➜ Local: http://localhost:5173/
用户: "好了,停掉吧"
[Tool] background_stop(task_id="bg-2")
└─ cancel: destroyForcibly → CANCELLED, runningCount → 0
推送 CANCELLED 通知
[Agent] 已停止 bg-2。
🔔 bg-2 - cancelled by user测试策略
| 测试类 | 覆盖点 |
|---|---|
NotificationQueueTest | push/drain 顺序保留、drain 清空、空队列 drain、hasPending 状态、100 并发 push 全部 drain |
BackgroundConfigTest | 默认值(5 / 64KiB)、自定义覆盖 |
BackgroundRunnerTest | launch 返回 bg-1、空命令 Rejected、echo→COMPLETED+exit0+output、非零退出→FAILED+exitCode、cancel RUNNING→CANCELLED+计数、cancel 未知 id→false、cancel 已完成→false、并发上限 Rejected、流式输出增量、输出截断+flag、listAll 按 id 排序、get 未知→null、完成推送 COMPLETED 通知、取消推送 CANCELLED 通知 |
BackgroundBashToolTest | 缺 command 参数报错、派发返回 id+用法提示、并发上限报错 |
BackgroundOutputToolTest | 缺 task_id 报错、未知 id 报错、COMPLETED 返回输出非 error、FAILED isError=true+exit code、RUNNING 报状态、tail_lines 限行 |
BackgroundStopToolTest | 缺 task_id 报错、未知 id 报错、停 RUNNING 成功、停已完成 no-op 非 error |
BackgroundCommandTest | 空 list 提示、list 显示任务、show 详情、show 未知、output、stop 成功、stop 已完成、notifications drain+清空、notifications 空、未知子命令用法、空参等价 list |
测试设计要点:
- 轮询而非固定 sleep --
awaitTerminal帮助以 20ms 间隔轮询直到终态(5s 超时)。echo这类毫秒级任务用固定 sleep 会要么浪费时间要么 flaky;轮询让测试在任务完成的瞬间就断言。s12 的loadAll排序测试就是因依赖同毫秒巧合而 flaky(1/5 次失败),s13 全程轮询规避 - 用真实进程验证 --
echo/exit 7/sleep 30/printf都是真实 shell 命令,验证流式收集、退出码、取消、截断的真实行为,不 mockProcessBuilder RunnerFixture(AutoCloseable) -- 工具测试用 fixture 封装 scope+runner,use { }块结束自动scope.cancel(),避免协程/进程泄漏跨测试- 竞态测试用
sleep 30-- cancel/并发上限测试用足够长的sleep 30确保任务在断言时仍 RUNNING,消除时序依赖 - 5× 重复运行验证稳定性 --
BackgroundRunnerTest(含流式/完成/取消时序)重复跑 5 次 + 全 suite 3 次,0 失败,确认无 flaky
开发过程:设计取舍与踩坑记录
s13 的实现相对 s12 顺畅(s12 的状态机 + ConcurrentHashMap 范式直接复用),但仍有几个值得记录的决策点:
设计阶段的关键取舍:
- 后台任务不持久化 -- 最初纠结是否要像 s12 那样把后台任务写磁盘,以便"重启后还能看到上次跑了啥"。最终否定:后台任务代表活进程,进程无法跨会话恢复,持久化一个"已经死了的进程记录"没意义。会话结束即清理,s12 的持久化留给"任务意图"(重启后能继续的任务)。这个分工让 s12/s13 职责清晰:s12 管"要做什么"(持久),s13 管"正在做什么"(瞬时)
error与exitCode分工 -- 最初想把所有失败都写error。但非零退出码(exit 7)不是"框架错误",是"命令自己失败了",用exitCode表达更准确,且让background_output能区分两类失败。最终:非零退出码error=null+exitCode=N;进程启动失败/异常error=msg+exitCode=null- 通知队列而非 Channel -- 考虑过用
Channel或SharedFlow把完成事件推给 REPL。但通知是"尽力展示"的副作用,用 Channel 引入背压/取消传播复杂度,且/bg notifications命令难以直接读 Channel。ConcurrentLinkedQueue+ 主动 drain 最简单
实现中的小坑:
runningCount必须在scope.launch前同步递增 -- 一开始想把它放在协程内部(与markRunning对称),但launch返回后协程可能未调度,并发检查会读旧值突破上限。改为launch前同步递增,协程只负责递减- cancel 与自然结束的竞态 -- 最初
complete无条件copy(status=...),cancel 置 CANCELLED 后会被complete覆盖成 FAILED。补上computeIfPresent内的"仅从 RUNNING 转换"校验,先到者扣减计数、后到者 no-op,状态稳定(直接套用 s06SubagentStore的手法) - 测试
NoopLlm重名冲突 --BackgroundCommandTest起初复用NoopLlm命名,与TaskCommandTest的同包private object NoopLlm冲突(Kotlin 顶层 private 同名同包仍 redeclaration)。改名为BgNoopLlm解决 launch协程里冗余 if/else -- 初稿写了if (exitCode == 0) complete() else complete()两个分支,纯冗余(complete内部已按 exitCode 区分 COMPLETED/FAILED)。合并为单次complete(id, exitCode)调用
与 s06 的对照(为何不复用 SubagentStore):
实现中反复确认"能不能复用 s06 的 SubagentStore"。结论是不能,三个硬差异:
- SubagentStore 存文本结果,BackgroundRunner 存进程退出码 + 流式输出(数据形状不同)
- BackgroundRunner 额外持有活进程引用(
processesmap)以支持cancel强杀,SubagentStore 无此需求 - BackgroundRunner 的输出是持续追加的(appendOutput),SubagentStore 的
finalText是一次性设置的
但状态机范式完全复用:ConcurrentHashMap + computeIfPresent 状态校验 + AtomicInteger 计数。s06 建立的模式在 s13 直接生效,这正是迭代开发的价值--前序 Index 沉淀的范式让后续 Index 的并发安全"免费"获得。
下一站
s13 给了智能体"启动一个 shell 进程且不阻塞"的执行原语,补上了 s12 留空的"执行"层。它是阶段三执行基石:
- s14 Cron Scheduler -- 定时器触发时,最自然的执行方式就是派发一个后台 shell 命令。
CronScheduler可以直接复用BackgroundRunner.launch,让定时任务在后台跑 - s17 Autonomous Agents -- 自治智能体用
TaskScheduler.readyTasks(s12)认领就绪任务后,把任务对应的命令派发到BackgroundRunner执行。后台执行 + 通知队列让自治智能体能在"等任务跑完"时继续认领别的任务 - s20 Comprehensive Agent -- 全机制集成时,后台执行是"智能体能同时做多件事"的关键拼图
s13 留给后续最大的礼物是一套"派发-流式收集-状态机-通知"的异步执行范式:协程派发 + ConcurrentHashMap 状态机 + 通知队列 + 回合间 drain。这套范式与 s06 的子智能体并行执行、s12 的持久任务调度一起,构成了 cat-code 的"并发执行"三件套--子智能体(推理并行)、后台任务(进程并行)、任务系统(依赖调度)。
下一篇:Index 14: Cron Scheduler -- 持久化调度 / 会话级触发,让任务按时间自动跑起来。


