1. 定位:谁在等待,谁可以继续

1.1 我想弄清楚的问题

S10 记录的是哪些工作可做,但执行一个慢命令时,同步工具仍会等命令结束。假如完整测试还在运行,而检查文档不依赖它,Agent 能否先继续其他工作?

我的理解是:Background Tasks 把显式请求的 Bash 命令放到后台线程,原调用先收到启动说明,完成结果在后续模型请求前作为通知加入消息。

源码基准为本地 ce8f9f1 的 s11_background_tasks/code.py。下面的函数按真实源码摘录,类方法属于 BackgroundManager;完整导入与注册见原文件,伪代码另行注明。摘录按 MIT 许可使用。

1.2 Background Task 和 S10 Task 有什么区别

机制记录什么ID 与存储谁推动执行
S10 Task System工作项、依赖、状态、负责人task_…,.tasks/ JSON 文件模型认领后用工作工具执行
S11 Background Tasks已启动 Bash 的运行状态和结果bg_0001,进程内的 dict/list后台线程运行命令

后台命令结束不会自动把某个 S10 工作项标成 completed;两种 ID 没有自动关联。后台线程使用同一个 WORKDIR,文件也在同一工作区,没有单独工作目录。

1.3 整体过程:从 S10 到 S11

图 1:回顾 S10 的默认总览

上一轮整体流程:本章改造前的已有系统蓝色已有;橙色本轮将改造;灰色虚线细节在图 2 合并。上一轮整体流程:本章改造前蓝色已有;橙色本轮将改造;灰色虚线细节在图 2 合并。对话历史允许整理与替换预处理转存 / 归档 / 缩短3 步 · 0 模型请求请求模型继续原任务判断响应有工具调用?分发调用工作 / 任务 / compact回传结果所有调用按 ID 配对TOOLS再加 6 个任务 schemaSYSTEM记忆背景 + 任务规则用户输入沿用 S04 入口输入事件UserPromptSubmitStop检查是否继续PreToolUse权限 / 日志 → S04PostToolUse执行后观察技能目录名称 / 描述 · 发现参考Skill 装载正文入历史 → S07.9compact先登记并返回确认工具实现read / write / edit / globtask→S06TODO→S05task / TODO 仅索引足够 →整理后仍超限主动 compact 且结果交齐API 超长 → S08无工具 ↓拒绝也返回配对结果工具结果或 Stop 反馈 → 更新历史 → 下一轮已有任务工具、记忆与原循环;S10 内部细节在本轮合并到工具索引。Compact 处理自动 / 主动 / 超长补救 → S08细节合并;异常最多重试 1 次记忆召回 ▸每个用户回合一次选择相关文件,再读正文记忆文件与索引.memory / MEMORY.md主题文件跨回合保存Memory 保存接口提取 / 保存 / 整理 → S09保留退出边界;内部细节合并本轮结束记忆处理后返回新用户回合无工具调用,Stop 无反馈保存检查完成 →任务工具 ▸create / update / list / getclaim / completeTaskStore 检查 ▸读取记录 / 校验依赖与状态ID · blockedBy · owner.tasks/ 文件 ▸每项 .tasks/{id}.json
图例 蓝:保留橙:将改造灰虚线:合并 完整说明
  • 蓝色:已有且保留
  • 橙色:本轮将改造(仍是旧实现)
  • 紫色:真正新增(本图未用)
  • 红色虚线:本轮将移出(仍是旧流程)(本图未用)
  • 灰色实线框:存储/辅助节点(本图未用)
  • 灰色虚线框:细节合并,功能保留
  • 灰字:来源/补充说明
  • 灰色虚线:关联,不是执行回环
  • 实线箭头:流程/数据方向
  • 下划线章节:点击查看具体实现
图 1:沿用 S10 的节点、位置和连线。橙色是将扩展的工具定义、分发、请求前处理和使用约定;灰色任务工具、检查与文件细节将在图 2 收入工作工具的 S10 索引,任务能力不删除。左右滑动查看全图。

图 2:后台命令怎样接回已有系统

