第12章 Headless 自主运行
2026.07.29有用户在场和无用户在场不是同一个系统的两个状态——它们是两个不同的系统入口。
模式层
12.1 有/无用户两种模式的差异
在有用户模式下,agent 与用户之间是一条双向通道:
用户 → agent:指令、澄清、否定、补充信息
agent → 用户:进度、问题、确认请求、选择项
这条通道最重要的特性是:agent 可以追问。 遇到不确定的事,它可以问用户。用户也可以主动打断——"不对,换个方向"——agent 调整计划。
在无用户(headless)模式下,这条双向通道消失了:
agent → 系统:工具调用
系统 → agent:工具结果
没有追问的余地。agent 遇到不确定的事,只能在现有信息范围内做最佳决策。它不能用"请确认一下"——因为没有人可问。
三个关键差异:
- 交互通道的消失。 有用户模式下,agent 可以询问"这个模块的接口要保留吗?";无用户模式下,agent 必须从一开始就知道接口策略,或者通过规则推导出"如果模块是公共 API → 保留接口"。不能在中间停下来问
- 权限模型的切换。 有用户时走
Ask模式——弹窗,用户决定。无用户时走PreAuthorized+AutoDeny——预授权的直接执行,未预授权的直接拒绝。拒绝不阻塞——agent 被拒绝后应该换路径 - 退出方式的改变。 有用户模式下,退出很简单:用户关终端。Session 自动保存。无用户模式下,退出必须有明确的退出码契约——因为调用者是调度脚本或 CI 系统,不是人
12.2 预授权模型
无用户模式下,无法弹窗请用户确认权限。所以所有权限决策必须在启动前完成。
预授权通过 codecoder.json 文件配置:
{
"allowlist": {
"run_command:git": "AlwaysThisSession",
"run_command:cargo": "AlwaysThisSession",
"write_file:src/**": "AlwaysThisProject",
"write_file:tests/**": "AlwaysThisProject"
},
"bg_max_auto": 10,
"bg_circuit_k": 2
}
- key:权限 key,格式与交互式模式相同
- value:信任级别。
AlwaysThisProject写入文件,AlwaysThisSession在 session 启动时加载到内存 - 未授权的 key:自动拒绝,返回
ToolFinished{is_error: true}
自动拒绝的 agent 行为约束: 拒绝不是"卡住"——agent 应该设计绕过方案。不能用 run_command:git → 可以用 read_file 读本地 git 日志。不能写文件 → 可以输出到 stdout 让调度器捕获。拒绝返回的错误信息中包含被拒绝的 key 和原因,agent 可以根据这些信息调整策略。
熔断机制: 连续被拒绝或卡在某一步 k 次(bg_circuit_k,默认 2)后,系统主动终止 headless 运行。这防止了 agent 在同一个死胡同里反复兜圈子。
12.3 优雅退出与崩溃恢复
Headless 模式下的退出有四种可能:
- 正常完成(退出码 0):所有里程碑 done,任务完成
- StuckNeedsFix(退出码 2):有里程碑卡在 needs_fix,重试预算耗尽
- 图异常(退出码 3-5):图结构问题、初始化失败、空图
- 信号退出(退出码取决于信号):收到 SIGINT / SIGTERM,优雅终止
SIGINT → CancelToken 链路:
当 headless runner 收到 SIGINT 时,系统不直接杀进程,而是翻转共享的 CancelToken。CancelToken 的完整设计(共享翻转、双路径取消、grace period)见第 5 章 5.2 和 5.6 节。取消后的行为:
- 终止当前正在执行的工具(如 kill 子进程)
- 保存当前状态(已完成的里程碑、当前进度)
- 退出(退出码 0 — 因为"被取消"不被视为错误)
崩溃恢复:
崩溃恢复依赖两个机制:
- Stamp 文件:agent 启动时写一个时间戳文件到项目根目录,正常退出时删除。下次启动时如果发现 stamp 存在,说明上次是异常终止
- Supervisor state:
supervisor_state.json持久化保存每个 Persistent Capability 的 crash_count、gave_up 状态。关于 Persistent Capability 的进程监督和崩溃恢复,见第 8 章 8.6 节。重启后跳过崩溃超限的服务,不再尝试 spawn
案例层
12.4 BG_TASK vs BG_WORKGRAPH
CodeCoder 支持两种 headless 模式:
BG_TASK 模式:
CODECODER_BG_TASK="帮我重构模块 A 的接口,提取到独立的 trait 中" ccd
通过环境变量传入一个自然语言任务描述。agent 启动后直接执行这个任务,没有 Work Graph。任务完成后退出。
BG_TASK 适合"一次性任务"——不需要里程碑规划、不需要拆步骤、一个 prompt 就能完成的任务。
BG_WORKGRAPH 模式:
CODECODER_BG_WORKGRAPH=1 ccd
通过环境变量通知 agent 进入 headless 模式,但不传入任务描述。agent 从已有的 Work Graph 文件中读取里程碑定义,按 drive_workgraph 循环推进。
BG_WORKGRAPH 适合"有规划的多步骤任务"——需要里程碑依赖关系、需要验收门、需要自恢复循环。
两种模式的启动流程:
fn run_background_cfg(config: BackgroundConfig) -> Result<ExitCode> {
// 设置无用户标志
context.set_headless(true);
// 加载预授权
context.load_allowlist(&config.allowlist)?;
if let Some(task) = &config.bg_task {
// BG_TASK 模式:直接处理任务
context.process_message(task)?;
} else {
// BG_WORKGRAPH 模式:加载并推进工作图
let mut graph = context.load_workgraph()?;
drive_workgraph(&mut graph, &context)?;
}
// 检查退出条件
if context.has_stuck_milestones() {
Ok(ExitCode::StuckNeedsFix)
} else {
Ok(ExitCode::Success)
}
}
12.5 codecoder.json 预授权
预授权文件的完整格式:
{
"allowlist": {
"run_command:git": "AlwaysThisSession",
"run_command:cargo": "AlwaysThisSession",
"run_command:docker": "AlwaysThisSession",
"write_file:src/**": "AlwaysThisProject",
"write_file:tests/**": "AlwaysThisProject",
"write_file:docs/**": "AlwaysThisProject",
"read_file:*": "AlwaysThisProject",
"glob:*": "AlwaysThisProject",
"grep:*": "AlwaysThisProject",
"diff:*": "AlwaysThisProject",
"web_search:*": "AlwaysThisSession",
"web_fetch:*": "AlwaysThisSession"
},
"bg_max_auto": 10,
"bg_circuit_k": 2,
"bg_max_fix_attempts": 3
}
设计原则:
- 只读操作(
read_file、glob、grep、diff)推荐全通配符*项目级别预授权——这些操作不会修改系统状态 - 写操作(
write_file)推荐路径限制 + 项目级别预授权——写操作的范围限制在特定目录 - 执行操作(
run_command)推荐会话级别预授权——即使预授权,也只在本 session 中有效 - 自修改操作(
generate_*、run_capability)不推荐预授权——这些操作应始终触发权限检查
12.6 BgObserver 可观测性
Headless 模式下没有终端窗口——用户看不到 agent 的实时输出。BgObserver 解决这个问题。
BgObserver 在 headless 运行期间,把每个事件同时写入 stderr 和项目根目录的 .ccd.bg.ndjson 文件:
# .ccd.bg.ndjson(每行一条 JSON)
{"event":"NewToken","token":"正在","timestamp":"..."}
{"event":"NewToken","token":"分析","timestamp":"..."}
{"event":"ToolStarted","tool":"read_file","args":"src/mod.rs","timestamp":"..."}
{"event":"ToolFinished","tool":"read_file","result":"ok","timestamp":"..."}
{"event":"MilestoneDone","milestone":"analyze-structure","timestamp":"..."}
用户可以 tail -f .ccd.bg.ndjson 实时观察 agent 的进展。文件是追加写入的——每行一条 JSON,新旧事件按时间顺序排列。开轮时 truncate,运行中逐事件 append。
.ccd.bg.ndjson 已被加入 .gitignore——不会污染项目仓库。
12.7 退出码契约
Headless runner 的退出码契约:
enum ExitCode {
Success = 0, // 正常完成,所有里程碑 done
EmptyGraph = 5, // 空图,无里程碑
StuckNeedsFix = 2, // 里程碑卡在 needs_fix,重试预算耗尽
GraphError = 3, // 图结构异常
InitError = 4, // 初始化失败
}
退出码的消费方是上层调度器(CI 系统、cron、自动化脚本):
- 退出码 0:正常,继续
- 退出码 2:需要人工介入——有里程碑卡住,自动修复无法解决
- 退出码 3-5:系统异常,不是任务问题,需要检查配置
12.8 SIGINT → CancelToken 链路
SIGINT 的完整处理链路:
fn handle_sigint() {
// 1. 翻转共享 CancelToken
cancel_token.cancel();
// 2. 发送 Cancel 命令(确保 agent 在等待状态时也能收到)
cmd_tx.send(AgentCommand::Cancel);
// 3. 等待当前工具执行完成(最多等待 grace_period)
let deadline = Instant::now() + Duration::from_secs(30);
while !current_tool_finished() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(100));
}
// 4. 保存状态
save_session();
save_workgraph();
// 5. 退出
process::exit(0);
}
第 3 步的 grace period 很重要。如果 agent 正在写入文件,强制终止可能导致文件损坏。30 秒的等待期给 agent 完成当前工具的时间。超过 30 秒后——即使当前工具未完成——也保存状态后退出。
12.9 崩溃恢复流程
崩溃恢复的完整流程:
fn recover_from_crash() -> Result<()> {
// 1. 检查 stamp 文件
let stamp_path = project_root().join(".ccd.stamp");
if stamp_path.exists() {
// 上次是异常终止
let last_session = read_stamp(&stamp_path)?;
log::warn!("上次运行异常终止,正在恢复 session {}", last_session);
// 加载 supervisor state
let supervisor = SupervisorState::load()?;
for (name, state) in &supervisor.services {
if state.crash_count > state.crash_budget {
// 跳过崩溃超限的服务
log::warn!("跳过 {}:崩溃次数 {} 超过预算 {}",
name, state.crash_count, state.crash_budget);
continue;
}
}
// 恢复 session
resume_session(&last_session)?;
}
// 写入新的 stamp
stamp_path.write(current_session_id())?;
Ok(())
}
崩溃恢复的关键设计点:
- stamp 文件不是锁——它只是一个标记。如果进程崩溃,stamp 文件不会被清理,下次启动时检测到异常终止
- supervisor_state 持久化 crash_count——防止 Persistent Capability 在每次崩溃后都被自动重启(循环崩溃)
- 恢复后继续执行——恢复不是回退到起始状态,而是从上次中断的位置继续
ADR 深度阅读
needs_fix 自恢复循环的设计演变
ADR 0039 记录了 needs_fix 自恢复循环的引入过程。自恢复循环的具体实现(包括重试次数、失败原因注入、修复 prompt 格式)已在第 11 章 11.7 节完整展开,此处仅记录设计演变的历史。
初始设计(无自恢复): 里程碑进入 needs_fix 后,系统什么都不做。等待用户手动将其设回 pending 或 in_progress。在 headless 模式下,这意味着"卡住"——没有用户,里程碑永远无法恢复。
第一次增强(自恢复引入): 引入自恢复循环:needs_fix → 将失败原因注入修复 prompt → 重新执行 → 重新验收。循环有界(默认 3 次)。超过预算仍然 fail → StuckNeedsFix。
第二次增强(失败原因累积): 初始版本中,每次重试的 prompt 只包含"上一次失败的原因"。问题在于:agent 修复了第一个问题,验收时暴露了第二个问题。第二次重试的 prompt 中又只有"第二个问题"的原因,agent 不知道第一个问题已被修复。
解决方案:每次重试的失败原因都追加到 fix_reason 中。Agent 在修复时能读到完整的失败历史——"第一次失败:编译错误。第二次失败:测试不通过"。这帮助 agent 理解"可能修复第一个问题引入了第二个问题"。
下一章,上下文压缩与持久化管理——当 session 超过上下文窗口后怎么办。