










有用户在场和无用户在场不是同一个系统的两个状态——它们是两个不同的系统入口。
在有用户模式下,agent 与用户之间是一条双向通道:
用户 → agent:指令、澄清、否定、补充信息
agent → 用户:进度、问题、确认请求、选择项
这条通道最重要的特性是:agent 可以追问。 遇到不确定的事,它可以问用户。用户也可以主动打断——"不对,换个方向"——agent 调整计划。
在无用户(headless)模式下,这条双向通道消失了:
agent → 系统:工具调用
系统 → agent:工具结果
没有追问的余地。agent 遇到不确定的事,只能在现有信息范围内做最佳决策。它不能用"请确认一下"——因为没有人可问。
三个关键差异:
Ask 模式——弹窗,用户决定。无用户时走 PreAuthorized + AutoDeny——预授权的直接执行,未预授权的直接拒绝。拒绝不阻塞——agent 被拒绝后应该换路径无用户模式下,无法弹窗请用户确认权限。所以所有权限决策必须在启动前完成。
预授权通过 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
}
AlwaysThisProject 写入文件,AlwaysThisSession 在 session 启动时加载到内存ToolFinished{is_error: true}自动拒绝的 agent 行为约束: 拒绝不是"卡住"——agent 应该设计绕过方案。不能用 run_command:git → 可以用 read_file 读本地 git 日志。不能写文件 → 可以输出到 stdout 让调度器捕获。拒绝返回的错误信息中包含被拒绝的 key 和原因,agent 可以根据这些信息调整策略。
熔断机制: 连续被拒绝或卡在某一步 k 次(bg_circuit_k,默认 2)后,系统主动终止 headless 运行。这防止了 agent 在同一个死胡同里反复兜圈子。
Headless 模式下的退出有四种可能:
SIGINT → CancelToken 链路:
当 headless runner 收到 SIGINT 时,系统不直接杀进程,而是翻转共享的 CancelToken。CancelToken 的完整设计(共享翻转、双路径取消、grace period)见第 5 章 5.2 和 5.6 节。取消后的行为:
崩溃恢复:
崩溃恢复依赖两个机制:
supervisor_state.json 持久化保存每个 Persistent Capability 的 crash_count、gave_up 状态。关于 Persistent Capability 的进程监督和崩溃恢复,见第 8 章 8.6 节。重启后跳过崩溃超限的服务,不再尝试 spawnCodeCoder 支持两种 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)
}
}
预授权文件的完整格式:
{
"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)不推荐预授权——这些操作应始终触发权限检查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——不会污染项目仓库。
Headless runner 的退出码契约:
enum ExitCode {
Success = 0, // 正常完成,所有里程碑 done
EmptyGraph = 5, // 空图,无里程碑
StuckNeedsFix = 2, // 里程碑卡在 needs_fix,重试预算耗尽
GraphError = 3, // 图结构异常
InitError = 4, // 初始化失败
}
退出码的消费方是上层调度器(CI 系统、cron、自动化脚本):
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 秒后——即使当前工具未完成——也保存状态后退出。
崩溃恢复的完整流程:
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(())
}
崩溃恢复的关键设计点:
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 超过上下文窗口后怎么办。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。