图 2 · 后台执行接回已有循环权限检查后,显式后台 Bash 登记并启动线程,立即回复启动说明。worker 完成后发布到内存队列;请求前收集一次并注入文本通知。任务工具细节收进 S10 索引。图 2 · 后台执行接回已有系统先回传启动说明;后台完成进入队列,请求前再注入通知。对话历史允许整理与替换请求前处理先注入通知,再整理完成队列为空就跳过请求模型继续原任务判断响应有工具调用?分发调用同步 / 后台 / compact回传结果所有调用按 ID 配对TOOLSBash 增加后台标志SYSTEM背景 + 独立命令规则用户输入沿用 S04 入口输入事件UserPromptSubmitStop检查是否继续PreToolUse权限 / 日志 → S04PostToolUse执行后观察技能目录名称 / 描述 · 发现参考Skill 装载正文入历史 → S07.9compact先登记并返回确认工具实现read / write / edit / globtask→S06TODO→S05持久任务 / 依赖 → S10足够 →整理后仍超限主动 compact 且结果交齐API 超长 → S08无工具 ↓拒绝也返回配对结果工具结果或 Stop 反馈 → 更新历史 → 下一轮紫色是启动、worker 和通知注入;灰色队列在内存中,S10 持久任务见工具索引。Compact 处理自动 / 主动 / 超长补救 → S08细节合并;异常最多重试 1 次记忆召回 ▸每个用户回合一次选择相关文件,再读正文记忆文件与索引.memory / MEMORY.md主题文件跨回合保存Memory 保存接口提取 / 保存 / 整理 → S09保留退出边界;内部细节合并本轮结束不等待后台线程新用户回合无工具调用,Stop 无反馈保存检查完成 →登记并启动 ▸bash + run_in_background = true先回复启动说明 / bg_id后台 worker ▸执行命令 → completed / failed不占用主线程等待命令内存中的完成队列 ▸tasks / results / _ready请求前收集与注入 ▸task_notification 文本 · 每项一次
图例 蓝:保留橙:改动紫:新增灰:辅助 完整说明
  • 蓝色:已有且保留
  • 橙色:修改/职责迁移
  • 紫色:真正新增
  • 红色虚线:真实移除(本图未用)
  • 灰色实线框:存储/辅助节点
  • 灰色虚线框:细节合并,功能保留(本图未用)
  • 灰字:来源/补充说明
  • 灰色虚线:关联,不是执行回环
  • 实线箭头:流程/数据方向
  • 下划线章节:点击查看具体实现
图 2:启动后分成两条路径:主线程回传说明,后台 worker 执行并发布结果。灰色队列保存内存状态,请求前收集已完成项并注入消息;本轮退出不等待后台。S10 的任务实现已合并到工作工具索引,旧循环与其他接口保留。左右滑动查看全图。

点击本章模块查看详情;局部图中的步骤与下方函数联动高亮。可拖动右下角,或用“放大/恢复尺寸”。

悬停总图节点即可预览输入、输出和引用连线;移开恢复原状态,点击可固定查看或打开详情。相关连线与箭头加粗,其他连线淡化;蓝、橙、紫等变化颜色含义不变。图 1 保持静态回顾;图 3 布局固定,可按章节设置预览连线。

后台条件:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
execute_tool 的权限检查放行后。
输入
工具名与 run_in_background 参数。
输出/状态
只有 Bash 且参数严格为 True 才走后台。
控制流
不按 build/install/test 关键词猜测。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

登记并启动:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
显式后台分支被选中后。
输入
原 tool_use block,含 command 和调用 ID。
输出/状态
后台编号 bg_0001 等,原调用先得到启动说明。
控制流
登记在锁内;线程启动失败回滚登记,不等执行完成。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

后台执行:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
BackgroundManager.start 启动 daemon 线程。
输入
后台 ID 和 Bash command。
输出/状态
completed 或 failed,结果进入内存完成队列。
控制流
命令执行在锁外,发布状态与结果在锁内。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

完成队列:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
请求前调用 collect 时。
输入
已经结束的 ready ID、任务记录与结果。
输出/状态
每项通知一次,取走后从 tasks/results/_ready 删除。
控制流
队列为空不等线程完成;通知 summary 只取 500 字符。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

请求前注入:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
每次 while 开头、模型请求之前。
输入
当前 messages 与完成通知文本。
输出/状态
合并到最后 user 消息或追加 user,返回通知数。
控制流
不是新的 tool_result,不复用原 tool_use_id;本轮返回后不会主动唤醒。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

