猫咖
首页博客工具
搜索
语言
切换网站风格
选择主题颜色
点击特效
主题

猫咖 · 持续更新中,源码见 GitHub。

Index 13: Background Tasks - 线程执行 / 通知队列

2026年8月13日
AI智能体Kotlin后端

系列

使用Kotlin从0开发一个ClaudeCode

系列

使用Kotlin从0开发一个ClaudeCode

进度 13 / 21

使用Kotlin从0开发一个ClaudeCode

上一篇

Index 12: Task System — TaskRecord / blockedBy / 磁盘持久化

下一篇

Index 14: Cron Scheduler - 持久化调度 / 会话级触发

目标

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:

  1. BackgroundRunner -- 在协程里派发 sh -c 进程,流式收集输出,状态机驱动生命周期
  2. NotificationQueue -- 线程安全的通知队列,异步完成在回合间被同步感知
  3. 三个 LLM 工具 -- background_bash(派发)、background_output(查状态/读输出)、background_stop(取消)
  4. /bg 命令 -- REPL 里手动管理后台任务
  5. 回合间 drain -- 每轮对话后把积压的完成通知打印出来

完成后的效果--长跑命令不再阻塞对话,且完成时自动被告知:

TEXT
> 帮我跑一下全量测试,顺便看看 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 是阶段三的执行层基石:

TEXT
s05 TodoWrite -> s12 TaskSystem -> s13 BackgroundTasks -> s14 CronScheduler
                              ↘ s17 AutonomousAgents

s12 让任务持久化了,但"执行"这一步仍是空的--/task ready 列出能跑的任务,然后呢?s13 给出了"跑一个 shell 命令且不阻塞"的执行原语;s14 的定时器会触发命令、s17 的自治智能体会认领任务并派发到后台。没有 s13,s14/s17 的"执行"就只能同步阻塞。

与 s06 子智能体的分工

这是设计时最容易混淆的点--子智能体也是"后台派发 + 轮询查询"模型,为什么不能复用?因为两者执行的东西根本不同:

维度s06 Subagents13 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 按需扩展

核心设计与实现

架构全景

TEXT
            ┌─────────────────────────────────────────────────────┐
            │             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 包不依赖增强层的约束):

TEXT
repl -> background, tool, agent, ...
background -> tool (Tool 接口), llm (无), kotlinx.coroutines

background 包只依赖 tool 接口(为了实现 LLM 工具),不依赖 agent/permission/hooks--它是可插拔的执行层。

核心模型:BackgroundStatus + BackgroundTask

KOTLIN
// background/BackgroundStatus.kt
enum class BackgroundStatus { RUNNING, COMPLETED, FAILED, CANCELLED }

状态机(KDoc 原文):

TEXT
launch -> RUNNING -> { 自然结束(exit 0)  -> COMPLETED
                     | 自然结束(exit≠0) -> FAILED
                     | cancel           -> CANCELLED }

两个刻意的设计点:

  1. 没有 PENDING/QUEUED -- 与 s12 TaskStatus(有 PENDING/BLOCKED)不同,后台任务一经 launch 立即 RUNNING(进程已启动)。并发上限在 launch 前检查,超限直接拒绝,不入队等待。因为"等一个空位再启动 shell"没有意义--要么现在能跑,要么告诉调用方稍后再试
  2. 没有 BLOCKED -- 后台任务无依赖概念(依赖是 s12 TaskSystem 的事),不存在"等别人完成才能跑"
KOTLIN
// 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:资源约束

KOTLIN
// background/BackgroundConfig.kt
data class BackgroundConfig(
    val maxConcurrent: Int = DEFAULT_MAX_CONCURRENT,      // 5
    val maxOutputChars: Int = DEFAULT_MAX_OUTPUT_CHARS    // 64 * 1024
)
  • maxConcurrent -- 并发上限,与 s06 maxConcurrentSubagents 对齐(默认 5)。超限 launch 返回 Rejected,不排队
  • maxOutputChars -- 单任务输出缓冲上限 64 KiB。yes/cat /dev/urandom/find / 会产生无限输出,不设上限会把内存撑爆。超出后截断并置 outputTruncated,调用方看到标志知道"有更多输出没保留"

maxConcurrent 由 AgentConfig.maxConcurrentBackground 注入(与 maxConcurrentSubagents 并列),保持配置入口统一。

BackgroundRunner:核心执行器

这是 s13 的算法核心。三类职责:派发、状态推进、取消。

派发:launch

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

执行协程:流式输出 + 自然结束

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

四个细节:

  1. redirectErrorStream(true) -- 合并 stderr 到 stdout,与 s02 BashTool 一致。后台任务只暴露一个输出流,简化模型
  2. 逐行 readLine 流式收集 -- 不等进程结束就 append,所以 background_output 在任务 RUNNING 时能看到"已经输出的部分"。这是后台执行相对于同步 BashTool 的核心增值
  3. readLine() ?: break -- 进程自然结束或被 destroyForcibly 强杀都会让流 EOF,readLine 返回 null,循环退出。cancel 和自然结束复用同一条输出收集路径
  4. CancellationException 重新抛出 -- 与 s06 TaskTool 一致,遵守结构化并发:scope 取消时传播,不吞掉

为什么用 Dispatchers.IO:readLine 和 waitFor 都是阻塞调用,放在 IO 线程池(专为阻塞 I/O 设计)而非 Default(CPU 密集)。ReplLoop 为后台任务单独建 backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),与子智能体的 Default scope 隔离,互不干扰。SupervisorJob 保证单个任务失败不传播给其他任务。