超时与进程清理:接口与实现
接口说明:触发、输入、输出与控制流
触发位置
命令执行结束、超时或进程退出路径。
输入
Shell 进程及所在工作区。
输出/状态
限时执行、截断输出和清理;失败进入 failed。
控制流
POSIX 进程组清理,Windows 使用 terminate/kill;不是目录隔离。

在正文查看这一部分的完整解释与代码

选择图中步骤,对照下方函数;条件与状态节点显示其相关实现。

比较范围: 总图是累积学习骨架。独立 S11 脚本实际只有 S04 的五个基础工具、Permission、Hooks 和后台机制,没有直接合入 S10 任务工具、Memory、Skills 或 Compact。图中的旧接口用于复习,不能当作已累计运行的源码。

本章没有增加第六个工具:模型仍调用 bash,只是 schema 多了 run_in_background。execute_tool 先做 PreToolUse,再决定同步 call_tool 或内部后台启动。BackgroundManager 方法不会作为 schema 发给模型。

图 3:主循环与后台线程的最小核心

图 3 · 主循环与后台线程的交接主线程选择后台、启动并回传 bg_id 后继续循环;worker 在下面执行并发布完成状态。只有下次循环请求前才收集并注入通知,本轮结束不等待。图 3 · 主循环与后台线程的交接上方是主循环,下方是 worker 与完成队列;点击内部函数查看代码。选择后台仅 Bash 且显式 trueshould_run_background()登记并启动bg_0001 · 立即回复start_background_task()BackgroundManager.start()主循环继续处理其他工具 / 请求agent_loop()收集已完成项队列空就返回 []collect_background_results()BackgroundManager.collect()请求前注入user 文本 → 模型输入inject_background_results()后台线程执行completed / failedBackgroundManager._run()_run_bash_process()共享的内存状态tasks / results / _ready / lockBackgroundManager.__init__()本轮可以结束后台可能仍在运行agent_loop()完成通知不是第二个 tool_result;若本轮已返回,下一次进入循环才收集。后台编号与 S10 任务 ID 无关;进程退出不会保留这些内存记录。
图例 蓝:保留紫:新增灰:辅助 完整说明
  • 蓝色:已有且保留
  • 橙色:修改/职责迁移(本图未用)
  • 紫色:真正新增
  • 红色虚线:真实移除(本图未用)
  • 灰色实线框:存储/辅助节点
  • 灰色虚线框:细节合并,功能保留(本图未用)
  • 灰字:来源/补充说明
  • 灰色虚线:关联,不是执行回环(本图未用)
  • 实线箭头:流程/数据方向
图 3:上方是当前调用和后续请求,下方是后台执行与共享队列。线程启动后无需等待命令结束;进入下一轮才收集完成项。如果本轮返回,就等下次进入循环。点击内部函数名可就近查看代码。左右滑动查看全图。
图 3 内部函数索引:点击查看实现
步骤/职责内部方法与循环实现
后台条件should_run_background()
execute_tool()
启动与登记start_background_task()
BackgroundManager.start()
继续主循环agent_loop()
worker 执行BackgroundManager._run()
_run_bash_process()
_format_bash_result()
共享状态BackgroundManager.__init__()
收集通知collect_background_results()
BackgroundManager.collect()
注入消息inject_background_results()
agent_loop()
退出边界agent_loop()

2. 后台生命周期:登记、运行与发布

2.1 BackgroundManager 保存什么

所属层: Harness 内部管理对象。它持有正在运行的任务、结果和待收集 ID;不是 S10 的文件任务仓库。

def __init__(self):
        self.tasks: dict[str, dict] = {}
        self.results: dict[str, str] = {}
        self._ready: list[str] = []
        self._counter = 0
        self._lock = threading.Lock()
字段内容更新位置
tasks原调用 ID、command、running/completed/failedstart 登记,_run 更新,collect 移除
results命令结果字符串_run 写入,collect 取走
_ready已完成、尚未通知的后台 ID_run 追加,collect 清空
_counter本进程内编号计数start 递增
_lock保护这些共享字段的锁登记、发布和收集时短暂持有

主线程和 worker 共享这些字段。命令执行在锁外,避免把等待慢命令变成主线程等待锁。重启进程后这些记录不会恢复。

2.2 显式判断是否后台运行

所属层: Harness 内部条件判断。输入工具名和参数,输出布尔值;必须是 Bash,并且字段值严格为 Python True。