状态推进:complete / fail(只从 RUNNING 转换)

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

KOTLIN
// 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 转换"校验:

TEXT
场景: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(截断保护)

KOTLIN
// 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:通知队列

KOTLIN
// 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。工具名用常量(魔法值禁令):

工具nameisReadOnly作用
BackgroundBashToolbackground_bashfalse派发命令,立即返回 id
BackgroundOutputToolbackground_outputtrue查状态 + 读输出(可 tail_lines)
BackgroundStopToolbackground_stopfalse取消运行中任务

isReadOnly 决定 s02 ToolRegistry 的并发策略:background_output 可与其他读工具并行;派发和取消有副作用需串行。

background_output 的状态分支返回(BackgroundOutputTool.kt):

KOTLIN
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.FAILED

FAILED 时 isError = true,让 LLM 在 tool_result 中明确感知失败(与 s06 QueryTaskTool 对 FAILED 的处理一致)。tail_lines 默认 50,传 0 返回全部(受 maxOutputChars 截断保护)。

权限模型:三个工具都注册进 toolRegistry,由 AgentLoop.onBeforeToolExecute 统一走 PermissionPipeline 审批。background_bash 的 command 参数会经过 DangerousCommandRule 等规则检查--后台执行不绕过权限,危险命令照样被拦。

/bg 命令

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

KOTLIN
// 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 命令访问:

KOTLIN
// repl/ReplCommand.kt
data class ReplContext(
    ...,
    val backgroundRunner: BackgroundRunner,        // s13
    val notificationQueue: NotificationQueue       // s13
)

2. 回合间 drain 通知:

KOTLIN
// repl/ReplLoop.kt:251(主循环 when 块之后)
// s13: 回合间 drain 后台完成通知,让异步结束被同步感知
drainNotifications()
KOTLIN
// 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. 退出时清理:

KOTLIN
// 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,置 outputTruncatedBackgroundRunner
cancel 已终态任务cancel 返回 false,状态不变BackgroundRunner
cancel 未知 idcancel 返回 falseBackgroundRunner
查询未知 id工具返回 isError=true "task not found"BackgroundOutputTool/BackgroundStopTool
协程被 scope 取消fail("coroutine cancelled") 后重新抛 CancellationExceptionBackgroundRunner
会话退出backgroundScope.cancel() 清理所有协程/进程ReplLoop

端到端流程

一次完整的后台任务生命周期(LLM 驱动 + 通知):

TEXT
用户: "帮我跑全量测试,跑完告诉我结果"

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)。

取消一个常驻进程:

TEXT
用户: "启动 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

测试策略

测试类覆盖点
NotificationQueueTestpush/drain 顺序保留、drain 清空、空队列 drain、hasPending 状态、100 并发 push 全部 drain
BackgroundConfigTest默认值(5 / 64KiB)、自定义覆盖
BackgroundRunnerTestlaunch 返回 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

测试设计要点:

  1. 轮询而非固定 sleep -- awaitTerminal 帮助以 20ms 间隔轮询直到终态(5s 超时)。echo 这类毫秒级任务用固定 sleep 会要么浪费时间要么 flaky;轮询让测试在任务完成的瞬间就断言。s12 的 loadAll 排序测试就是因依赖同毫秒巧合而 flaky(1/5 次失败),s13 全程轮询规避
  2. 用真实进程验证 -- echo/exit 7/sleep 30/printf 都是真实 shell 命令,验证流式收集、退出码、取消、截断的真实行为,不 mock ProcessBuilder
  3. RunnerFixture(AutoCloseable) -- 工具测试用 fixture 封装 scope+runner,use { } 块结束自动 scope.cancel(),避免协程/进程泄漏跨测试
  4. 竞态测试用 sleep 30 -- cancel/并发上限测试用足够长的 sleep 30 确保任务在断言时仍 RUNNING,消除时序依赖
  5. 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,状态稳定(直接套用 s06 SubagentStore 的手法)
  • 测试 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"。结论是不能,三个硬差异:

  1. SubagentStore 存文本结果,BackgroundRunner 存进程退出码 + 流式输出(数据形状不同)
  2. BackgroundRunner 额外持有活进程引用(processes map)以支持 cancel 强杀,SubagentStore 无此需求
  3. 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 -- 持久化调度 / 会话级触发,让任务按时间自动跑起来。

目录

当前章节:目标

  • 1. 目标
  • 2. 为什么需要
  • 3. 现实问题
  • 4. 与 s06 子智能体的分工
  • 5. 设计原则
  • 6. 核心设计与实现
  • 7. 架构全景
  • 8. 核心模型:BackgroundStatus + BackgroundTask
  • 9. BackgroundConfig:资源约束
  • 10. BackgroundRunner:核心执行器
  • 11. NotificationQueue:通知队列
  • 12. 三个 LLM 工具
  • 13. /bg 命令
  • 14. ReplLoop 集成
  • 15. 错误处理
  • 16. 端到端流程
  • 17. 测试策略
  • 18. 开发过程:设计取舍与踩坑记录
  • 19. 下一站
回到顶部

相关推荐

查看全部文章
Index 21: Terminal UX —— Claude Code 风格终端交互

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

2026年8月21日

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

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

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

2026年8月13日

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

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

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

2026年8月13日

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