def should_run_background(tool_name: str, tool_input: dict) -> bool:
    return (
        tool_name == "bash"
        and tool_input.get("run_in_background") is True
    )

模型参数里的 JSON true 解析成 Python True。省略标志、传字符串 "true",或给文件工具传同名字段,都不会满足这里的条件;源码不根据命令中的 install/test/build 词语自动猜测。

2.3 先登记,再启动线程并返回编号

start_background_task 是内部便利包装,返回后台 ID;它不是 TOOLS 里另注册的工具。

def start_background_task(block) -> str:
    return BACKGROUND.start(block)

真正登记和启动的是 BackgroundManager.start。它检查 Bash 与非空 command,锁内生成 bg_0001 等 ID,记录原调用 ID;释放锁后启动 daemon 线程。

def start(self, block) -> str:
        if block.name != "bash":
            raise ValueError("Only Bash commands can run in the background")
        command = block.input.get("command")
        if not isinstance(command, str) or not command.strip():
            raise ValueError("Bash command cannot be empty")

        with self._lock:
            self._counter += 1
            task_id = f"bg_{self._counter:04d}"
            self.tasks[task_id] = {
                "tool_use_id": block.id,
                "command": command,
                "status": "running",
            }

        thread = threading.Thread(
            target=self._run,
            args=(task_id, command),
            daemon=True,
        )
        try:
            thread.start()
        except Exception:
            with self._lock:
                self.tasks.pop(task_id, None)
            raise
        print(f"  [background] started {task_id}: {command[:60]}")
        return task_id

线程启动失败会删除刚登记的任务并抛出异常。启动成功只表示命令已交给 worker,不表示完成;非常短的命令也可能在主线程准备回复时已经结束。

2.4 worker 执行后发布完成结果

所属层: BackgroundManager._run 是后台线程的内部函数,不是工具入口。它在锁外执行命令,之后持锁更新状态、结果和完成队列。

def _run(self, task_id: str, command: str):
        try:
            output, exit_code = _run_bash_process(command)
            result = _format_bash_result(output, exit_code)
            status = "completed" if exit_code == 0 else "failed"
        except Exception as error:
            result = f"Error: {type(error).__name__}: {error}"
            status = "failed"

        with self._lock:
            task = self.tasks.get(task_id)
            if task is None:
                return
            task["status"] = status
            self.results[task_id] = result
            self._ready.append(task_id)

只有退出码 0 是 completed;非零退出、超时返回的 None 或异常都会进入 failed。发布到 _ready 不会立即调用模型,也不会执行第二次 PostToolUse。

3. 结果回到主循环:收集与消息注入

3.1 一次性收集已完成项

包装函数调用同一个后台管理器:

def collect_background_results() -> list[str]:
    return BACKGROUND.collect()

BackgroundManager.collect 在锁内移走 ready 项并清空队列,在锁外格式化通知。正在运行的任务不会被取走。

def collect(self) -> list[str]:
        with self._lock:
            ready = []
            for task_id in self._ready:
                task = self.tasks.pop(task_id, None)
                result = self.results.pop(task_id, "")
                if task is not None:
                    ready.append((task_id, task, result))
            self._ready.clear()

        notifications = []
        for task_id, task, result in ready:
            notifications.append(
                f"<task_notification>\n"
                f"  <task_id>{task_id}</task_id>\n"
                f"  <status>{task['status']}</status>\n"
                f"  <command>{task['command']}</command>\n"
                f"  <summary>{result[:500]}</summary>\n"
                f"</task_notification>"
            )
            print(f"  [background] collected {task_id}: {task['status']}")
        return notifications

每项完成结果只送一次:收集后,任务和结果从 dict 中删除。通知的 summary 最多 500 字符,不能把它当成完整输出;当前实现没有把剩余正文另外保存为可恢复文件。

3.2 在模型请求前注入 user 文本

所属层: Harness 内部消息处理。输入 messages,收集通知后合并到最后一条 user 消息,或追加新的 user 消息;返回注入的通知数。

def inject_background_results(messages: list) -> int:
    notifications = collect_background_results()
    if not notifications:
        return 0

    blocks = [{"type": "text", "text": item} for item in notifications]
    if messages and messages[-1].get("role") == "user":
        content = messages[-1].get("content", "")
        if isinstance(content, list):
            content.extend(blocks)
        else:
            messages[-1]["content"] = [
                {"type": "text", "text": str(content)},
                *blocks,
            ]
    else:
        messages.append({"role": "user", "content": blocks})
    return len(notifications)

最后一条 user 如果已有 tool_result 列表,就把通知 text 块接到同一列表后。若原 content 是字符串,就先转成 text 块。这样模型会在下一次请求看到消息,不会收到不配对的工具结果。

3.3 启动回复和完成通知是两回事

信息形式对应什么
启动回复tool_result,按原 block.id 配对这次 Bash 调用已接受,后台编号是多少
完成通知user 消息中的 text某个 bg_id 后来 completed 或 failed

原调用已经得到启动说明,因此完成结果不会再用同一个 tool_use_id 回答一次。它用独立通知表达事件:

<task_notification>
  <task_id>bg_0001</task_id>
  <status>completed</status>
  <command>python -m pytest</command>
  <summary>...</summary>
</task_notification>

XML 风格只是文本格式,不是新的 SDK 消息角色或工具类型。它表示命令完成事件的数据,不是新增的用户任务;模型仍需判断结果是否足以继续,failed 也会作为通知送达。

3.4 用一次后台测试串起来

  1. 模型请求 bash(command="python -m pytest", run_in_background=True)。
  2. PreToolUse 放行后启动线程,当前调用先得到带 bg_id 的启动说明。
  3. 主循环可以继续处理独立的文件读取或文档检查。依赖测试结果的步骤仍应等待结果。
  4. worker 结束,将状态与结果放进 ready 队列。
  5. 再次进入 while 顶部时收集通知,模型请求才带上测试结果。

第 4 步可以发生在模型请求期间;结果只会在后续注入点进入 messages。如果模型已经结束本轮,通知要等新的用户回合等事件再次进入 agent_loop。

4. 接回执行入口与真实边界

4.1 工具入口在哪一层分流

模型看到的 Bash schema 多一个布尔参数,工具名仍是 bash:

{"name": "bash", "input_schema": {
    "type": "object",
    "properties": {
        "command": {"type": "string"},
        "run_in_background": {"type": "boolean"},
    },
    "required": ["command"],
}}

这是省略 description 的 schema 摘录。模型不直接调用 BackgroundManager.start。统一入口 execute_tool 做权限与 Hooks,决定是否后台,再回传当前调用的结果。

def execute_tool(block) -> str:
    blocked = trigger_hooks("PreToolUse", block)
    if blocked is not None:
        return str(blocked)

    if should_run_background(block.name, block.input):
        try:
            task_id = start_background_task(block)
            output = (
                f"[Background task {task_id} started] "
                "The result will be collected on a later turn."
            )
        except Exception as error:
            output = f"Error: {error}"
    else:
        output = call_tool(block)

    trigger_hooks("PostToolUse", block, output)
    return output

PreToolUse 返回非 None 就直接拒绝;后台启动也必须先通过它。PostToolUse 在本次启动说明或同步结果返回后执行,实际后台完成由通知机制处理。

同步分支由 call_tool 查本地分发表,它自身不重复执行 Hooks:

def call_tool(block) -> str:
    handler = TOOL_HANDLERS.get(block.name)
    try:
        output = handler(**block.input) if handler else f"Unknown: {block.name}"
    except Exception as error:
        output = f"Error: {error}"
    return str(output)

TOOL_HANDLERS["bash"] 对应的入口是 run_bash。虽然它为兼容 schema 接收后台参数,函数体仍做同步执行;真正的后台判断已经在 execute_tool 完成。

def run_bash(command: str, run_in_background: bool = False) -> str:
    return _format_bash_result(*_run_bash_process(command))

4.2 原 Agent Loop 的两个插入点

循环顶部收集结果,工具执行入口选择是否后台。剩下的消息追加与 tool_use 配对继续沿用原流程:

def agent_loop(messages: list):
    while True:
        inject_background_results(messages)
        response = client.messages.create(
            model=MODEL,
            system=SYSTEM,
            messages=messages,
            tools=TOOLS,
            max_tokens=8000,
        )
        messages.append({"role": "assistant", "content": response.content})

        tool_calls = [
            block for block in response.content if block.type == "tool_use"
        ]
        if not tool_calls:
            force = trigger_hooks("Stop", messages)
            if force:
                messages.append({"role": "user", "content": force})
                continue
            return

        results = []
        for block in tool_calls:
            output = execute_tool(block)
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": output,
            })
        messages.append({"role": "user", "content": results})

控制流程伪代码可以压缩为:

while True:
    inject completed notifications into messages
    response = request model
    if no tool calls:
        if Stop asks to continue: continue
        return
    for each tool call:
        check PreToolUse
        if explicit background Bash:
            start worker; result = started acknowledgement
        else:
            result = execute synchronously
        run PostToolUse; pair result with original call ID
    append this batch of tool results

这里没有 wait/join 全部后台任务的步骤,也没有“后台完成就自动请求模型”的回调。collect 不等未完成线程;本轮没有更多工具调用且 Stop 不强制继续时,函数照常返回。

4.3 命令执行、超时与清理

所属层: 内部进程执行实现。前台 Bash 与后台 worker 共用 _run_bash_process;不是两个不同的 Shell 执行引擎。

def _run_bash_process(command: str) -> tuple[str, int | None]:
    process = None
    try:
        # start_new_session (setsid) exists only on POSIX; skip it on Windows.
        popen_kwargs = {} if os.name == "nt" else {"start_new_session": True}
        process = subprocess.Popen(
            command,
            shell=True,
            cwd=WORKDIR,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True, errors="replace",
            **popen_kwargs,
        )
        with _shell_process_lock:
            _shell_processes.add(process)
        stdout, stderr = process.communicate(timeout=120)
        output = (stdout + stderr).strip()
        return (output[:50000] if output else "(no output)"), process.returncode
    except subprocess.TimeoutExpired:
        return "Error: Timeout (120s)", None
    except OSError as error:
        return f"Error: {type(error).__name__}: {error}", None
    finally:
        if process is not None:
            _stop_process_group(process)
            try:
                process.wait(timeout=0.2)
            except subprocess.TimeoutExpired:
                pass
            with _shell_process_lock:
                _shell_processes.discard(process)

它在同一 WORKDIR 启动 Shell,communicate 最多等待 120 秒,合并 stdout/stderr 后最多保留 50,000 字符。结果格式化会把非零退出码写进错误说明:

def _format_bash_result(output: str, exit_code: int | None) -> str:
    if exit_code in (0, None):
        return output
    return f"Error: command exited with status {exit_code}\n{output}"

清理方法如下。POSIX 使用进程组信号,Windows 回退为终止/杀死 Shell 进程;不能据此假设 Windows 会自动清理全部后代。

def _stop_process_group(process: subprocess.Popen):
    """Stop a shell process and its children (cross-platform).

    POSIX uses process-group signals (SIGTERM then SIGKILL). Windows has
    neither ``os.killpg`` nor ``signal.SIGKILL``, so it falls back to
    ``Popen.terminate()`` / ``Popen.kill()``.
    """
    if os.name == "nt":
        for stop in (process.terminate, process.kill):
            if process.poll() is not None:
                return
            try:
                stop()
            except OSError:
                return
            try:
                process.wait(timeout=0.05)
            except subprocess.TimeoutExpired:
                continue
        return

    # SIGKILL is POSIX-only; fall back to SIGTERM on platforms without it.
    for sig in (signal.SIGTERM, getattr(signal, "SIGKILL", signal.SIGTERM)):
        try:
            os.killpg(process.pid, sig)
        except (ProcessLookupError, OSError):
            return
        time.sleep(0.05)

源码还在 atexit 和 SIGTERM 路径调用 _stop_all_shell_processes。这属于生命周期清理,没有提供工作区隔离;进程退出不保证所有后台命令都能自然完成。

4.4 当前实现的边界与我的理解

边界当前源码行为
哪些工具可后台仅显式请求的 Bash,文件工具仍同步
是否持久化只有内存状态,不关联 S10 任务文件
何时收到完成结果下一次模型请求前;不会主动唤醒已返回的循环
是否等待全部完成不等待;本轮可先返回
是否有完整输出runner 先截到 50,000,通知再截到 500 字符
是否可查询或取消本章没有额外 query/cancel 工具;collect 后记录移除
是否独立目录仍使用同一 WORKDIR,独立性由任务安排保证

我的一句话理解:让主循环先拿到启动确认继续工作,把完成结果排队,在下次请求前送回模型;后台执行和任务完成记录是两个层次。