vault backup: 2026-06-08 23:08:57
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
---
|
||||
title: "协调者与蜂群模式 - 多 Agent 高级编排"
|
||||
description: "从源码角度解析 Claude Code 多 Agent 协作:Coordinator Mode 的 System Prompt 设计、Worker 生命周期、Task 通信协议和 Swarm 蜂群的任务分配机制。"
|
||||
keywords: ["协调者模式", "蜂群模式", "Agent Swarm", "多 Agent 协作", "任务编排"]
|
||||
---
|
||||
|
||||
{/* 本章目标:从源码角度揭示 Coordinator Mode 和 Agent Swarms 的架构设计 */}
|
||||
|
||||
## 两种协作模式的架构差异
|
||||
|
||||
| 维度 | Coordinator Mode | Agent Swarms |
|
||||
|------|-----------------|--------------|
|
||||
| **门控** | `feature('COORDINATOR_MODE')` + `CLAUDE_CODE_COORDINATOR_MODE=1` | `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` 环境变量 |
|
||||
| **拓扑** | 星型:Coordinator 居中,Worker 外围 | 星型+P2P 混合:Team Lead 协调,Teammate 间可直接通信 |
|
||||
| **角色** | 明确分工:Coordinator 编排、Worker 执行 | Team Lead 协调 + Teammate 自主认领任务 |
|
||||
| **通信** | `SendMessage` 定向通信 + `<task-notification>` | Mailbox 消息系统(message / broadcast) |
|
||||
| **适用** | 需要集中决策的复杂任务 | 并行度高、需要 Teammate 间直接协作的任务 |
|
||||
|
||||
两者不是互斥的——理论上 Coordinator Mode 可以在 Agent Teams 架构之上运行(概念层叠加,非嵌套团队),将 Coordinator 作为特殊的 Team Lead,但这部分集成(`workerAgent.ts` 中的 `getCoordinatorAgents`)目前为 stub 实现,尚未完整落地。
|
||||
|
||||
## Coordinator Mode:星型编排架构
|
||||
|
||||
### 激活机制
|
||||
|
||||
```typescript
|
||||
// src/coordinator/coordinatorMode.ts:36
|
||||
export function isCoordinatorMode(): boolean {
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
|
||||
}
|
||||
return false // 外部构建始终 false
|
||||
}
|
||||
```
|
||||
|
||||
Coordinator Mode 需要双重门控:构建时 `feature('COORDINATOR_MODE')` 和运行时环境变量。`matchSessionMode()` 在会话恢复时自动同步模式状态——如果恢复的会话是 coordinator 模式,它会翻转环境变量以确保一致性。
|
||||
|
||||
### Coordinator 的工具集
|
||||
|
||||
Coordinator 被剥夺了所有"动手"工具,只保留编排能力:
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| **Agent** | 启动新 Worker(`subagent_type: "worker"`) |
|
||||
| **SendMessage** | 向已有 Worker 发送后续指令 |
|
||||
| **TaskStop** | 中途停止走错方向的 Worker |
|
||||
| **subscribe_pr_activity** | 订阅 GitHub PR 事件(review comments、CI 结果) |
|
||||
|
||||
Coordinator **不写代码、不读文件、不执行命令**——它的核心职责是:理解需求、分配任务、综合结果,以及在无需工具时直接回答用户问题。
|
||||
|
||||
### Worker 的工具权限
|
||||
|
||||
Worker 的可用工具由 `getCoordinatorUserContext()`(`coordinatorMode.ts:80`)动态注入到 System Prompt:
|
||||
|
||||
```typescript
|
||||
// 简化模式下:只有 Bash + Read + Edit
|
||||
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||||
? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]
|
||||
: Array.from(ASYNC_AGENT_ALLOWED_TOOLS)
|
||||
.filter(name => !INTERNAL_WORKER_TOOLS.has(name))
|
||||
```
|
||||
|
||||
`INTERNAL_WORKER_TOOLS`(TeamCreate、TeamDelete、SendMessage、SyntheticOutput)被显式排除——Worker 不能嵌套创建团队或发送消息,防止不可控的递归。
|
||||
|
||||
### Scratchpad:跨 Worker 的共享知识库
|
||||
|
||||
当 `isScratchpadGateEnabled()`(内部检查 `tengu_scratch` feature gate)启用时,Workers 获得一个 Scratchpad 目录,Coordinator 通过其系统上下文知晓该目录的存在:
|
||||
|
||||
```
|
||||
Scratchpad 目录:
|
||||
- Workers 可自由读写,无需权限审批
|
||||
- 用于持久化的跨 Worker 知识
|
||||
- 结构由 Coordinator 决定(无固定格式)
|
||||
```
|
||||
|
||||
这是一个关键的协作原语——Worker A 的研究结果可以写入 Scratchpad,Worker B 直接读取,无需通过 Coordinator 中转。
|
||||
|
||||
### `<task-notification>` 通信协议
|
||||
|
||||
Worker 完成后,Coordinator 收到 XML 格式的通知:
|
||||
|
||||
```xml
|
||||
<task-notification>
|
||||
<task-id>agent-a1b</task-id> ← Worker 的 agentId
|
||||
<status>completed|failed|killed</status>
|
||||
<summary>Agent "Investigate auth bug" completed</summary>
|
||||
<result>Found null pointer in src/auth/validate.ts:42...</result>
|
||||
<usage>
|
||||
<total_tokens>N</total_tokens>
|
||||
<tool_uses>N</tool_uses>
|
||||
<duration_ms>N</duration_ms>
|
||||
</usage>
|
||||
</task-notification>
|
||||
```
|
||||
|
||||
通知以 `user-role message` 形式送达,Coordinator 通过 `<task-notification>` 标签区分它和用户消息。`<task-id>` 用于 `SendMessage` 的 `to` 参数,实现定向续传。
|
||||
|
||||
### Coordinator 的核心职责:综合(Synthesis)
|
||||
|
||||
Coordinator System Prompt(`coordinatorMode.ts:111-369`,约 260 行)明确要求 Coordinator **不能懒惰地委派理解**:
|
||||
|
||||
```
|
||||
反模式(禁止):
|
||||
"Based on your findings, fix the auth bug"
|
||||
→ 把理解的责任推给了 Worker
|
||||
|
||||
正确做法:
|
||||
"Fix the null pointer in src/auth/validate.ts:42.
|
||||
The user field on Session (src/auth/types.ts:15) is
|
||||
undefined when sessions expire but the token remains cached.
|
||||
Add a null check before user.id access."
|
||||
→ Coordinator 自己理解了问题,给出精确指令
|
||||
```
|
||||
|
||||
这是 Coordinator Mode 最核心的设计约束:Coordinator 必须先理解,再分配。
|
||||
|
||||
## Agent Teams (Swarm):蜂群式协作
|
||||
|
||||
Swarm 模式基于任务系统 V2(详见[任务管理](../tools/task-management.mdx)),核心机制是**共享任务列表 + 竞争认领 + Mailbox 消息系统**:
|
||||
|
||||
### 团队初始化
|
||||
|
||||
```
|
||||
Team Lead 创建团队(TeamCreateTool)
|
||||
↓
|
||||
设置 teamName → setLeaderTeamName()
|
||||
↓
|
||||
所有 Teammate 自动获得相同的 taskListId
|
||||
↓
|
||||
Teammate 启动时:
|
||||
1. CLAUDE_CODE_TASK_LIST_ID 环境变量(显式覆盖)
|
||||
2. Teammate 上下文的 teamName(共享 Lead 的任务列表)
|
||||
3. CLAUDE_CODE_TEAM_NAME 环境变量
|
||||
4. Lead 设置的 teamName
|
||||
5. getSessionId()(兜底)
|
||||
```
|
||||
|
||||
多级优先级确保了 Team Lead 和所有 Teammate 指向同一个任务列表,无需额外协调。
|
||||
|
||||
### 架构组件
|
||||
|
||||
官方 Agent Teams 架构定义了四个核心组件:
|
||||
|
||||
| 组件 | 角色 |
|
||||
|------|------|
|
||||
| **Team Lead** | 创建团队、分配任务、综合结果的主 Claude Code 会话 |
|
||||
| **Teammate** | 独立的 Claude Code 实例,各自拥有独立的上下文窗口 |
|
||||
| **Task List** | 共享的任务列表,Teammate 竞争认领和完成 |
|
||||
| **Mailbox** | 消息系统,支持 Teammate 间直接通信 |
|
||||
|
||||
### Mailbox 消息系统
|
||||
|
||||
官方架构中的 Mailbox 是 Teammate 间通信的核心原语,支持两种消息模式(`broadcast` 模式来自源码推断,官方文档未明确细分):
|
||||
|
||||
| 模式 | 作用 | 场景 |
|
||||
|------|------|------|
|
||||
| **message** | 定向发送给指定 Teammate | 传递具体指令、请求协作 |
|
||||
| **broadcast** | 广播给所有 Teammate | 全局通知、状态同步 |
|
||||
|
||||
Mailbox 的关键特性:
|
||||
- **自动投递**:消息自动送达目标 Teammate 的对话上下文
|
||||
- **空闲通知**(TeammateIdle):Teammate 完成当前任务进入空闲时,自动通过 Mailbox 通知 Team Lead
|
||||
- **直接通信**:与 Coordinator Mode 不同,Teammate 之间可以直接通信,无需经过 Lead 中转
|
||||
|
||||
### Hook 事件
|
||||
|
||||
Agent Teams 提供三个关键 Hook 事件,用于在团队生命周期中注入自定义逻辑:
|
||||
|
||||
| Hook | 触发时机 | 典型用途 |
|
||||
|------|---------|---------|
|
||||
| **TaskCreated** | 新任务添加到任务列表时 | 自动分配、优先级排序 |
|
||||
| **TaskCompleted** | 任务标记为完成时 | 结果通知、依赖解锁 |
|
||||
| **TeammateIdle** | Teammate 完成所有任务进入空闲时 | Lead 重新分配、动态扩缩容 |
|
||||
|
||||
### 限制
|
||||
|
||||
当前 Agent Teams 实现的限制:
|
||||
- **不支持嵌套团队**:Teammate 不能再创建子团队
|
||||
- **每 session 一个团队**:一个会话只能属于一个团队
|
||||
- **Lead 固定**:Team Lead 创建后不可更换
|
||||
- **不支持 in-process Teammate 的会话恢复**:进程重启后 in-process 类型 Teammate 的状态丢失
|
||||
|
||||
### 持久化存储
|
||||
|
||||
团队状态通过文件系统持久化,确保进程重启后可恢复:
|
||||
|
||||
```
|
||||
~/.claude/teams/{team-name}/config.json ← 团队配置
|
||||
~/.claude/tasks/{team-name}/ ← 共享任务列表(文件锁保护)
|
||||
```
|
||||
|
||||
### 任务认领与竞争
|
||||
|
||||
`claimTask()` 是 Agent Teams 的核心并发原语:
|
||||
|
||||
```
|
||||
Teammate A 调用 TaskList → 发现 task #3 是 pending
|
||||
Teammate B 同时发现 task #3 是 pending
|
||||
↓
|
||||
两者同时尝试 TaskUpdate(task #3, {status: "in_progress"})
|
||||
↓
|
||||
文件锁保证原子性:
|
||||
- 第一个写入者获得 owner 锁定
|
||||
- 第二个写入者收到 already_claimed 错误
|
||||
↓
|
||||
获得任务的 teammate 执行工作
|
||||
↓
|
||||
完成后 TaskUpdate(task #3, {status: "completed"})
|
||||
→ 依赖此任务的其他任务自动解锁
|
||||
→ tool_result 提示 "Call TaskList to find your next task"
|
||||
```
|
||||
|
||||
### Teammate 的生命周期管理
|
||||
|
||||
```
|
||||
Teammate 异常退出
|
||||
↓
|
||||
unassignTeammateTasks()
|
||||
→ 扫描任务列表,找到 owner === teammateName 的未完成任务
|
||||
→ 重置为 pending + owner=undefined
|
||||
↓
|
||||
Team Lead 感知途径:
|
||||
1. 任务状态变化(pending 重置)—— 通过共享任务列表
|
||||
2. Mailbox 空闲通知(TeammateIdle hook)—— Teammate 停止时自动通知 Lead
|
||||
↓
|
||||
Team Lead 重新分配任务或创建新 Teammate
|
||||
```
|
||||
|
||||
## 任务类型全景
|
||||
|
||||
支撑多 Agent 协作的是 7 种任务类型(`src/tasks/types.ts`):
|
||||
|
||||
| 任务类型 | 运行位置 | 状态管理 | 适用场景 |
|
||||
|----------|---------|---------|---------|
|
||||
| **LocalAgentTask** | 本地子进程 | `LocalAgentTaskState` | 标准子 Agent 任务 |
|
||||
| **LocalShellTask** | 本地 shell | `LocalShellTaskState` | 后台 shell 命令 |
|
||||
| **InProcessTeammateTask** | 同进程内 | `InProcessTeammateTaskState` | 轻量级进程内队友 |
|
||||
| **RemoteAgentTask** | 远程服务器 | `RemoteAgentTaskState` | 分布式 Agent(CCR) |
|
||||
| **DreamTask** | 后台静默 | `DreamTaskState` | 后台自主整理记忆 |
|
||||
| **LocalWorkflowTask** | 本地 | `LocalWorkflowTaskState` | 工作流编排 |
|
||||
| **MonitorMcpTask** | 本地 | `MonitorMcpTaskState` | MCP 监控任务 |
|
||||
|
||||
`InProcessTeammateTask` 与 `LocalAgentTask` 的关键差异:前者共享进程的内存空间和基础设施状态(如 MCP 连接池),但有独立的对话上下文和工具权限;后者是完全隔离的子进程,启动开销更大但更安全。
|
||||
|
||||
## Coordinator vs Agent Teams 的选择
|
||||
|
||||
| 场景 | 推荐模式 | 原因 |
|
||||
|------|---------|------|
|
||||
| "重构认证系统,需要多模块协调" | Coordinator | 需要集中决策,Worker 间有依赖 |
|
||||
| "修复 10 个独立的 lint 警告" | Agent Teams | 任务独立,Teammate 可完全并行 |
|
||||
| "研究方案 A 和方案 B,然后选一个实现" | Coordinator | 先并行研究,再集中决策 |
|
||||
| "在大仓库中搜索所有 TODO 并分类" | Agent Teams | 无依赖,各自领任务即可 |
|
||||
@@ -0,0 +1,858 @@
|
||||
---
|
||||
title: "子 Agent 机制 - 权限、流程、同步/异步与 Fork"
|
||||
description: "从源码角度解析 Claude Code 子 Agent:AgentTool 的执行链路、权限模式、同步与异步生命周期、任务通知队列、AgentTool fork、slash command fork 与 runForkedAgent 的边界。"
|
||||
keywords: ["子 Agent", "AgentTool", "权限模式", "同步子 Agent", "异步子 Agent", "forkSubagent", "runForkedAgent"]
|
||||
---
|
||||
|
||||
{/* 本章目标:把子 Agent 的几条容易混淆的执行链路拆开说明,并给出源码入口。 */}
|
||||
|
||||
## 先分清四个概念
|
||||
|
||||
Claude Code 里常被一起称为"子 Agent"的东西,其实有四类执行路径:
|
||||
|
||||
| 类型 | 谁触发 | 是否经过 Tool 协议 | 结果怎么回来 | 典型入口 |
|
||||
|------|--------|--------------------|--------------|----------|
|
||||
| 命名子 Agent | 主模型调用 `Agent(...)`,并提供 `subagent_type` | 是,属于一次 `tool_use` | 当前 turn 的 `tool_result`,或后台完成后的 `<task-notification>` | `src/tools/AgentTool/AgentTool.tsx` |
|
||||
| AgentTool fork | 主模型调用 `Agent(...)`,省略 `subagent_type`,且 fork gate 开启 | 是,仍然是 `Agent` 工具 | 先返回 `async_launched`,完成后通过任务通知回到主模型 | `src/tools/AgentTool/AgentTool.tsx`、`src/tools/AgentTool/forkSubagent.ts` |
|
||||
| Slash command fork | 用户执行 `context: fork` 的 slash command / skill | 否,不是模型发出的 `Agent` tool_use | 普通模式同步返回命令输出;assistant 模式后台回注隐藏 prompt | `src/utils/processUserInput/processSlashCommand.tsx` |
|
||||
| `runForkedAgent()` | 运行时内部服务直接分叉一条执行支线 | 否,内部 API | 调用方内部消费结果 | `src/utils/forkedAgent.ts` |
|
||||
|
||||
一句话记忆:
|
||||
|
||||
`AgentTool` fork 是给模型使用的工具语义;`runForkedAgent()` 是给运行时内部能力使用的实现细节;slash command fork 是 skill / command 的执行模式。
|
||||
|
||||
## AgentTool 主流程
|
||||
|
||||
模型看到的 `Agent` 工具最终会进入 `AgentTool.call()`。一条普通命名子 Agent 的执行链如下:
|
||||
|
||||
```text
|
||||
assistant message
|
||||
-> tool_use: Agent({ prompt, subagent_type?, run_in_background?, ... })
|
||||
-> query.ts: runTools(...)
|
||||
-> toolExecution.ts: await tool.call(...)
|
||||
-> AgentTool.call(...)
|
||||
-> resolve selectedAgent / fork path / permission mode / tool pool
|
||||
-> runAgent(...)
|
||||
-> finalizeAgentTool(...)
|
||||
-> mapToolResultToToolResultBlockParam(...)
|
||||
-> user message with tool_result
|
||||
-> query.ts starts next model turn with that tool_result
|
||||
```
|
||||
|
||||
关键源码入口:
|
||||
|
||||
| 代码 | 作用 |
|
||||
|------|------|
|
||||
| `src/tools/AgentTool/AgentTool.tsx` | `Agent` 工具定义、路由、同步/异步生命周期 |
|
||||
| `src/tools/AgentTool/runAgent.ts` | 子 Agent 的 query loop、system prompt、MCP、sidechain transcript |
|
||||
| `src/services/tools/toolExecution.ts` | 外层工具执行器,`await tool.call(...)` 的地方 |
|
||||
| `src/query.ts` | 主 agentic loop,收集 tool results 并进入下一轮模型调用 |
|
||||
| `src/tasks/LocalAgentTask/LocalAgentTask.tsx` | 后台本地 Agent task 的注册、状态更新、完成通知 |
|
||||
|
||||
## AgentTool 输入参数
|
||||
|
||||
`Agent` 工具的输入 schema 定义在 `AgentTool.tsx` 的 `baseInputSchema()` 和 `fullInputSchema()`。有些字段会被 feature gate 从模型可见 schema 中隐藏,但 `call()` 的实现会按统一的 `AgentToolInput` 类型处理这些可选字段。
|
||||
|
||||
### 基础参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 作用 | 影响路径 |
|
||||
|------|------|------|------|----------|
|
||||
| `description` | `string` | 是 | 3-5 个词的任务短描述,用于 UI、任务列表、日志、后台通知和输出摘要 | 不参与子 Agent 的实际 prompt 推理,但会影响 task 展示和通知 |
|
||||
| `prompt` | `string` | 是 | 子 Agent 要执行的完整任务说明 | 普通 agent 会变成子 Agent 的 user message;fork path 会嵌入 fork directive;remote path 会作为远程初始消息 |
|
||||
| `subagent_type` | `string` | 否 | 指定命名 agent 类型 | 有值时走命名 agent;省略时 fork gate 开启则走 AgentTool fork,否则回退到 `general-purpose` |
|
||||
| `model` | `'sonnet' \| 'opus' \| 'haiku'` | 否 | 这次调用的模型覆盖 | 普通命名 agent 中优先级高于 agent definition 的 `model`;coordinator mode 下忽略;fork path 继承父模型 |
|
||||
| `run_in_background` | `boolean` | 否 | 请求后台运行 | 为 `true` 时走异步 task;如果后台任务被禁用或 fork gate 开启,这个字段会从 schema 中隐藏 |
|
||||
|
||||
### 多 Agent / Teammate 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 作用 | 影响路径 |
|
||||
|------|------|------|------|----------|
|
||||
| `name` | `string` | 否 | 给 spawned agent 命名,使其可被 `SendMessage({ to: name })` 定向 | 与 `team_name` 或当前 team context 一起出现时触发 teammate spawn;普通后台子 Agent 中也会注册 `name -> agentId` 方便后续发送消息 |
|
||||
| `team_name` | `string` | 否 | 指定要加入或使用的 team | 与 `name` 一起触发 `spawnTeammate()`;省略时可继承当前 `appState.teamContext.teamName` |
|
||||
| `mode` | permission mode | 否 | teammate spawn 的权限模式提示 | 当前实现只用于 teammate 的 `plan_mode_required: spawnMode === 'plan'`;它不是普通本地子 Agent 的 `permissionMode` 覆盖 |
|
||||
|
||||
`name + team_name` 是一条独立分支:它不会进入普通 `runAgent()` 本地子 Agent 路径,而是调用 `spawnTeammate()`,返回 `teammate_spawned`。如果在 teammate 内继续带 `name` spawn teammate,会被拒绝,因为 team roster 是扁平结构。
|
||||
|
||||
### 隔离与工作目录参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 作用 | 影响路径 |
|
||||
|------|------|------|------|----------|
|
||||
| `isolation` | `'worktree'`,内部构建还支持 `'remote'` | 否 | 覆盖 agent definition 的隔离模式 | `worktree` 创建临时 git worktree;`remote` 委派到 CCR,直接返回 `remote_launched` |
|
||||
| `cwd` | `string` | 否 | 指定子 Agent 的运行目录 | 仅在 `KAIROS` schema 中暴露;会通过 `runWithCwdOverride()` 改变文件和 shell 操作的 cwd |
|
||||
|
||||
`isolation` 入参优先级高于 agent definition 里的 `isolation`。`cwd` 的 schema 文案要求不要和 `isolation: "worktree"` 同时使用;实现上如果两者同时出现,`cwd` 会优先成为运行目录,但仍可能创建 worktree,因此调用方应视为互斥参数。
|
||||
|
||||
### 参数可见性与实际效果
|
||||
|
||||
| 参数 | 可能不可见的情况 | 说明 |
|
||||
|------|------------------|------|
|
||||
| `run_in_background` | `DISABLE_BACKGROUND_TASKS` 生效,或 `isForkSubagentEnabled()` 为 true | fork gate 开启时所有 `AgentTool` spawn 都会被强制异步,所以不需要让模型再选择 |
|
||||
| `cwd` | 非 `KAIROS` 构建 / 模式 | schema 会 omit 掉,但实现类型仍保留该字段 |
|
||||
| `isolation: "remote"` | 非内部构建 | 外部构建只接受 `worktree` |
|
||||
| `model` | coordinator mode 或 fork path | coordinator 会清空 model override;fork 需要继承父模型以保持请求前缀和行为一致 |
|
||||
|
||||
### 参数与 agent definition 的优先级
|
||||
|
||||
| 配置项 | 调用参数 | agent definition | 最终规则 |
|
||||
|--------|----------|------------------|----------|
|
||||
| agent 类型 | `subagent_type` | 默认 / active agents | 显式 `subagent_type` 优先;省略时由 fork gate 决定 fork 或 `general-purpose` |
|
||||
| 模型 | `model` | `selectedAgent.model` | 普通命名 agent 中调用参数优先;没有参数则用定义;再没有则继承父模型 |
|
||||
| 后台运行 | `run_in_background` | `selectedAgent.background` | 任一为 true 都会异步;还有 coordinator、assistant、fork gate 等强制异步条件 |
|
||||
| 隔离 | `isolation` | `selectedAgent.isolation` | 调用参数优先 |
|
||||
| 权限模式 | 无本地覆盖参数 | `selectedAgent.permissionMode` | 普通子 Agent 用 definition 的 `permissionMode`,默认 `acceptEdits`;fork 使用 `bubble` |
|
||||
| 工具集合 | 无调用参数 | `selectedAgent.tools` | 普通子 Agent 在 `runAgent()` 里按 definition 过滤;fork 使用父级 exact tools |
|
||||
|
||||
## Agent Definition 字段
|
||||
|
||||
`AgentTool` 的调用参数只描述"这一次怎么 spawn"。真正决定 agent 默认能力的是 agent definition。自定义 agent 可以来自用户 / 项目目录、JSON 配置、插件或内置定义,核心字段最终都会归一到 `AgentDefinition`。
|
||||
|
||||
### 常用 frontmatter
|
||||
|
||||
| 字段 | 类型 | 作用 | 运行时影响 |
|
||||
|------|------|------|------------|
|
||||
| `name` | `string` | agent 类型名 | 模型通过 `subagent_type` 匹配它;插件 agent 可能带命名空间前缀 |
|
||||
| `description` | `string` | 使用场景说明 | 进入可用 agent 列表,帮助主模型选择 |
|
||||
| `tools` | `string[]` | 允许的工具集合 | `runAgent()` 内经 `resolveAgentTools()` 过滤;`['*']` 表示全量可用工具 |
|
||||
| `disallowedTools` | `string[]` | 禁用工具集合 | JSON agent 支持该字段,用于从允许集合中排除 |
|
||||
| `prompt` | `string` | agent system prompt 主体 | 普通命名子 Agent 会用它构建自己的 system prompt |
|
||||
| `model` | `string` | 默认模型 | 可被 `Agent({ model })` 覆盖;`inherit` 表示继承父模型 |
|
||||
| `effort` | effort level 或 number | 推理努力级别 | 传给 agent 运行配置 |
|
||||
| `permissionMode` | permission mode | 默认权限模式 | 普通子 Agent 工具池组装时使用;省略则默认 `acceptEdits` |
|
||||
| `background` | `boolean` | 是否总是后台运行 | 为 true 时,即使调用参数没有 `run_in_background` 也走异步 |
|
||||
| `isolation` | `'worktree'` / `'remote'` | 默认隔离模式 | 可被调用参数 `isolation` 覆盖 |
|
||||
| `maxTurns` | positive integer | 最大 agentic turns | 传给 `query()`,防止子 Agent 无限循环 |
|
||||
| `color` | agent color | UI 颜色 | 用于 grouped UI、任务面板、teammate 展示 |
|
||||
| `memory` | `'user' \| 'project' \| 'local'` | 持久记忆作用域 | 在 system prompt 中追加 agent memory,并按 scope 读写目录 |
|
||||
|
||||
示例:
|
||||
|
||||
```md
|
||||
---
|
||||
name: code-reviewer
|
||||
description: Review a code change and find correctness risks
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
model: sonnet
|
||||
permissionMode: acceptEdits
|
||||
background: true
|
||||
maxTurns: 8
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a focused code reviewer. Prioritize bugs, regressions, and missing tests.
|
||||
```
|
||||
|
||||
### MCP、Hooks、Skills
|
||||
|
||||
| 字段 | 作用 | 说明 |
|
||||
|------|------|------|
|
||||
| `requiredMcpServers` | 启动前必须存在的 MCP server 模式 | `AgentTool.call()` 会等待 pending server,最长约 30 秒;没有可用工具则报错 |
|
||||
| `mcpServers` | agent 专属 MCP server | `runAgent()` 初始化,生命周期跟随该子 Agent |
|
||||
| `hooks` | agent 生命周期内注册的 hooks | `runAgent()` 会注册 frontmatter hooks;agent 停止时清理 session hooks |
|
||||
| `skills` | 预加载 skill 名称 | `runAgent()` 会解析并注入对应 skill;插件 skill 支持命名空间或后缀匹配 |
|
||||
| `initialPrompt` | 首个 user turn 前置内容 | 可用于启动时固定注入额外说明 |
|
||||
|
||||
这些字段属于 agent definition,不是 `Agent(...)` 调用参数。调用方不能在一次 `Agent` tool_use 里临时传入 `tools`、`hooks` 或 `skills` 来覆盖 agent 定义。
|
||||
|
||||
### runAgent() 扩展点
|
||||
|
||||
`runAgent()` 不只是把 prompt 丢给模型。它会在进入 query loop 前后挂载一组 agent 级扩展点:
|
||||
|
||||
| 扩展点 | 时机 | 作用 |
|
||||
|--------|------|------|
|
||||
| `SubagentStart` hooks | 子 Agent query loop 启动前 | 允许 hook 修改或补充启动上下文 |
|
||||
| frontmatter `hooks` | agent session 初始化时注册 | 只在这个子 Agent 的 session 内生效,结束后清理 |
|
||||
| preload `skills` | system prompt / skill 解析阶段 | 把指定 skill 的说明和资源注入 agent 可见上下文 |
|
||||
| agent `memory` | system prompt 构建时 | 按 `user` / `project` / `local` scope 读取 agent memory,并追加到 agent prompt |
|
||||
| sidechain transcript | query loop 运行时 | 记录子 Agent 的独立消息链,供恢复、调试和 `SendMessage` 续跑使用 |
|
||||
|
||||
这些扩展点解释了为什么同样是 `runAgent()`,不同 agent definition 会表现出不同的工具边界、启动行为和长期上下文。
|
||||
|
||||
## 路由规则
|
||||
|
||||
`AgentTool.call()` 首先决定这次调用到底要跑哪一种 agent:
|
||||
|
||||
```text
|
||||
subagent_type 有值
|
||||
-> 使用命名 agent
|
||||
|
||||
subagent_type 省略 && isForkSubagentEnabled() 为 true
|
||||
-> 使用 fork agent
|
||||
|
||||
subagent_type 省略 && fork gate 关闭
|
||||
-> 回退到 general-purpose
|
||||
```
|
||||
|
||||
命名 agent 来自内置 agent、用户配置目录、插件 agent 等定义。fork agent 是代码里内置的特殊 agent,定义在 `forkSubagent.ts`,它不是普通专业角色,而是"继承父上下文的 worker"。
|
||||
|
||||
## 权限模型
|
||||
|
||||
子 Agent 权限要分成三层看:能不能启动这个 agent、这个 agent 有哪些工具、工具执行时如何处理权限请求。
|
||||
|
||||
### 启动权限
|
||||
|
||||
`AgentTool` 自身是一个工具调用,因此先经过普通工具权限系统。随后 `AgentTool.call()` 还会做 agent 级过滤:
|
||||
|
||||
| 检查 | 说明 |
|
||||
|------|------|
|
||||
| `filterDeniedAgents()` | 根据权限规则过滤被禁止的 agent 类型 |
|
||||
| `requiredMcpServers` | 如果 agent 声明必需 MCP server,会等待它们连接,失败或超时则停止 |
|
||||
| teammate 限制 | in-process teammate 不能继续 spawn teammate,也不能 spawn 后台 agent |
|
||||
| fork 递归保护 | fork worker 里不能再次 fork |
|
||||
|
||||
被权限规则 deny 的命名 agent 会直接报错,而不是退回到别的 agent。这样可以避免模型绕过用户或配置里的拒绝规则。
|
||||
|
||||
### 工具池权限
|
||||
|
||||
普通命名子 Agent 不直接继承父 agent 当前那一轮的工具池限制。它会用自己的权限模式重新组装工具池:
|
||||
|
||||
```ts
|
||||
const workerPermissionContext = {
|
||||
...appState.toolPermissionContext,
|
||||
mode: selectedAgent.permissionMode ?? 'acceptEdits',
|
||||
}
|
||||
|
||||
const workerTools = assembleToolPool(
|
||||
workerPermissionContext,
|
||||
appState.mcp.tools,
|
||||
)
|
||||
```
|
||||
|
||||
这里有几个重要含义:
|
||||
|
||||
| 维度 | 行为 |
|
||||
|------|------|
|
||||
| 默认权限模式 | 如果 agent 定义没有写 `permissionMode`,默认使用 `acceptEdits` |
|
||||
| 全局 allow / deny 规则 | 仍然来自 `appState.toolPermissionContext` |
|
||||
| agent 自己的 `tools` 字段 | 在 `runAgent()` 内通过 `resolveAgentTools()` 继续过滤 |
|
||||
| MCP 工具 | 来自当前 AppState 中已经连接的 MCP 工具;agent 也可以声明专属 MCP server |
|
||||
|
||||
fork agent 是例外。它为了保持父子请求的 prompt cache 前缀一致,会使用父级 exact tools:
|
||||
|
||||
```text
|
||||
useExactTools: true
|
||||
availableTools: toolUseContext.options.tools
|
||||
```
|
||||
|
||||
因此 fork 的权限策略不是"重新组装工具池",而是"继承父工具定义,并用 `bubble` 权限模式把权限请求上浮到父终端"。
|
||||
|
||||
### 权限模式速览
|
||||
|
||||
| 模式 | 子 Agent 中的意义 |
|
||||
|------|------------------|
|
||||
| `acceptEdits` | 默认模式。通常允许读和编辑类安全路径,危险操作仍走权限系统 |
|
||||
| `default` / 其他普通模式 | 按主权限系统规则询问或放行 |
|
||||
| `bypassPermissions` | 显式危险模式,只有用户启用跳过权限时才应出现 |
|
||||
| `bubble` | fork 专用思路:权限请求冒泡到父级会话处理 |
|
||||
|
||||
## 同步子 Agent
|
||||
|
||||
同步子 Agent 是默认路径:没有显式 `run_in_background: true`,agent 定义也没有 `background: true`,并且没有被 coordinator / assistant mode / fork gate 等机制强制异步。
|
||||
|
||||
同步等待发生在普通工具调用链里。外层 `toolExecution.ts` 会执行:
|
||||
|
||||
```ts
|
||||
const result = await tool.call(...)
|
||||
```
|
||||
|
||||
如果这个工具是 `AgentTool`,那么 `AgentTool.call()` 会在内部跑完整个子 Agent:
|
||||
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> agentIterator = runAgent(...)[Symbol.asyncIterator]()
|
||||
-> while true:
|
||||
await agentIterator.next()
|
||||
收集 assistant / user 消息
|
||||
转发 progress 给 UI / SDK
|
||||
如果 result.done,跳出
|
||||
-> finalizeAgentTool(agentMessages, ...)
|
||||
-> return { data: { status: "completed", ...agentResult } }
|
||||
```
|
||||
|
||||
返回后,`mapToolResultToToolResultBlockParam()` 把 `completed` 结果转成当前 turn 的 `tool_result`。然后 `query.ts` 把这个 tool result 放进消息列表,进入下一轮模型调用。
|
||||
|
||||
也就是说,同步子 Agent 不通过统一队列回注结果。主模型是在这次 `Agent` tool call 上等待,直到拿到最终 `tool_result` 才继续。
|
||||
|
||||
### 同步子 Agent 的可后台化
|
||||
|
||||
同步子 Agent 注册为 foreground task,因此它可以中途被后台化。循环里会同时等待下一条子 Agent 消息和后台化信号:
|
||||
|
||||
```ts
|
||||
const raceResult = await Promise.race([
|
||||
nextMessagePromise.then(result => ({ type: 'message', result })),
|
||||
backgroundPromise,
|
||||
])
|
||||
```
|
||||
|
||||
如果后台化信号先到,当前前台 iterator 会被清理,新的后台 `runAgent(..., isAsync: true)` 接管剩余工作。此时 `AgentTool.call()` 不再等待最终结果,而是返回 `async_launched`,后续完成结果走任务通知队列。
|
||||
|
||||
## 异步子 Agent
|
||||
|
||||
异步子 Agent 的触发条件包括:
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| `run_in_background: true` | 模型显式要求后台运行 |
|
||||
| agent 定义 `background: true` | 该 agent 总是后台运行 |
|
||||
| coordinator mode | worker 统一异步,方便编排 |
|
||||
| fork subagent gate 开启 | 当前实现会强制所有 `AgentTool` spawn 使用异步通知模型 |
|
||||
| assistant / kairos mode | 避免同步子任务阻塞输入队列 |
|
||||
| proactive active | 主动循环下也可能强制异步 |
|
||||
|
||||
异步路径不会等待子 Agent 完成:
|
||||
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> registerAsyncAgent(...)
|
||||
-> void runAsyncAgentLifecycle(...)
|
||||
-> return { status: "async_launched", agentId, outputFile }
|
||||
```
|
||||
|
||||
后台生命周期在 `runAsyncAgentLifecycle()` 中完成:
|
||||
|
||||
```text
|
||||
runAsyncAgentLifecycle()
|
||||
-> for await message of runAgent(...)
|
||||
-> updateAsyncAgentProgress(...)
|
||||
-> finalizeAgentTool(...)
|
||||
-> completeAsyncAgent(...)
|
||||
-> enqueueAgentNotification(...)
|
||||
```
|
||||
|
||||
异步 Agent 使用独立 `AbortController`。普通 ESC 取消主线程不会自动杀掉后台 Agent;后台 Agent 需要通过任务停止、bulk kill 或 task 管理命令显式结束。
|
||||
|
||||
## 完成通知与统一队列
|
||||
|
||||
后台 Agent 完成后,`enqueueAgentNotification()` 会生成一条 XML 形态的 `<task-notification>`:
|
||||
|
||||
```xml
|
||||
<task-notification>
|
||||
<task-id>...</task-id>
|
||||
<tool-use-id>...</tool-use-id>
|
||||
<output-file>...</output-file>
|
||||
<status>completed</status>
|
||||
<summary>Agent "..." completed</summary>
|
||||
<result>...</result>
|
||||
<usage>...</usage>
|
||||
</task-notification>
|
||||
```
|
||||
|
||||
这条消息通过 `enqueuePendingNotification({ mode: 'task-notification' })` 进入统一 command queue。
|
||||
|
||||
### 队列什么时候消费
|
||||
|
||||
| 场景 | 消费方式 |
|
||||
|------|----------|
|
||||
| REPL / TUI | `useQueueProcessor()` 订阅队列;当 query 空闲且没有本地 JSX UI 阻塞时,调用 `processQueueIfReady()` |
|
||||
| CLI / SDK headless | `print.ts` 中的 `drainCommandQueue()` 在 turn 之间持续消费;如果还有后台任务运行,会继续等待并 drain 新通知 |
|
||||
| 子 Agent 内部 | `query.ts` 会消费带有当前 `agentId` 的 `task-notification`,主线程只消费 `agentId === undefined` 的消息 |
|
||||
|
||||
`task-notification` 最终会作为 user-role 消息或 attachment 进入下一轮模型上下文。模型因此能看到后台结果,并决定是否综合、继续行动或回复用户。
|
||||
|
||||
### 还有哪些消息走同一队列
|
||||
|
||||
统一队列不只用于后台 Agent。常见来源包括:
|
||||
|
||||
| 来源 | mode | 用途 |
|
||||
|------|------|------|
|
||||
| 用户在当前 turn 未结束时继续输入 | `prompt` / `bash` | 排队到下一轮处理 |
|
||||
| 后台 shell / monitor 结束或卡住提醒 | `task-notification` | 通知模型命令状态 |
|
||||
| remote agent / ultraplan / ultrareview 完成 | `task-notification` | 把远程结果交给本地模型 |
|
||||
| scheduled task / cron | `prompt` | 定时触发主模型任务 |
|
||||
| Chrome / MCP channel 推送 | `prompt` | 外部系统主动注入消息 |
|
||||
| hook 阻塞错误 | `task-notification` | 唤醒模型处理 stop hook 错误 |
|
||||
| orphaned permission response | `orphaned-permission` | 处理工具权限回复比原请求更晚到达的情况 |
|
||||
|
||||
队列优先级是 `now > next > later`。`enqueue()` 默认 `next`,`enqueuePendingNotification()` 默认 `later`,这样系统通知不会抢在用户输入前面。
|
||||
|
||||
## 继续通信与任务控制
|
||||
|
||||
后台子 Agent 返回 `async_launched` 后,主模型不应该直接假装已经知道最终答案。它有三种后续操作面:发消息、读输出、停止任务。
|
||||
|
||||
### SendMessage
|
||||
|
||||
`SendMessage` 用来给运行中或曾经启动过的 agent 追加消息。它可以通过两种地址找到本地后台 agent:
|
||||
|
||||
| 地址 | 来源 | 行为 |
|
||||
|------|------|------|
|
||||
| `name` | `Agent({ name, ... })` 注册到 `agentNameRegistry` | 先解析成 agentId,再发送 |
|
||||
| raw `agentId` | `async_launched` 或 `completed` tool result 中返回 | 直接定位对应 task 或 transcript |
|
||||
|
||||
发送 plain text message 时必须提供 `summary`,因为 UI 和权限摘要需要一个短描述。`to: "*"` 表示广播给 teammate team;结构化消息不能广播。
|
||||
|
||||
`SendMessage` 对本地后台 agent 的行为分三种:
|
||||
|
||||
| 目标状态 | 行为 | 结果 |
|
||||
|----------|------|------|
|
||||
| task 仍在 `running` | 调用 `queuePendingMessage(agentId, message, ...)` | 消息进入该 task 的 `pendingMessages`,在子 Agent 下一次 tool round / loop 边界被投递 |
|
||||
| task 已停止但还在 AppState | 调用 `resumeAgentBackground(...)` | 用这条消息把 agent 后台恢复运行,完成后仍通过通知回来 |
|
||||
| task 已从 AppState 清掉 | 仍尝试 `resumeAgentBackground(...)` | 如果 sidechain transcript 还在,就从 transcript 恢复;否则返回失败 |
|
||||
|
||||
这意味着 `SendMessage` 不是只能在 agent 正在跑时使用。隔了很久以后,只要调用方还知道 `name` 或 `agentId`,并且对应 transcript 没被清理,就可能恢复并继续这个 agent。反过来,如果 task 状态和 transcript 都没了,`SendMessage` 无法凭空重建上下文。
|
||||
|
||||
几个容易误会的点:
|
||||
|
||||
| 点 | 说明 |
|
||||
|----|------|
|
||||
| running agent 不会立刻中断当前工具调用 | 消息先排进 `pendingMessages`,等 agent loop 到安全边界再处理 |
|
||||
| stopped agent 会变成新的后台运行 | `resumeAgentBackground()` 返回 output file,之后靠完成通知回注 |
|
||||
| `name` 只在注册还在时可靠 | name registry 是运行时状态;跨很久恢复时 raw `agentId` 更稳定 |
|
||||
| cross-session send 有额外限制 | `bridge:` / `uds:` 地址只支持 plain text,且可能需要显式权限或连接状态 |
|
||||
|
||||
### TaskOutput
|
||||
|
||||
`TaskOutput` 是旧式读取后台任务输出的工具,当前 prompt 明确建议优先使用 `Read` 读取任务返回的 `output_file`。它仍然可用,主要行为如下:
|
||||
|
||||
| 参数 | 行为 |
|
||||
|------|------|
|
||||
| `task_id` | 要读取的后台任务 id |
|
||||
| `block: false` | 非阻塞读取当前状态和已有输出 |
|
||||
| `block: true` | 等待任务完成,默认行为 |
|
||||
| `timeout` | 阻塞等待的最大时长 |
|
||||
|
||||
如果 `block: true` 等到任务完成,`TaskOutput` 会把 task 标记为 `notified`,避免再重复发送完成通知。因为这个工具已经 deprecated,新代码和模型提示都更推荐直接读 `output_file`。
|
||||
|
||||
### TaskStop
|
||||
|
||||
`TaskStop` 停止运行中的后台任务。它接受 `task_id`,也兼容旧的 `shell_id`。校验规则很直接:任务必须存在且状态是 `running`,否则报错。
|
||||
|
||||
停止后会调用统一的 `stopTask()`,具体 task 类型再映射到各自 kill 逻辑,例如本地 agent 会 abort 自己的 `AbortController`,shell task 会停止进程,remote task 会走 remote 停止路径。
|
||||
|
||||
## 失败、取消与清理
|
||||
|
||||
子 Agent 的异常路径主要分同步和异步看。
|
||||
|
||||
### 同步路径
|
||||
|
||||
同步子 Agent 抛出 `AbortError` 时,`AgentTool.call()` 会把它继续抛给外层工具框架,主 turn 进入正常的中断处理。非 abort 错误会先记录;如果已经收集到 assistant 消息,会尽量 `finalizeAgentTool()` 返回部分结果,让主模型看到已有进展。如果完全没有 assistant 消息,则重新抛出错误。
|
||||
|
||||
同步 finally 会做这些清理:
|
||||
|
||||
| 清理 | 作用 |
|
||||
|------|------|
|
||||
| 清空 background hint UI | 避免前台提示残留 |
|
||||
| `stopForegroundSummarization()` | 停止前台摘要定时器 |
|
||||
| `unregisterAgentForeground()` | 子 Agent 未后台化时,从 foreground task 注册表移除 |
|
||||
| SDK task notification | 给 SDK / VS Code 面板发完成、失败或 stopped 事件 |
|
||||
| `clearInvokedSkillsForAgent()` | 清理 agent 作用域 skill 状态 |
|
||||
| `clearDumpState()` | 清理 dump/transcript 调试状态 |
|
||||
| `cleanupWorktreeIfNeeded()` | 未后台化时清理或保留 worktree |
|
||||
|
||||
### 异步路径
|
||||
|
||||
异步路径由 `runAsyncAgentLifecycle()` 兜住异常:
|
||||
|
||||
| 情况 | 状态更新 | 通知 |
|
||||
|------|----------|------|
|
||||
| 正常完成 | `completeAsyncAgent(...)` | `enqueueAgentNotification(status: completed)` |
|
||||
| `AbortError` | `killAsyncAgent(...)` | `enqueueAgentNotification(status: killed)`,带 partial result |
|
||||
| 其他错误 | `failAsyncAgent(...)` | `enqueueAgentNotification(status: failed)`,带 error |
|
||||
|
||||
代码会先更新 task 状态,再做 handoff classifier 或 worktree cleanup 这类可能较慢的附加工作。这个顺序很重要:`TaskOutput(block=true)` 等待的是 task 进入 terminal status,不能被后续分类器或 git 清理卡住。
|
||||
|
||||
通知也有防重机制。`enqueueAgentNotification()` 会先原子检查并设置 `task.notified`;如果已经通知过,就不再重复入队。
|
||||
|
||||
## AgentTool fork
|
||||
|
||||
AgentTool fork 是 `Agent` 工具的一种特殊路由,不是普通命名 agent。
|
||||
|
||||
### Gate
|
||||
|
||||
fork 默认关闭。需要构建/运行时启用 `FORK_SUBAGENT` feature,例如开发时显式设置:
|
||||
|
||||
```powershell
|
||||
$env:FEATURE_FORK_SUBAGENT='1'; bun run dev
|
||||
```
|
||||
|
||||
即使 feature 打开,以下场景也会强制关闭:
|
||||
|
||||
| 场景 | 原因 |
|
||||
|------|------|
|
||||
| coordinator mode | coordinator 已有自己的委派模型 |
|
||||
| non-interactive session | pipe / SDK 场景下避免不可见的 fork 嵌套 |
|
||||
|
||||
### 路径
|
||||
|
||||
```text
|
||||
主模型
|
||||
-> Agent({ prompt }),没有 subagent_type
|
||||
-> AgentTool.call()
|
||||
-> isForkSubagentEnabled()
|
||||
-> selectedAgent = FORK_AGENT
|
||||
-> buildForkedMessages(...)
|
||||
-> runAgent(... useExactTools: true, forkContextMessages: parent messages)
|
||||
-> 注册 task / transcript / notification
|
||||
```
|
||||
|
||||
fork 的目标是让多个 worker 共享父请求的 prompt cache 前缀。它会:
|
||||
|
||||
| 维度 | fork 行为 |
|
||||
|------|-----------|
|
||||
| system prompt | 使用父级已经渲染好的 system prompt |
|
||||
| 对话历史 | 传入父级完整 `toolUseContext.messages` |
|
||||
| tools | 使用父级 exact tools,不重新过滤 |
|
||||
| thinking config | 继承父级配置,避免 cache key 变化 |
|
||||
| placeholder tool_result | 多个 fork 使用相同占位文本,只有最后 directive 不同 |
|
||||
| 权限 | `permissionMode: 'bubble'` |
|
||||
|
||||
这就是为什么 fork path 和普通 agent path 在 tool pool、prompt 构造、模型继承上都不同。
|
||||
|
||||
### 递归保护
|
||||
|
||||
fork worker 保留 `Agent` 工具是为了让工具定义字节和父级一致,但代码会拒绝 fork 内再次 fork:
|
||||
|
||||
| 保护 | 说明 |
|
||||
|------|------|
|
||||
| `querySource === 'agent:builtin:fork'` | 直接识别当前已经在 fork worker 内 |
|
||||
| `<fork-boilerplate>` 扫描 | 兜底识别 fork 指令已经存在于上下文 |
|
||||
|
||||
fork worker 应该直接完成任务,而不是继续委派。
|
||||
|
||||
## Slash command fork
|
||||
|
||||
slash command fork 是 skill / command 的执行模式。它由 skill frontmatter 控制:
|
||||
|
||||
```md
|
||||
---
|
||||
name: code-review
|
||||
context: fork
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
---
|
||||
```
|
||||
|
||||
加载 skill 时,`frontmatter.context === 'fork'` 会被解析成 command 的 `context: 'fork'`。执行 slash command 时:
|
||||
|
||||
```text
|
||||
用户输入 /code-review
|
||||
-> processSlashCommand(...)
|
||||
-> command.context === 'fork'
|
||||
-> executeForkedSlashCommand(...)
|
||||
-> prepareForkedCommandContext(...)
|
||||
-> runAgent(...)
|
||||
```
|
||||
|
||||
普通交互模式下,`executeForkedSlashCommand()` 会同步跑完子 Agent,显示 progress UI,然后把结果作为本地命令输出返回给主对话。
|
||||
|
||||
assistant / kairos 模式下,它会 fire-and-forget:后台 runner 完成后,把结果包装成隐藏 prompt 重新放入 command queue。这样多个 scheduled task 不会在启动时串行阻塞用户输入。
|
||||
|
||||
## `runForkedAgent()`
|
||||
|
||||
`runForkedAgent()` 是内部服务用的执行器,不暴露给模型,也不产生 `Agent` tool_result。
|
||||
|
||||
它的输入是 `cacheSafeParams`、`promptMessages`、`canUseTool` 等运行时对象,直接跑 query loop:
|
||||
|
||||
```text
|
||||
内部服务
|
||||
-> runForkedAgent({ promptMessages, cacheSafeParams, ... })
|
||||
-> createSubagentContext(...)
|
||||
-> query(...)
|
||||
-> 返回 ForkedAgentResult
|
||||
```
|
||||
|
||||
常见调用方:
|
||||
|
||||
| 调用方 | 用途 |
|
||||
|--------|------|
|
||||
| compact | 对话压缩 |
|
||||
| extractMemories / sessionMemory | 记忆抽取和维护 |
|
||||
| promptSuggestion / speculation | 提示建议和预测 |
|
||||
| sideQuestion | 不打扰主上下文的临时问答 |
|
||||
| agentSummary | 后台 agent 摘要 |
|
||||
| autoDream | 后台记忆整合 |
|
||||
|
||||
它和 AgentTool fork 的共同点是"分叉执行",但边界完全不同:
|
||||
|
||||
| 维度 | AgentTool fork | `runForkedAgent()` |
|
||||
|------|----------------|--------------------|
|
||||
| 调用者 | 模型通过 `Agent` 工具调用 | 运行时服务直接调用 |
|
||||
| 协议层 | 经过 Tool schema / tool_use / tool_result | 不经过 Tool 协议 |
|
||||
| 可见性 | 主模型会先看到 `async_launched`,完成后看到通知 | 结果由内部调用方处理 |
|
||||
| 主要目标 | 并行 worker + prompt cache 共享 | 内部辅助任务复用 query loop |
|
||||
|
||||
## Worktree 隔离
|
||||
|
||||
`Agent` 工具支持 `isolation: "worktree"`。启用后,子 Agent 在临时 git worktree 中运行,适合实现型或实验型任务。
|
||||
|
||||
生命周期:
|
||||
|
||||
| 阶段 | 行为 |
|
||||
|------|------|
|
||||
| 创建 | 使用 agent id 派生 slug,创建独立 worktree |
|
||||
| CWD 覆盖 | `runWithCwdOverride(worktreePath, fn)` 让工具在 worktree 内执行 |
|
||||
| fork + worktree | 额外注入路径翻译提示,提醒 worker 重新读取文件 |
|
||||
| 清理 | 无变更则移除 worktree;有变更则保留并把路径返回给主模型 |
|
||||
|
||||
如果 worktree 是 hook-based,代码会保留它,因为无法可靠判断 VCS 变更。
|
||||
|
||||
## 结果格式
|
||||
|
||||
`AgentTool.mapToolResultToToolResultBlockParam()` 根据状态返回不同 tool result:
|
||||
|
||||
| 状态 | 结果 |
|
||||
|------|------|
|
||||
| `completed` | 子 Agent 输出内容,可附带 `agentId`、worktree 信息和 usage |
|
||||
| `async_launched` | 后台 agent id、output file 路径、等待完成通知的说明 |
|
||||
| `teammate_spawned` | teammate id、name、team name |
|
||||
| `remote_launched` | remote task id、session URL、output file |
|
||||
|
||||
同步子 Agent 的 `completed` 结果直接成为当前 `Agent` tool call 的 `tool_result`。异步子 Agent 的首次 tool result 是 `async_launched`,最终输出通过 `<task-notification>` 回到模型。
|
||||
|
||||
### 输出字段
|
||||
|
||||
| 状态 | 关键字段 | 说明 |
|
||||
|------|----------|------|
|
||||
| `completed` | `content`、`agentId`、`totalTokens`、`totalToolUseCount`、`totalDurationMs` | 同步子 Agent 的最终结果;普通 agent 会附带可继续通信的 `agentId` |
|
||||
| `async_launched` | `agentId`、`description`、`prompt`、`outputFile`、`canReadOutputFile` | 后台 agent 已启动;最终结果稍后通过通知到达 |
|
||||
| `teammate_spawned` | `teammate_id`、`name`、`team_name` | teammate 已启动,后续通过 mailbox / SendMessage 协作 |
|
||||
| `remote_launched` | `taskId`、`sessionUrl`、`outputFile`、`description` | remote CCR agent 已启动,完成后走 remote task 通知 |
|
||||
|
||||
一次性内置 agent 可以省略 `agentId` / `SendMessage` hint 和 usage trailer,避免把不会继续通信的信息塞进上下文。
|
||||
|
||||
### outputSchema 与 tool_result
|
||||
|
||||
`AgentTool` 的 `outputSchema` 描述的是 `call()` 返回的结构化 data;`mapToolResultToToolResultBlockParam()` 再把这些 data 映射成模型实际看到的 `tool_result` 文本块。读代码时可以按这个顺序看:
|
||||
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> return { data: { status, ...fields } }
|
||||
-> mapToolResultToToolResultBlockParam(data, toolUseID)
|
||||
-> ToolResultBlockParam
|
||||
-> query.ts 把 tool_result 放进下一轮消息
|
||||
```
|
||||
|
||||
四类结果的字段重点:
|
||||
|
||||
| status | data 字段 | 模型可见信息 |
|
||||
|--------|-----------|--------------|
|
||||
| `completed` | `content`、`agentId`、usage、可选 worktree result | 子 Agent 最终输出;如果可继续通信,会提示可用 `SendMessage` |
|
||||
| `async_launched` | `agentId`、`description`、`prompt`、`outputFile`、`canReadOutputFile` | 后台已启动;提示等待通知或读取 output file |
|
||||
| `teammate_spawned` | `teammate_id`、`name`、`team_name` | teammate 已加入 team;后续通过 mailbox / `SendMessage` 协作 |
|
||||
| `remote_launched` | `taskId`、`sessionUrl`、`outputFile`、`description` | remote task 已启动;本地模型等待 remote task notification |
|
||||
|
||||
这里的 `status` 是结果分发的主轴。后面 catch / finally 中的 failed、killed、cleanup 逻辑不会改写已经返回的同步 `tool_result`;后台路径会通过 task state 和 notification 把终态再交给主模型。
|
||||
|
||||
## 生命周期状态机
|
||||
|
||||
把本地子 Agent 当成 task 看,核心状态可以这样理解:
|
||||
|
||||
```text
|
||||
AgentTool.call()
|
||||
-> resolve route
|
||||
-> create optional worktree
|
||||
-> register foreground 或 register async task
|
||||
-> runAgent()
|
||||
-> completed / failed / killed
|
||||
-> tool_result 或 task-notification
|
||||
-> cleanup agent-scoped state
|
||||
```
|
||||
|
||||
同步和异步的差别不在于是否调用 `runAgent()`,而在于谁等待 `runAgent()`:
|
||||
|
||||
| 路径 | 谁等待 | 主模型什么时候继续 |
|
||||
|------|--------|--------------------|
|
||||
| 同步子 Agent | `AgentTool.call()` 自己 `for await` 子 Agent 消息流 | 子 Agent 完成并返回 `tool_result` 后 |
|
||||
| 自动后台化 | 前台先等;超时后前台 iterator 退出,后台 lifecycle 接管 | `AgentTool.call()` 返回 `async_launched` 后 |
|
||||
| 异步子 Agent | `runAsyncAgentLifecycle()` 在后台等 | 主模型收到 `async_launched` 后立即继续 |
|
||||
| slash command fork 普通交互 | `executeForkedSlashCommand()` 等 | slash command 完成后 |
|
||||
| slash command fork assistant / kairos | fire-and-forget 后台 runner 等 | 启动后主输入流程继续,完成后隐藏 prompt 回注 |
|
||||
| `runForkedAgent()` | 内部调用方自己等 | 不进入主模型 tool_result 协议 |
|
||||
|
||||
所以“同步子 Agent 怎么等完成”最短答案是:外层工具执行器 `await tool.call()`,而 `AgentTool.call()` 内部持续消费 `runAgent()` 的 async iterator,直到 iterator `done` 或异常。
|
||||
|
||||
## 等待与回注方式对照
|
||||
|
||||
子 Agent 结果回到主模型有三种主要机制:
|
||||
|
||||
| 机制 | 适用路径 | 回注载体 | 是否阻塞当前 turn |
|
||||
|------|----------|----------|-------------------|
|
||||
| `tool_result` | 同步命名子 Agent | 当前 `Agent` tool_use 对应的 tool result | 是 |
|
||||
| `<task-notification>` | 异步 / 后台本地 Agent、remote task、后台 shell 等 | 统一 command queue 中的 task notification | 否 |
|
||||
| hidden prompt / command queue prompt | assistant / kairos 的 slash command fork、scheduled task 等 | queue 中的 prompt 类消息 | 否 |
|
||||
|
||||
这里容易混淆的是:后台子 Agent 完成后不会“补写”原来的 `tool_result`。原来的 `Agent` tool call 已经返回了 `async_launched`;最终结果是新的一条队列消息,下一轮模型看到后再决定怎么整合。
|
||||
|
||||
## Progress、UI 与 Transcript
|
||||
|
||||
子 Agent 有三条并行的“可观察输出”:给用户看的 progress、给模型看的最终结果、给系统恢复用的 transcript。
|
||||
|
||||
| 输出 | 同步路径 | 异步路径 | 用途 |
|
||||
|------|----------|----------|------|
|
||||
| progress UI | `AgentTool.call()` 消费子 Agent 消息时实时转发给 UI / SDK | `runAsyncAgentLifecycle()` 更新 task progress state | 让用户看到子 Agent 正在做什么 |
|
||||
| output file | 同步路径也会写入 side output,方便调试和恢复 | 后台 task 的主要可读输出,`async_launched` 会返回路径 | 主模型可用 `Read(outputFile)` 查看 |
|
||||
| sidechain transcript | `runAgent()` 记录独立消息链 | 同样记录,且用于后台恢复 | `SendMessage`、resume、debug、summary 都依赖它 |
|
||||
| task state | foreground task 注册表记录同步运行状态 | LocalAgentTask 记录 running / completed / failed / killed | UI、`TaskOutput`、通知防重都看这里 |
|
||||
|
||||
同步 progress 是“边跑边展示,最后一次性返回 tool_result”。异步 progress 是“边跑边写 task state,最后入队 task notification”。sidechain transcript 不等同于用户可见输出;它是系统用来重建 agent 上下文的消息日志。
|
||||
|
||||
## 典型调用示例
|
||||
|
||||
### 同步命名子 Agent
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "review parser bug",
|
||||
"prompt": "Review the parser changes and identify correctness risks.",
|
||||
"subagent_type": "code-reviewer"
|
||||
}
|
||||
```
|
||||
|
||||
适合短任务或必须立即拿结果才能继续的任务。主模型会等到子 Agent 输出 `completed`。
|
||||
|
||||
### 后台命名子 Agent
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "run regression suite",
|
||||
"prompt": "Run the regression tests and summarize failures.",
|
||||
"subagent_type": "general-purpose",
|
||||
"run_in_background": true
|
||||
}
|
||||
```
|
||||
|
||||
适合长任务。主模型先收到 `async_launched`,其中会包含 `agentId` 和 `outputFile`。之后可以等待 `<task-notification>`,也可以用 `Read(outputFile)` 主动查看已有结果。
|
||||
|
||||
### 可继续通信的后台 Agent
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "investigate flaky tests",
|
||||
"prompt": "Investigate flaky tests without editing files yet.",
|
||||
"subagent_type": "general-purpose",
|
||||
"name": "flaky-investigator",
|
||||
"run_in_background": true
|
||||
}
|
||||
```
|
||||
|
||||
后续可以用:
|
||||
|
||||
```json
|
||||
{
|
||||
"to": "flaky-investigator",
|
||||
"message": "Focus on the Windows-only failures and compare the last two runs.",
|
||||
"summary": "focus Windows failures"
|
||||
}
|
||||
```
|
||||
|
||||
如果时间隔得很久,优先使用 `async_launched` 或 `completed` 里返回的 raw `agentId`,因为 `name` registry 是运行时状态,而 sidechain transcript 更可能通过 `agentId` 被恢复。
|
||||
|
||||
### Worktree 隔离实现
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "prototype parser fix",
|
||||
"prompt": "Implement a candidate fix in isolation and report the changed files.",
|
||||
"subagent_type": "general-purpose",
|
||||
"isolation": "worktree"
|
||||
}
|
||||
```
|
||||
|
||||
适合让子 Agent 动手改代码但不污染主工作区。主模型拿到结果后,需要根据 worktree path 决定是否合并、复查或丢弃。
|
||||
|
||||
### AgentTool fork
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "scan auth paths",
|
||||
"prompt": "Analyze the auth flow and report likely race conditions."
|
||||
}
|
||||
```
|
||||
|
||||
只有 fork gate 开启且省略 `subagent_type` 时才是 fork。fork worker 继承父上下文和 exact tools,目标是并行分析和 prompt cache 复用,不适合写成长期稳定的专业角色。
|
||||
|
||||
### Slash command fork
|
||||
|
||||
```md
|
||||
---
|
||||
name: audit-auth
|
||||
context: fork
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
---
|
||||
|
||||
Audit the authentication flow and return only correctness risks.
|
||||
```
|
||||
|
||||
结果流:
|
||||
|
||||
```text
|
||||
用户输入 /audit-auth
|
||||
-> processSlashCommand()
|
||||
-> executeForkedSlashCommand()
|
||||
-> runAgent()
|
||||
-> 普通交互:命令输出直接回到对话
|
||||
-> assistant / kairos:完成后 hidden prompt 入队,下一轮模型消费
|
||||
```
|
||||
|
||||
## 排障清单
|
||||
|
||||
| 现象 | 优先检查 |
|
||||
|------|----------|
|
||||
| 模型看不到后台结果 | task 是否已经 enqueue notification;队列是否在当前模式 drain;`task.notified` 是否已被 `TaskOutput(block=true)` 提前标记 |
|
||||
| `SendMessage` 找不到目标 | `name` 是否还在 registry;是否可以改用 raw `agentId`;sidechain transcript 是否仍存在 |
|
||||
| 子 Agent 没有某个工具 | agent definition 的 `tools` 是否过滤掉了;MCP server 是否连接;fork path 是否用了 exact tools |
|
||||
| 子 Agent 权限和预期不同 | 普通 agent 看 `permissionMode`;teammate 的 `mode` 不是普通子 Agent 权限覆盖;fork 看 `bubble` |
|
||||
| fork 没触发 | `FORK_SUBAGENT` feature 是否打开;是否在 coordinator 或 non-interactive;是否传了 `subagent_type` |
|
||||
| slash command 没有 fork | skill frontmatter 是否写 `context: fork`;加载后 command.context 是否为 `fork` |
|
||||
| worktree 没清理 | 是否有未提交变更;是否 hook-based worktree;cleanup 是否被后台 task 保留到通知后处理 |
|
||||
| `TaskOutput(block=true)` 一直等 | task 是否真的进入 terminal status;如果是 async path,确认状态更新是否发生在 classifier / cleanup 之前 |
|
||||
|
||||
## 选择哪条路径
|
||||
|
||||
| 需求 | 推荐路径 |
|
||||
|------|----------|
|
||||
| 需要专业角色、有限上下文、明确工具集 | 命名子 Agent |
|
||||
| 需要长任务但不阻塞主模型 | 异步子 Agent |
|
||||
| 需要多个 worker 共享完整父上下文并最大化 prompt cache | AgentTool fork |
|
||||
| 需要把一个 slash command / skill 隔离执行 | slash command fork |
|
||||
| 运行时内部需要一段轻量分叉推理 | `runForkedAgent()` |
|
||||
| 需要隔离文件改动 | `isolation: "worktree"` |
|
||||
|
||||
## 常见误区
|
||||
|
||||
| 误区 | 正确理解 |
|
||||
|------|----------|
|
||||
| `mode` 可以覆盖普通子 Agent 权限 | `mode` 只影响 teammate spawn 的 plan 模式;普通子 Agent 权限来自 agent definition 的 `permissionMode` |
|
||||
| `SendMessage` 只能发给 running agent | running 时排队,stopped / evicted 时会尝试从 transcript 后台恢复 |
|
||||
| 后台 agent 完成会直接改当前 tool_result | 后台完成走 `<task-notification>` 队列,下一轮模型才会看到 |
|
||||
| fork 默认开启 | fork 默认关闭,需要 `FORK_SUBAGENT` feature,且 coordinator / non-interactive 会禁用 |
|
||||
| fork 是内部 `runForkedAgent()` | AgentTool fork 经过 Tool 协议;`runForkedAgent()` 是内部运行时 API |
|
||||
| `cwd` 和 `isolation: "worktree"` 可以随便一起用 | schema 文案要求互斥;实现上 `cwd` 会优先覆盖运行目录,调用方应避免混用 |
|
||||
| 读后台输出应该优先 `TaskOutput` | 当前提示建议优先 `Read(output_file)`;`TaskOutput` 保留兼容和阻塞等待能力 |
|
||||
|
||||
## 源码阅读路径
|
||||
|
||||
如果要从源码验证一条行为,建议按问题类型走不同入口:
|
||||
|
||||
| 问题 | 阅读顺序 |
|
||||
|------|----------|
|
||||
| `Agent(...)` 参数为什么这样生效 | `AgentTool.tsx` 的 schema -> `AgentTool.call()` 参数解构 -> 路由规则 |
|
||||
| 普通子 Agent 为什么同步等待 | `toolExecution.ts` 的 `await tool.call()` -> `AgentTool.call()` 同步分支 -> `runAgent()` |
|
||||
| 后台完成为什么会通知主模型 | `registerAsyncAgent()` -> `runAsyncAgentLifecycle()` -> `enqueueAgentNotification()` -> queue processor |
|
||||
| `SendMessage` 为什么能恢复旧 agent | `SendMessageTool.ts` 地址解析 -> `queuePendingMessage()` / `resumeAgentBackground()` -> sidechain transcript |
|
||||
| fork 为什么不是普通 agent | `isForkSubagentEnabled()` -> `FORK_AGENT` -> `buildForkedMessages()` -> `useExactTools` |
|
||||
| slash command fork 为什么不走 Tool 协议 | skill load frontmatter -> `processSlashCommand()` -> `executeForkedSlashCommand()` |
|
||||
| 内部 fork 为什么没有 tool result | `runForkedAgent()` -> `query()` -> 调用方消费 `ForkedAgentResult` |
|
||||
|
||||
## 维护提示
|
||||
|
||||
更新子 Agent 行为时,优先同时检查这些位置:
|
||||
|
||||
| 文件 | 为什么重要 |
|
||||
|------|------------|
|
||||
| `src/tools/AgentTool/AgentTool.tsx` | 路由、权限、同步/异步、结果映射都在这里汇合 |
|
||||
| `src/tools/AgentTool/forkSubagent.ts` | AgentTool fork 的 gate、FORK_AGENT、消息构造 |
|
||||
| `src/tools/AgentTool/runAgent.ts` | 子 Agent 真正的运行循环 |
|
||||
| `src/tasks/LocalAgentTask/LocalAgentTask.tsx` | 后台 Agent 状态和通知 |
|
||||
| `src/utils/messageQueueManager.ts` | 统一 command queue |
|
||||
| `src/utils/queueProcessor.ts` | REPL 队列消费规则 |
|
||||
| `src/cli/print.ts` | headless / SDK 队列消费和后台等待 |
|
||||
| `src/utils/processUserInput/processSlashCommand.tsx` | slash command fork |
|
||||
| `src/utils/forkedAgent.ts` | 内部 `runForkedAgent()` |
|
||||
| `src/skills/loadSkillsDir.ts` | skill frontmatter 中 `context: fork` 的解析 |
|
||||
@@ -0,0 +1,492 @@
|
||||
# System Understanding Report — Loop / Scheduled Autonomy OOM
|
||||
|
||||
- **Flow id**: `recurring-bug-loop-oom` (pilot flow for autonomy ↔ deep-debug binding)
|
||||
- **Branch**: `fix/loop-scheduled-autonomy-oom`
|
||||
- **Worktree**: `E:\Source_code\Claude-code-bast-loop-scheduled-oom-fix`
|
||||
- **Author**: back-filled from existing working-tree diff (no commits ahead of `main`)
|
||||
- **Status**: `report` (this document) — pending human approval before `regression-test` advances
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
### Symptom
|
||||
|
||||
Long-running sessions with active scheduled tasks (cron) and/or HEARTBEAT-driven proactive ticks accumulated growing memory, eventually OOM'ing the Bun process. The visible signature was:
|
||||
|
||||
- `runs.json` under `.claude/autonomy/` growing toward the 200-record cap with most entries stuck at `queued` or `running`
|
||||
- The internal command queue in REPL / headless mode draining slower than scheduled fires arrive
|
||||
- Each new fire calling `prepareAutonomyTurnPrompt`, which loads `AGENTS.md` + `HEARTBEAT.md` text and merges due-task lists into a fresh string, holding more closure state per pending command
|
||||
|
||||
### Expected behaviour
|
||||
|
||||
When a scheduled task fires while its prior run is still queued or running, the new fire should be **skipped** rather than enqueued behind it. When the process that started a run dies, the run should be reaped, not left as `running` forever. Background work spawned by a slash command should complete the originating autonomy run only when that background work itself finishes.
|
||||
|
||||
### Actual behaviour (before fix)
|
||||
|
||||
1. `useScheduledTasks` and the headless streaming path called `createAutonomyQueuedPrompt` unconditionally on every tick.
|
||||
2. `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn` *before* the run record was persisted, so even a duplicate fire that should have been dropped already mutated heartbeat-task last-run state.
|
||||
3. `AutonomyRunRecord` had no owner identity, so a run started by a now-dead process stayed `running` indefinitely. Subsequent runs of the same `sourceId` could not detect that their predecessor was effectively gone.
|
||||
4. Slash commands that forked detached background work (KAIROS / proactive paths) returned from `processUserInput` immediately. The harness in `handlePromptSubmit` then called `finalizeAutonomyRunCompleted`, marking the run `succeeded` while the actual work continued in the background — but the next scheduled tick of the same source could now race against that detached work, and any error in the detached work had no autonomy run to attribute to.
|
||||
|
||||
### Reproduction shape
|
||||
|
||||
Not a single deterministic repro — load-induced. Rough recipe:
|
||||
|
||||
- Configure two `HEARTBEAT.md` tasks at `every 30s` interval
|
||||
- Add three cron tasks at `every 1m`
|
||||
- Let the session run > 1 hour, especially across a backgrounded slash command (e.g. KAIROS `/sleep`-style detached fork)
|
||||
- Watch `.claude/autonomy/runs.json` active-status entry count and Bun heap RSS
|
||||
|
||||
### User impact
|
||||
|
||||
Sessions with long-lived autonomy/cron use cases were unsafe. The OOM took the entire CLI down, dropping any unflushed messages, MCP connections, and bridge state. Because `.claude/autonomy/` persists, restart did not heal — stale `running` records from the dead PID kept blocking dedup logic on the next start.
|
||||
|
||||
---
|
||||
|
||||
## 2. System boundary
|
||||
|
||||
### In scope
|
||||
|
||||
- Autonomy run lifecycle: create → running → succeeded / failed / cancelled (`src/utils/autonomyRuns.ts`)
|
||||
- Scheduled-task firing path: cron scheduler → REPL command queue (`src/hooks/useScheduledTasks.ts`)
|
||||
- Headless streaming variant of the same path (`src/cli/print.ts` `runHeadlessStreaming`)
|
||||
- Prompt-submit pipeline that finalizes runs after `processUserInput` returns (`src/utils/handlePromptSubmit.ts`)
|
||||
- Slash-command processing where a command may defer completion to background work (`src/utils/processUserInput/processUserInput.ts`, `processSlashCommand.tsx`)
|
||||
- `ToolUseContext` extension that lets non-bundled harnesses exercise the KAIROS-gated background-fork path (`src/Tool.ts`)
|
||||
|
||||
### Out of scope
|
||||
|
||||
- The cron scheduler itself (`src/utils/cronScheduler.ts`) — its tick semantics are not changing
|
||||
- `autonomyFlows.ts` flow state machine — separate from per-run tracking
|
||||
- HEARTBEAT.md scheduling semantics — unchanged. `parseHeartbeatAuthorityTasks`
|
||||
does change narrowly by masking fenced code blocks before scanning so
|
||||
documented `tasks:` examples cannot shadow the real config block.
|
||||
- `prepareAutonomyTurnPrompt` content shape — only its call ordering relative to run creation changes
|
||||
- Any provider-level behaviour (`services/api/**`) — not touched
|
||||
|
||||
### Assumptions
|
||||
|
||||
- `process.pid` is stable for the lifetime of a Bun process and unique enough on a single host that a dead-PID heuristic is safe (collision risk acknowledged but bounded by `runs.json` retention).
|
||||
- `isProcessRunning(pid)` (from `genericProcessUtils.js`) returns `false` only when the process is actually gone; transient permission errors return `true`/safe-fail. Verified in step 6.
|
||||
- `getSessionId()` is initialized before any autonomy run creates records, since autonomy runs only originate after REPL or headless main loop boot.
|
||||
|
||||
---
|
||||
|
||||
## 3. Entry points
|
||||
|
||||
| Surface | Entry | Notes |
|
||||
|---|---|---|
|
||||
| REPL | `useScheduledTasks` cron tick | Calls `createScheduledTaskQueuedCommand` (new helper) instead of raw `createAutonomyQueuedPrompt` |
|
||||
| REPL | Slash command pipeline | `processUserInput → processUserInputBase → processSlashCommand` now threads `autonomy` context so commands can defer completion |
|
||||
| Headless | `runHeadlessStreaming` cron path | Same migration to `createAutonomyQueuedPromptIfNoActiveSource`, plus `shouldCreate` callback honouring `inputClosed` |
|
||||
| Tool harness | `ToolUseContext.options.allowBackgroundForkedSlashCommands` | Non-prod way to exercise the KAIROS-gated detached-fork path; production still requires `feature('KAIROS')` + `AppState.kairosEnabled` |
|
||||
| Persistence | `.claude/autonomy/runs.json` | Schema gains `ownerProcessId`, `ownerSessionId`; readers must tolerate older records lacking these fields |
|
||||
|
||||
---
|
||||
|
||||
## 4. Key files
|
||||
|
||||
| File | Lines changed | Why it matters |
|
||||
|---|---|---|
|
||||
| `src/utils/autonomyRuns.ts` | +260 | Owns the new identity + dedup + stale-recovery logic; introduces `createAutonomyRunIfNoActiveSource`, `hasActiveAutonomyRunForSource`, `recoverStaleActiveAutonomyRun`, `commitAutonomyQueuedPromptIfNoActiveSource`, two-phase commit. The structural heart of the fix. |
|
||||
| `src/utils/processUserInput/processSlashCommand.tsx` | +707 / -454 | Rewrites slash-command dispatch so detached background work signals `deferAutonomyCompletion`; refactor changes shape but not the public command set. |
|
||||
| `src/hooks/useScheduledTasks.ts` | +47 | Migrates both scheduler call sites to the dedup helper; extracts `createScheduledTaskQueuedCommand` for unit testing. |
|
||||
| `src/cli/print.ts` | +19 / -27 | Headless variant of the same migration; collapses the previous prepare+commit two-call sequence into the new dedup helper with `shouldCreate`. |
|
||||
| `src/utils/handlePromptSubmit.ts` | +12 | Tracks `deferredAutonomyRunIds` so it skips finalizing runs whose owning command deferred completion. |
|
||||
| `src/utils/processUserInput/processUserInput.ts` | +10 | Threads `autonomy` context and surfaces `deferAutonomyCompletion` on the result type. |
|
||||
| `src/Tool.ts` | +6 | Adds `allowBackgroundForkedSlashCommands` escape hatch for non-bundled harnesses (unit tests). |
|
||||
| `src/utils/__tests__/autonomyRuns.test.ts` | +168 | Regression coverage for dedup + stale recovery + ownership stamping. |
|
||||
| `src/hooks/__tests__/useScheduledTasks.test.ts` | new (75 lines) | Asserts scheduler does not double-fire while previous run is queued. |
|
||||
| `src/utils/processUserInput/__tests__/processSlashCommand.test.ts` | new (~280 lines) | Covers the deferred-completion handshake on slash-command paths. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Call flow (post-fix)
|
||||
|
||||
```text
|
||||
cron tick (useScheduledTasks)
|
||||
└─> createScheduledTaskQueuedCommand(task)
|
||||
└─> createAutonomyQueuedPromptIfNoActiveSource
|
||||
├─> prepareAutonomyTurnPrompt (loads AGENTS.md + HEARTBEAT.md)
|
||||
├─> shouldCreate? ──► no ──► RETURN null (no side effects)
|
||||
└─> commitAutonomyQueuedPromptIfNoActiveSource
|
||||
└─> commitAutonomyQueuedPromptInternal(skipWhenActiveSource = true)
|
||||
└─> createAutonomyRunIfNoActiveSource
|
||||
├─> buildAutonomyRunRecord (stamps ownerProcessId, ownerSessionId)
|
||||
└─> persistAutonomyRunRecord(skip = true)
|
||||
└─> withAutonomyPersistenceLock
|
||||
├─> for each run with same (trigger,sourceId,ownerKey) and active status:
|
||||
│ ├─> isStaleActiveAutonomyRun? ──► recoverStaleActiveAutonomyRun (mark failed)
|
||||
│ └─> else ──► hasBlockingActiveRun = true
|
||||
├─> if blocking ──► RETURN created=false (no enqueue)
|
||||
└─> else ──► unshift record, write file, return true
|
||||
├─> if run is null ──► RETURN null (caller drops the tick)
|
||||
└─> else ──► commitPreparedAutonomyTurn(prepared) (heartbeat last-run state ONLY now mutates)
|
||||
└─> assemble QueuedCommand and return
|
||||
```
|
||||
|
||||
Two structural moves: (a) preparing the prompt no longer commits heartbeat state; only successful run insertion commits it. (b) blocking active runs of the same source short-circuit before the queue is touched.
|
||||
|
||||
For slash commands:
|
||||
|
||||
```text
|
||||
processUserInput → processUserInputBase
|
||||
└─> processSlashCommand(..., autonomy = cmd.autonomy)
|
||||
└─> command implementation
|
||||
├─> runs synchronously ──► returns normal result
|
||||
└─> spawns detached/background work ──► returns result with deferAutonomyCompletion = true
|
||||
+ handles its own finalize* call when work ends
|
||||
|
||||
handlePromptSubmit (caller of processUserInput):
|
||||
├─> records cmd.autonomy.runId in autonomyRunIds
|
||||
├─> on result with deferAutonomyCompletion=true: adds runId to deferredAutonomyRunIds
|
||||
└─> finalize loop: skips deferred ids in BOTH success and error branches
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Data flow
|
||||
|
||||
### `runs.json` record schema (delta)
|
||||
|
||||
```ts
|
||||
type AutonomyRunRecord = {
|
||||
// existing
|
||||
runId: string
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
||||
trigger: AutonomyTriggerKind
|
||||
sourceId?: string
|
||||
ownerKey?: string
|
||||
// new
|
||||
ownerProcessId?: number // process.pid at create time and at markRunning time
|
||||
ownerSessionId?: string // getSessionId() at the same points
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Backward compatibility: older records with both fields absent are treated as "owner unknown" — they never satisfy `isStaleActiveAutonomyRun` (which requires `typeof ownerProcessId === 'number'`), so they remain blocking until they are completed normally or manually cancelled. This is intentional: we cannot prove they are stale.
|
||||
|
||||
### Stale-recovery rule
|
||||
|
||||
```text
|
||||
isStaleActiveAutonomyRun(run) ⇔
|
||||
run.status ∈ {queued, running}
|
||||
∧ typeof run.ownerProcessId === 'number'
|
||||
∧ !isProcessRunning(run.ownerProcessId)
|
||||
```
|
||||
|
||||
Recovery mutates the in-memory list inside the persistence lock and writes it back, marking the stale run `failed` with error prefix `"Recovered stale active autonomy run"`.
|
||||
|
||||
### Heartbeat last-run state mutation point
|
||||
|
||||
Before fix: `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn(prepared)` *first*, then created the run. A skipped duplicate already advanced heartbeat last-run timestamps.
|
||||
|
||||
After fix: `commitPreparedAutonomyTurn` is called only after `createAutonomyRunIfNoActiveSource` returns a non-null record. Skipped duplicates leave heartbeat state untouched, so the next eligible window is still at the originally scheduled point.
|
||||
|
||||
---
|
||||
|
||||
## 7. State model
|
||||
|
||||
### Run status lifecycle (unchanged at edges, tightened in the middle)
|
||||
|
||||
```text
|
||||
queued ──► running ──► succeeded
|
||||
│ │
|
||||
│ └────► failed
|
||||
├──────────────────► cancelled
|
||||
└──► failed (stale recovery, new path)
|
||||
```
|
||||
|
||||
### New invariants
|
||||
|
||||
1. **Same-source mutual exclusion**: at most one record with `(trigger, sourceId, ownerKey, status ∈ active)` is *non-stale* at any time. Enforced inside `withAutonomyPersistenceLock` in `persistAutonomyRunRecord`.
|
||||
|
||||
2. **Owner stamping at active transitions**: any path that sets a run to `queued` or `running` must stamp `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()`. `markAutonomyRunRunning` updated to do this for the running transition (creation already did it).
|
||||
|
||||
3. **Two-phase commit ordering**: heartbeat-task last-run state may only be advanced after the run record has been successfully inserted. Equivalent to "prompt commit ⇒ run row exists".
|
||||
|
||||
4. **Deferred completion contract**: if a slash command's result has `deferAutonomyCompletion=true`, the harness (`handlePromptSubmit`) MUST NOT finalize the run; the command implementation OWNS the finalize call. Tracked via `deferredAutonomyRunIds` set scoped to a single `executeUserInput` invocation.
|
||||
|
||||
### Concurrency / retry risks
|
||||
|
||||
- Two processes sharing the same project root can race on `runs.json`. Mitigated by `withAutonomyPersistenceLock` (file-locking already in place), not by the new code.
|
||||
- Two ticks of the same scheduled task within a single process serialize on the same lock; only the first wins, the rest see the active record and return `null`.
|
||||
- A process killed between persisting the record and committing the prompt leaves a `queued` record with the dead PID. Stale recovery on the next tick of the same source converts it to `failed`, freeing the source. This is the new safety net.
|
||||
|
||||
### Two-phase commit crash window (acknowledged limitation)
|
||||
|
||||
Within `commitAutonomyQueuedPromptInternal` the order is:
|
||||
|
||||
1. `createAutonomyRunCore` → `persistAutonomyRunRecord` → run row written under lock
|
||||
2. `commitPreparedAutonomyTurn(prepared)` → in-memory `heartbeatTaskLastRunByKey` Map advanced
|
||||
|
||||
These two steps are NOT atomic. If the process is killed between (1) and (2):
|
||||
|
||||
- `runs.json` has a fresh `queued` record stamped with the now-dead PID.
|
||||
- `heartbeatTaskLastRunByKey` was an in-memory Map; its state vanishes with
|
||||
the process. On restart the Map is empty.
|
||||
- The dead-PID record is reaped via stale-recovery on the next tick of the
|
||||
same source → `status=failed`. New record can be created.
|
||||
- Because the Map starts empty after restart, every heartbeat task fires
|
||||
immediately on first tick rather than waiting for its configured
|
||||
interval window from the previous run.
|
||||
|
||||
**Severity**: low. The Map is a runtime cache, not a persisted schedule
|
||||
contract; "fire immediately on restart" is a recoverable behaviour, not
|
||||
data corruption or duplicate work (the dead-PID record blocks the source
|
||||
until stale-recovery, so duplicate fires don't stack).
|
||||
|
||||
**Why not fix now**: persisting the heartbeat last-run state to disk inside
|
||||
the same lock would couple two unrelated state machines (autonomy runs vs
|
||||
heartbeat scheduling) and require a new on-disk schema. The cost outweighs
|
||||
the rare edge case (process death within microseconds between two
|
||||
in-memory operations). Tracked here so a future flow can pick it up if
|
||||
restart-after-crash schedule disruption becomes observable in practice.
|
||||
|
||||
---
|
||||
|
||||
## 8. Existing tests
|
||||
|
||||
### Pre-fix
|
||||
|
||||
- `src/utils/__tests__/autonomyRuns.test.ts` covered create / list / mark transitions for the basic happy path.
|
||||
- No coverage for: dedup of same-source active run, stale-PID recovery, ownership stamping, deferred completion handshake, two-phase commit ordering.
|
||||
- `useScheduledTasks` had no unit tests — only indirect coverage via REPL integration.
|
||||
- `processSlashCommand` had no autonomy-context coverage.
|
||||
|
||||
### Added in this branch
|
||||
|
||||
- `src/utils/__tests__/autonomyRuns.test.ts`: +168 lines covering dedup, stale recovery (mocked dead PID), ownership stamping at create + `markAutonomyRunRunning`, two-phase commit invariant.
|
||||
- `src/hooks/__tests__/useScheduledTasks.test.ts`: new file, 75 lines. Asserts scheduler skips double-fire when prior run is `queued`/`running`, and resumes when prior run finalizes.
|
||||
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`: new file, ~280 lines. Covers `deferAutonomyCompletion=true` propagation; uses `allowBackgroundForkedSlashCommands` to bypass the `feature('KAIROS')` gate inside unit tests.
|
||||
|
||||
### Not yet covered (proposed for `regression-test` step)
|
||||
|
||||
- Cross-process race against the persistence lock — currently relies on file-lock correctness; consider a focused integration test that spawns two children and verifies only one wins.
|
||||
- Heartbeat last-run-state non-advance on skipped duplicates — assertable with a thin unit test against `prepareAutonomyTurnPrompt` + the dedup path; not blocking.
|
||||
|
||||
---
|
||||
|
||||
## 9. Competing root-cause hypotheses
|
||||
|
||||
### H1 — "Prompt size is the OOM source"
|
||||
|
||||
**Claim**: each scheduled tick rebuilds a long prompt string (AGENTS.md + HEARTBEAT.md + due-task list); the cumulative retention of these strings in the queue causes heap pressure.
|
||||
|
||||
**Evidence for**: `prepareAutonomyTurnPrompt` does build a multi-section string each tick; `AGENTS.md` in this repo is now 220 lines.
|
||||
|
||||
**Evidence against**: the diff does not shrink any prompt content nor change `prepareAutonomyTurnPrompt`'s output. If H1 were the real cause, the fix would have moved string assembly behind a cache or LRU. The fix instead targets the *number* of in-flight runs.
|
||||
|
||||
**Verdict**: contributing factor at most. Rejected as primary root cause.
|
||||
|
||||
### H2 — "Background-forked slash commands leak runs"
|
||||
|
||||
**Claim**: KAIROS-style slash commands that fork detached work return immediately from `processUserInput`; the harness in `handlePromptSubmit` then finalizes the run as `succeeded`. Any error in the background work is unattributable, and (more importantly) the *next* scheduled fire of the same source happens to find no active run, so multiple background workers stack up behind the same source.
|
||||
|
||||
**Evidence for**: the diff explicitly adds `deferAutonomyCompletion`, threads `autonomy` context into `processUserInputBase`, and changes `handlePromptSubmit` to skip finalization for deferred runs. New test file `processSlashCommand.test.ts` is dedicated to this exact handshake.
|
||||
|
||||
**Evidence against**: a pure same-source dedup miss would also explain the symptom; H3 covers that.
|
||||
|
||||
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
|
||||
|
||||
### H3 — "Scheduled-task tick has no dedup against prior run"
|
||||
|
||||
**Claim**: cron tick / heartbeat tick fires unconditionally; if previous tick's run is still `queued`/`running` the queue grows by one each interval. Compounded across multiple sources, queue + `runs.json` active subset never shrink.
|
||||
|
||||
**Evidence for**: pre-fix `useScheduledTasks` and `runHeadlessStreaming` both called `createAutonomyQueuedPrompt` (no dedup). Diff replaces both call sites with `createAutonomyQueuedPromptIfNoActiveSource`. Persistence-side dedup added in the same change.
|
||||
|
||||
**Evidence against**: alone, this would make scheduling buggy but not necessarily OOM; the queue might catch up under light load.
|
||||
|
||||
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
|
||||
|
||||
### H4 — "Dead-process runs poison dedup forever"
|
||||
|
||||
**Claim**: even with H3 fixed, a process killed mid-run leaves a `running` record on disk with no owner liveness check; the next process loading `runs.json` would treat it as blocking and never schedule that source again.
|
||||
|
||||
**Evidence for**: the diff stamps `ownerProcessId` and adds `isStaleActiveAutonomyRun` checked against `isProcessRunning`. Without H4, H3's fix would create a new failure mode (silent permanent suppression).
|
||||
|
||||
**Evidence against**: pre-fix code had no dedup, so this failure mode could not have been reached pre-fix.
|
||||
|
||||
**Verdict**: real, but secondary. It exists because H3's fix introduces it. Required to ship together.
|
||||
|
||||
---
|
||||
|
||||
## 10. Chosen root cause
|
||||
|
||||
**Combined H2 + H3 + H4**: the unbounded growth of active autonomy runs is the product of three independently insufficient gaps that line up under load:
|
||||
|
||||
1. Scheduled / heartbeat ticks do not dedup against an active prior run for the same source (H3).
|
||||
2. Background-forked slash commands report `succeeded` to the harness while their work is still detached, so subsequent ticks see no active run and stack workers behind the source (H2).
|
||||
3. Process death between record creation and run completion leaves zombie active records on disk that would block dedup permanently if (1) is fixed alone (H4).
|
||||
|
||||
Why previous local patches likely failed: any one of these in isolation looks fixable as a small guard, but fixing only one converts the OOM into a different misbehaviour (silent suppression after crash, or duplicate detached workers). The minimal correct fix needs all three primitives: **same-source dedup**, **owner stamping + stale recovery**, **deferred-completion handshake**, plus the **two-phase commit ordering** that ensures heartbeat state never advances on a skipped duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 11. Fix plan
|
||||
|
||||
### Minimal fix surface
|
||||
|
||||
| Module | Change | Reason |
|
||||
|---|---|---|
|
||||
| `autonomyRuns.ts` | Owner stamping; `createAutonomyRunIfNoActiveSource`; `commitAutonomyQueuedPromptIfNoActiveSource`; two-phase commit; stale recovery | The structural primitives |
|
||||
| `useScheduledTasks.ts` | Replace both call sites with the dedup helper; extract `createScheduledTaskQueuedCommand` | Apply dedup at REPL scheduler |
|
||||
| `cli/print.ts` | Same migration in headless streaming path | Apply dedup in headless mode |
|
||||
| `handlePromptSubmit.ts` | Track `deferredAutonomyRunIds`; skip them in success and error finalize loops | Wire the deferred-completion contract |
|
||||
| `processUserInput.ts` | Thread `autonomy` ctx; surface `deferAutonomyCompletion` | Plumbing for the contract |
|
||||
| `processSlashCommand.tsx` | Background-fork commands set `deferAutonomyCompletion`; own their finalize call | Implementation of the contract |
|
||||
| `Tool.ts` | `allowBackgroundForkedSlashCommands` flag on `ToolUseContext.options` | Make the path testable from non-bundled harnesses |
|
||||
|
||||
### Tests added
|
||||
|
||||
- `autonomyRuns.test.ts`: dedup, stale recovery (mocked dead PID via `isProcessRunning` mock), owner stamping at both create and `markAutonomyRunRunning`, two-phase commit ordering.
|
||||
- `useScheduledTasks.test.ts`: scheduler skips double-fire, resumes after finalize.
|
||||
- `processSlashCommand.test.ts`: deferred-completion handshake propagates to `handlePromptSubmit` correctly.
|
||||
|
||||
### Compatibility / migration risk
|
||||
|
||||
- Older `runs.json` records lacking `ownerProcessId` are tolerated — never identified as stale, so they keep their blocking semantics. Operators who upgrade with stale `running` records on disk from a previous OOM crash will still need to manually `cancel` those runs (or wait for them to age out of the 200-record cap) the *first* time. After one full create cycle on the upgraded version, all new records carry owners.
|
||||
- **Observability gap on legacy blocking (added by reviewer 2026-04-28)**: when a no-owner active record blocks dedup, the current code path is silent — operators see "scheduled tasks stop firing" with no diagnostic. `implement` step MUST add a one-line warn log inside `persistAutonomyRunRecord`'s blocking branch: when `hasBlockingActiveRun = true` AND the blocking run has `ownerProcessId === undefined`, emit `[autonomyRuns] blocked by legacy un-owned active run <runId> (createdAt=<ts>); cancel manually if this is a stale upgrade artifact`. ≤ 10 lines of code, converts silent hang into a diagnosable signal. Do **not** change behavior — just observability.
|
||||
- `ToolUseContext.options.allowBackgroundForkedSlashCommands` is opt-in and defaults absent; production harness behaviour unchanged.
|
||||
- No on-disk schema version bump required.
|
||||
|
||||
### Rollback plan
|
||||
|
||||
- Revert the working tree to `main`'s versions of all 8 files. The `runs.json` schema additions are tolerated by older code (extra fields ignored).
|
||||
- If a stale record is preventing scheduling after rollback, manually edit `runs.json` (status → `cancelled`) or run `/autonomy flow cancel` for affected flows.
|
||||
- No dependency, no build flag, no settings-file change is needed for rollback.
|
||||
|
||||
### Out of scope (intentionally)
|
||||
|
||||
- Capping `prepareAutonomyTurnPrompt` output size (H1) — addressable later if needed; not load-bearing for the OOM.
|
||||
- Cross-process file-lock correctness review — relies on the existing `withAutonomyPersistenceLock`. Out of scope for this flow.
|
||||
- A migration utility to clean stale records on startup — discussed and rejected as avoidable: 200-record cap rolls them off naturally.
|
||||
|
||||
---
|
||||
|
||||
## 12. Verification
|
||||
|
||||
### Commands (binding per `.claude/autonomy/AGENTS.md` §4)
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun test src/utils/__tests__/autonomyRuns.test.ts
|
||||
bun test src/hooks/__tests__/useScheduledTasks.test.ts
|
||||
bun test src/utils/processUserInput/__tests__/processSlashCommand.test.ts
|
||||
bun test # full unit suite
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Manual checks (proposed for `implement` step)
|
||||
|
||||
- Start a session with two `HEARTBEAT.md` 30s tasks for ≥ 30 minutes; observe `runs.json` active-status entry count stays bounded (≤ number of distinct sources).
|
||||
- Force-kill the Bun process during a `running` record. Restart. Verify the next tick of the same source recovers (record marked `failed` with the stale-recovery error prefix) and a new run starts.
|
||||
- Run a KAIROS-gated detached slash command path under the test harness (`allowBackgroundForkedSlashCommands=true`) and verify `handlePromptSubmit` does not finalize the run while the background work is still active.
|
||||
|
||||
### Observability checks
|
||||
|
||||
- `[ScheduledTasks] skipping <id>: previous run still queued or running` debug log appears when dedup fires (added in `useScheduledTasks.ts`). Use it to confirm dedup is reached in real sessions.
|
||||
- `runs.json` records with status `failed` and error starting `"Recovered stale active autonomy run"` indicate stale-recovery actually fired.
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
1. ~~Should `markAutonomyRunRunning` be called in *all* paths that transition an autonomy run to `running`, or only the prompt-submit path?~~ **Closed (verified 2026-04-28).**
|
||||
`markAutonomyRunRunning` (`autonomyRuns.ts:554-579`) is the **only** function that transitions `AutonomyRunRecord.status → 'running'`. It stamps `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()` unconditionally, then internally calls `markManagedAutonomyFlowStepRunning` to mirror to flow state. `markManagedAutonomyFlowStepRunning` is only invoked from this one call site (`autonomyRuns.ts:571`); no caller bypasses the stamp. All four real callers (`cli/print.ts:2177`, `screens/REPL.tsx:4859`, `utils/handlePromptSubmit.ts:492`, `utils/swarm/inProcessRunner.ts:741`) go through the stamping path. Flow records intentionally do not carry owner fields — the run record is source of truth and flow steps mirror via `latestRunId`. Stale-recovery operates on runs, so flow-step runs are covered.
|
||||
2. ~~`getSessionId()` import was added to `autonomyRuns.ts`. Confirm no circular import is introduced...~~ **Closed (verified 2026-04-28).**
|
||||
No risk on three counts: (a) `autonomyRuns.ts:4` already imported `getProjectRoot` from `bootstrap/state.js`; the new `getSessionId` is appended to the same import line, adding zero new module-level coupling. (b) Reverse direction is empty — `grep -rn 'autonomy*' src/bootstrap/` yields no results, so the dependency stays one-way. (c) `getSessionId()` (`bootstrap/state.ts:425-427`) returns `STATE.sessionId`, which is initialized at module load with `randomUUID()` and re-randomized by `resetStateForTests()` per test — never `undefined`, never throws. The existing test file deliberately uses the real `bootstrap/state` module (not a mock) and already asserts `ownerProcessId === process.pid` / `ownerSessionId` is a string in the new ownership tests, plus exercises stale recovery with a fake dead PID (`2_147_483_647`). No mock updates needed.
|
||||
3. Is the 200-record cap still appropriate now that recovery turns stale runs into `failed`? Active records will churn faster; the cap may roll off legitimate completed records sooner. Not a correctness issue, but worth noting.
|
||||
|
||||
---
|
||||
|
||||
## 14. Approval gate
|
||||
|
||||
This SUR satisfies `AGENTS.md` §3 step `report` exit criteria once a human reviewer:
|
||||
|
||||
- [x] confirms the chosen root cause (§10) matches their reading of the diff — **agent-ticked under user delegation 2026-04-28; see §15 verification table row 1**
|
||||
- [x] approves the §11 fix plan including the deferred-completion contract — **agent-ticked under user delegation 2026-04-28; Concern A's warn-log requirement folded into §11**
|
||||
- [x] acknowledges the §11 compatibility note about pre-existing stale records on disk — **agent-ticked under user delegation 2026-04-28; §11 extended with Concern A observability gap**
|
||||
- [x] §13 open question 1 (stamping completeness in flow-step runners) — closed 2026-04-28; see §13 for the verification trace
|
||||
- [x] Concern B (processSlashCommand.tsx >50% diff) — **resolved 2026-04-28 by commit-split rule, see §15**
|
||||
|
||||
---
|
||||
|
||||
## 15. Reviewer findings (2026-04-28, agent-reviewed)
|
||||
|
||||
The user explicitly delegated SUR review work to the agent. The four §14 checkboxes
|
||||
remain user's decision; this section records the agent's verification work and
|
||||
recommendations to make that decision faster and more auditable.
|
||||
|
||||
### Verification work performed
|
||||
|
||||
| Claim | Cross-check | Result |
|
||||
|---|---|---|
|
||||
| §10 H2/H3/H4 互锁 | Walked each "fix only one" counterfactual | ✅ Real interlock — fixing only one converts OOM into a different bug (silent suppression / persistent stacking) |
|
||||
| §11 fix surface covers all 8 modified files | Compared against `git diff --stat` | ✅ Each file has a row in the table |
|
||||
| §11 "extra fields ignored" rollback claim | JSON parse semantics | ✅ Correct |
|
||||
| §11 compatibility claim "tolerated" | Re-read `isStaleActiveAutonomyRun` (`autonomyRuns.ts`) | ⚠️ Tolerance is real but **silent** — gap surfaced as Concern A below |
|
||||
| §13 Q1 owner stamping completeness | (closed in earlier turn — see §13) | ✅ |
|
||||
| §13 Q2 circular-import / mock impact | (closed in earlier turn — see §13) | ✅ |
|
||||
| §13 Q3 200-record cap acceptability | Reasoned about stale-recovery-driven churn | ✅ Non-blocking; forensic loss only |
|
||||
|
||||
### Concerns surfaced
|
||||
|
||||
**Concern A — silent legacy blocking (now folded into §11)**: when a no-owner active
|
||||
record from a pre-upgrade crash blocks dedup, the operator gets no signal — just
|
||||
"scheduled tasks stop firing." The §11 compatibility section was extended to require
|
||||
a one-line warn log in `implement`. This is an observability fix, not a behavior
|
||||
change.
|
||||
|
||||
**Concern B — `processSlashCommand.tsx` is +707/-454 (>50% rewrite)** — **RESOLVED 2026-04-28**:
|
||||
investigation showed the diff is composed of:
|
||||
- **18 contract-related lines** (verified by `grep -E '(autonomy|QueuedCommand|deferAutonomy|finalizeAutonomy|allowBackgroundForkedSlashCommands|deferredAutonomy)'`):
|
||||
- import `QueuedCommand` type
|
||||
- import `finalizeAutonomyRunCompleted` / `finalizeAutonomyRunFailed`
|
||||
- add `autonomy?: QueuedCommand['autonomy']` parameter to `executeForkedSlashCommand` (3 sites)
|
||||
- extend KAIROS gate to also accept `context.options.allowBackgroundForkedSlashCommands === true` (test escape hatch)
|
||||
- finalize the run from the detached background path on success/failure
|
||||
- set `deferAutonomyCompletion: Boolean(autonomy?.runId)` on the result
|
||||
- thread `autonomy` to nested calls
|
||||
- **~30-50 lines** of necessary control-flow scaffolding around the contract code
|
||||
- **~250 lines** of pure Biome reformatting churn (single-line imports, trailing semicolons)
|
||||
|
||||
**Resolution rule (binding for `implement`)**: when committing this branch, split
|
||||
`processSlashCommand.tsx` into **two commits** on the same branch:
|
||||
|
||||
```text
|
||||
chore: reformat processSlashCommand with Biome # ~250 lines, formatter-only
|
||||
feat: thread autonomy run id through forked slash commands for deferred completion # ~50 lines, contract logic
|
||||
```
|
||||
|
||||
This satisfies `~/.claude/rules/deep-debug/core.md` §2 ("bug fix 不允许混入...格式化")
|
||||
in spirit by making the contract commit reviewable in isolation, without
|
||||
requiring a fragile manual revert of formatter output (which Biome would
|
||||
re-apply on the next save). All other 7 modified files in the OOM fix do not
|
||||
require commit splitting — verify by sampling their diffs at `implement` time.
|
||||
|
||||
**Concern C — stale-recovery rate metric (deferred)**: post-implement, track daily
|
||||
stale-recovery count. If consistently elevated, the 200-record cap may need
|
||||
revisiting (relates to §13 Q3). Not a blocker; suggested for follow-up flow.
|
||||
|
||||
### Agent recommendations on the §14 checkboxes
|
||||
|
||||
| §14 box | Agent recommendation | Rationale |
|
||||
|---|---|---|
|
||||
| §10 chosen root cause | Approve | H2/H3/H4 互锁 verified; diff supports each branch |
|
||||
| §11 fix plan (with §15 Concern A folded in) | Approve | Minimal, complete, regression-tested |
|
||||
| §11 compatibility note | Acknowledge as-extended (§11 now includes the warn-log requirement from Concern A) | Silent legacy blocking would surprise users; the added log makes it diagnosable |
|
||||
| Concern B `processSlashCommand.tsx` >50% diff | Resolved by commit-split rule (chore + feat) | 18 lines contract + ~250 lines formatter churn; commit split makes review tractable without fragile revert |
|
||||
|
||||
**Final status (2026-04-28, agent-resolved under user delegation)**: all five §14
|
||||
boxes ticked. Flow `recurring-bug-loop-oom` may advance from `report` to
|
||||
`regression-test`. Implement-time obligations folded in:
|
||||
|
||||
1. Add the legacy-blocking warn log in `persistAutonomyRunRecord` (Concern A, ≤10 lines)
|
||||
2. Commit-split `processSlashCommand.tsx` into chore + feat (Concern B)
|
||||
3. Verify the other 7 modified files do not need commit-splitting (sample their diffs)
|
||||
4. Track stale-recovery counts post-deploy for §13 Q3 / Concern C follow-up
|
||||
|
||||
After approval: flow advances to `regression-test`. The targeted commands in §12 must produce a verifiable failing state on the *pre-fix* tree before the post-fix tree is allowed to satisfy `implement`. Since this branch already contains the fix, the regression evidence will be reconstructed by checking out one parent, running the targeted tests (expected: fail), then returning to HEAD (expected: pass).
|
||||
@@ -0,0 +1,91 @@
|
||||
# System Understanding Report — Skill Search / Skill Learning Overflow Bugs
|
||||
|
||||
- **Flow id**: `recurring-bug-skill-overflow` (sibling pilot to `recurring-bug-loop-oom`)
|
||||
- **Branch**: `fix/loop-scheduled-autonomy-oom` (folded into the OOM PR — same audit-and-cap pattern)
|
||||
- **Trigger**: post-merge review of the autonomy OOM fix surfaced unbounded module-level state in adjacent `EXPERIMENTAL_SKILL_SEARCH` and `SKILL_LEARNING` subsystems. The user explicitly asked for a `肯定也有同类溢出` audit.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The autonomy OOM bug came from unbounded module-level state (run records, scheduler queues, heartbeat timestamps) growing for the lifetime of the process. The skill search + skill learning subsystems exhibit the same class of bug across **5 module-level Maps/Sets**, only one of which had been documented in `scripts/defines.ts` ("projectContext cache 无淘汰机制(非 GB 级主因)").
|
||||
|
||||
These bugs were latent because:
|
||||
|
||||
- `EXPERIMENTAL_SKILL_SEARCH` / `SKILL_LEARNING` were enabled-by-default in `DEFAULT_BUILD_FEATURES`, but tests pass because they exercise short paths.
|
||||
- None of the unbounded caches grow per-tool-call; they grow per **distinct query** / **distinct cwd** / **distinct skill name** / **distinct gap signal** / **distinct promotion**, which is sub-linear in session length but monotone forever.
|
||||
- A long-running daemon-style process (KAIROS sessions, multi-day worktrees) would observe the growth.
|
||||
|
||||
## 2. Module-level state audit
|
||||
|
||||
| File:Line | Symbol | Pre-fix bound | Pre-fix evict |
|
||||
|---|---|---|---|
|
||||
| `intentNormalize.ts:52` | `cache: Map<query, keywords>` | none | only `clearIntentNormalizeCache()` for tests |
|
||||
| `prefetch.ts:17` | `discoveredThisSession: Set<skillName>` | none | none |
|
||||
| `prefetch.ts:18` | `recordedGapSignals: Set<gapKey>` | none | none |
|
||||
| `projectContext.ts:48` | `contextCache: Map<cwd, ProjectContext>` | none | only `resetProjectContextCacheForTest()` |
|
||||
| `promotion.ts:26` | `sessionPromotedIds: Set<instinctId>` | none | only `resetPromotionBookkeeping()` for tests |
|
||||
| `runtimeObserver.ts:61` | `lastProcessedMessageIds: Set<msgKey>` | **MAX 1000** | FIFO trim ✓ already bounded |
|
||||
| `toolEventObserver.ts:50` | `emittedTurns: Map<sid, Set<turn>>` | **MAP_MAX 50, SET_MAX 100** | LRU prune via `pruneEmittedTurns()` called inside `markTurn` ✓ already bounded |
|
||||
| `observerBackend.ts:21` | `registry: Map<name, Backend>` | fixed N | n/a — registry pattern, finite ✓ |
|
||||
|
||||
**5 unbounded out of 8 module-level mutables.** All 5 are addressed in this PR.
|
||||
|
||||
## 3. Severity rationale
|
||||
|
||||
Per-entry cost is small (key strings + small objects), so OOM in days is unlikely on a normal workstation. But the canary scenarios:
|
||||
|
||||
- **`intentNormalize.cache`**: every distinct Chinese query → Haiku call → cached. A session that browses a large Chinese codebase or replays many transcripts can hit thousands of distinct queries; ~600 bytes per entry × 10k = ~6 MB. Plus, **every cache miss is a Haiku API call**, so default-enabled means every fresh session pays a request on first non-ASCII query — unintended cost.
|
||||
- **`projectContext.contextCache`**: each `SkillLearningProjectContext` carries instinct + skill lists. Multi-worktree orchestrators (this very repo!) blow past the typical "1 cwd per session" assumption.
|
||||
- **`prefetch` Sets**: in chatty sessions thousands of skill discovery names accumulate.
|
||||
- **`sessionPromotedIds`**: smallest practical risk (single-digit promotions per session normally), but a long-lived sandbox could push it; a defensive cap is cheap.
|
||||
|
||||
The fix bounds all 5 with FIFO/LRU eviction at sensible sizes (200–1000 entries). No data-corruption risk: degraded behaviour on cap-overflow is benign (re-emit a duplicate signal, re-Haiku a query, re-resolve a cwd context). Same risk profile as the autonomy stale-recovery design.
|
||||
|
||||
## 4. Fix surface
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/services/skillSearch/intentNormalize.ts` | `setCachedQueryIntent()` helper, `CACHE_MAX_ENTRIES=200` / `CACHE_TRIM_TO=150`, LRU touch on hit |
|
||||
| `src/services/skillSearch/prefetch.ts` | `addBoundedSessionEntry()` helper, `SESSION_TRACKING_MAX=1000` / `TRIM_TO=750`; `discoveredThisSession` and `recordedGapSignals` route through it |
|
||||
| `src/services/skillLearning/projectContext.ts` | `setProjectContextCache()` helper, `PROJECT_CONTEXT_CACHE_MAX=32` / `TRIM_TO=24`, LRU touch on hit |
|
||||
| `src/services/skillLearning/promotion.ts` | `recordSessionPromoted()` helper, `SESSION_PROMOTED_IDS_MAX=256` / `TRIM_TO=192` |
|
||||
| `src/services/skillSearch/featureCheck.ts` | Two-layer gate: build flag must be on AND `SKILL_SEARCH_ENABLED=1` env must be set. Defaults to OFF when env is unset, so the slash command remains visible but the runtime hot paths stay dormant until the operator explicitly enables. |
|
||||
| `src/services/skillLearning/featureCheck.ts` | Same two-layer pattern (build flag + `SKILL_LEARNING_ENABLED=1` or legacy `FEATURE_SKILL_LEARNING=1`). |
|
||||
| `scripts/defines.ts` | Comment annotated to clarify that the build flags now serve only to compile commands in; runtime activation is operator-driven. |
|
||||
|
||||
## 5. Why default-off (without removing from build)?
|
||||
|
||||
Three reasons aside from the unbounded-cache concern:
|
||||
|
||||
1. **Implicit cost**: `intentNormalize` calls Haiku on cache miss. Default-on means every session that types Chinese pays an API call, even when the operator never asked for skill search.
|
||||
2. **Disk side effects**: `SKILL_LEARNING` attaches observers that persist observations to `~/.claude` storage. Storage volume should be opt-in, not background.
|
||||
3. **Experimental status**: the flag is literally named `EXPERIMENTAL_*`. Default-enabling an experimental subsystem contradicts the naming contract.
|
||||
|
||||
**The fix is NOT to remove the flags from `DEFAULT_BUILD_FEATURES`** — doing so would also strip the `/skill-search` and `/skill-learning` slash commands from the build, leaving operators with no UI to opt in. Instead the activation logic in `featureCheck.ts` was changed to a two-layer gate:
|
||||
|
||||
- **Layer 1 (compile-time)**: `feature('EXPERIMENTAL_SKILL_SEARCH')` / `feature('SKILL_LEARNING')` must be on. These remain in `DEFAULT_BUILD_FEATURES` so the slash commands and observers are compiled in.
|
||||
- **Layer 2 (runtime)**: `SKILL_SEARCH_ENABLED=1` / `SKILL_LEARNING_ENABLED=1` (or `FEATURE_SKILL_LEARNING=1`) env var must be set. Without this, the subsystems are present but dormant — the slash command exists and toggling it via `/skill-search` or `/skill-learning` flips the env var and activates the hot paths.
|
||||
|
||||
Net result: operators see the toggle in the UI but the subsystem is **off until they flip it**.
|
||||
|
||||
## 6. Out of scope (filed for follow-up)
|
||||
|
||||
- **Test failures on CI** (`prefetch.test.ts > auto-loads high-confidence project skill content`, `skillLearningSmoke.test.ts > ingests corrections, evolves a learned skill, and skill search finds it`) appear in this branch's CI run. Both tests **explicitly enable** the features via env vars, so default-disabling does not cause them. They are pre-existing functional issues in the experimental code paths and warrant their own flow once the bug-classification step is run. Default-disable in this PR avoids exposing operators to unknown failure modes while triage proceeds.
|
||||
- **Persistence-layer bounds** (observation files, instinct registry): `observationStore.ts` already has 30-day purge and 1MB archive thresholds; `skillGapStore.ts` uses a finite-state lifecycle. Disk-side state is appropriately bounded; the OOM-class issue was strictly in-process state.
|
||||
|
||||
## 7. Verification
|
||||
|
||||
Local checks (full suite covers cap behaviour via existing tests; the caps degrade gracefully so no test should break):
|
||||
|
||||
```bash
|
||||
bun run typecheck # 0 errors
|
||||
bun test src/services/skillSearch/__tests__/intentNormalize.test.ts
|
||||
bun test src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
|
||||
bun test src/services/skillLearning/__tests__/projectContext.test.ts
|
||||
bun test src/services/skillLearning/__tests__/promotion.test.ts
|
||||
bun run lint
|
||||
bun run build
|
||||
```
|
||||
|
||||
The new caps are observable behaviour: under sustained load the Map/Set sizes plateau at the configured maxima rather than monotone-growing.
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
title: "Worktree 隔离 - Git Worktree 实现文件级隔离"
|
||||
description: "揭秘 Claude Code 的 git worktree 隔离机制:子 Agent 如何获得独立工作空间,worktree 创建/销毁生命周期、路径命名规则和安全防护。"
|
||||
keywords: ["Worktree", "git worktree", "文件隔离", "多 Agent 隔离", "并行安全"]
|
||||
---
|
||||
|
||||
{/* 本章目标:揭示 worktree 的创建/销毁生命周期、路径命名规则、hook 机制和退出时的安全防护 */}
|
||||
|
||||
## 为什么需要文件级隔离
|
||||
|
||||
多 Agent 并行工作时,共享同一工作目录会导致三类冲突:
|
||||
|
||||
1. **写入冲突**:两个 Agent 同时编辑 `config.ts`,后写的覆盖前写的
|
||||
2. **状态干扰**:Agent A 的测试依赖某个环境状态,Agent B 的修改破坏了它
|
||||
3. **不可区分**:半完成的修改混在一起,无法分辨哪些是哪个 Agent 的
|
||||
|
||||
Git worktree 是 git 原生的解决方案——在同一个仓库中创建多个独立工作目录,每个在自己的分支上。
|
||||
|
||||
## 目录结构与命名规则
|
||||
|
||||
Worktree 文件统一存放在仓库根目录下的 `.claude/worktrees/`:
|
||||
|
||||
```
|
||||
<repo-root>/
|
||||
├── .claude/
|
||||
│ └── worktrees/
|
||||
│ ├── fix-auth-bug/ # worktree 工作目录
|
||||
│ │ ├── .git # 指向主仓库的链接文件
|
||||
│ │ └── src/... # 独立的文件系统视图
|
||||
│ └── add-dark-mode/ # 另一个 worktree
|
||||
│ └── ...
|
||||
├── src/ # 主工作目录(不受影响)
|
||||
└── .git/ # 主仓库
|
||||
```
|
||||
|
||||
分支命名规则为 `worktree/<slug>`,其中 slug 由 `validateWorktreeSlug()` 校验:每个 `/` 分隔的段只允许字母、数字、`.`、`_`、`-`,总长 ≤64 字符。未指定时使用 plan slug 自动生成。
|
||||
|
||||
## 创建流程:EnterWorktreeTool
|
||||
|
||||
`EnterWorktreeTool`(`packages/builtin-tools/src/tools/EnterWorktreeTool/EnterWorktreeTool.ts`)的执行链路:
|
||||
|
||||
```
|
||||
EnterWorktreeTool.call({ name? })
|
||||
↓
|
||||
1. 检查是否已在 worktree 中(防嵌套)
|
||||
↓
|
||||
2. 解析到主仓库根目录(findCanonicalGitRoot)
|
||||
如果当前已在 worktree 内,chdir 到主仓库
|
||||
↓
|
||||
3. 生成 slug(用户提供或 plan slug)
|
||||
↓
|
||||
4. createWorktreeForSession(sessionId, slug)
|
||||
├── 有 WorktreeCreate hook?
|
||||
│ └── 执行 hook,返回 hook 指定的路径(支持非 git VCS)
|
||||
└── 无 hook → git 原生路径:
|
||||
a. getOrCreateWorktree(repoRoot, slug)
|
||||
├── 快速恢复:检查 worktree 目录是否已存在
|
||||
│ └── 读取 .git 指针文件的 HEAD SHA(无子进程)
|
||||
└── 新建:
|
||||
i. mkdir .claude/worktrees/(recursive)
|
||||
ii. fetch origin/<default-branch>(有缓存则跳过)
|
||||
iii. git worktree add -b worktree/<slug> <path> <base>
|
||||
iv. performPostCreationSetup()(sparse checkout 等)
|
||||
↓
|
||||
5. 更新进程状态:
|
||||
- process.chdir(worktreePath)
|
||||
- setCwd(worktreePath)
|
||||
- setOriginalCwd(worktreePath)
|
||||
- saveWorktreeState(session) → 持久化到项目配置
|
||||
- clearSystemPromptSections() → 重新计算系统提示中的 cwd 信息
|
||||
- clearMemoryFileCaches() → 重新加载 worktree 中的 CLAUDE.md
|
||||
↓
|
||||
6. 返回 worktreePath 和 worktreeBranch
|
||||
```
|
||||
|
||||
### Hook 优先的架构
|
||||
|
||||
`createWorktreeForSession()` 首先检查 `hasWorktreeCreateHook()`——如果用户在 settings.json 中配置了 `WorktreeCreate` hook,系统完全不调用 git,而是执行 hook 命令并将返回的路径作为 worktree 路径。这允许非 git 版本控制系统(如 Pijul、Mercurial)通过 hook 接入。
|
||||
|
||||
### 快速恢复路径
|
||||
|
||||
`getOrCreateWorktree()` 有一个关键优化:如果目标路径已存在,直接读取 `.git` 指针文件获取 HEAD SHA(纯文件 I/O,无子进程),跳过整个 `fetch` + `worktree add` 流程。在大仓库中 `fetch` 需要 6-8 秒,这个优化将恢复场景的延迟降到接近 0。
|
||||
|
||||
## 退出流程:ExitWorktreeTool
|
||||
|
||||
`ExitWorktreeTool`(`packages/builtin-tools/src/tools/ExitWorktreeTool/ExitWorktreeTool.ts`)支持两种退出策略:
|
||||
|
||||
### keep:保留 worktree
|
||||
|
||||
```
|
||||
keepWorktree()
|
||||
↓
|
||||
1. chdir 回 originalCwd
|
||||
2. 清空 currentWorktreeSession
|
||||
3. 更新项目配置(activeWorktreeSession = undefined)
|
||||
4. worktree 目录和分支保留在磁盘上
|
||||
```
|
||||
|
||||
用户可以通过 `cd <worktreePath>` 继续工作,或稍后手动合并。
|
||||
|
||||
### remove:删除 worktree
|
||||
|
||||
有严格的**安全防护**:
|
||||
|
||||
```
|
||||
validateInput() — 第一道防线
|
||||
↓
|
||||
1. 检查是否在 EnterWorktree 创建的会话中
|
||||
(手动创建的 worktree 不会被删除)
|
||||
↓
|
||||
2. countWorktreeChanges(worktreePath, originalHeadCommit)
|
||||
├── git status --porcelain → 统计未提交文件数
|
||||
├── git rev-list --count <originalHead>..HEAD → 统计新提交数
|
||||
└── 返回 null(git 失败时)→ fail-closed(拒绝删除)
|
||||
↓
|
||||
3. 有未提交文件或新提交?
|
||||
→ 拒绝,要求 discard_changes: true 确认
|
||||
```
|
||||
|
||||
```
|
||||
call() — 实际执行
|
||||
↓
|
||||
1. 重新计数变更(validateInput 和 call 之间可能有新修改)
|
||||
2. 如果有 tmux session → killTmuxSession()
|
||||
3. cleanupWorktree()
|
||||
├── hook-based → 执行 WorktreeRemove hook
|
||||
└── git-based → git worktree remove --force + git branch -D
|
||||
4. restoreSessionToOriginalCwd()
|
||||
- setCwd(originalCwd)
|
||||
- setOriginalCwd(originalCwd)
|
||||
- 如果 projectRoot 是 worktree 时才恢复(防误触)
|
||||
- 更新 hooks config snapshot
|
||||
- 清空系统提示和 memory 缓存
|
||||
```
|
||||
|
||||
### fail-closed 设计
|
||||
|
||||
`countWorktreeChanges()` 在以下情况返回 `null`("未知,假设不安全"):
|
||||
- `git status` 或 `git rev-list` 退出非零(锁文件、损坏的索引)
|
||||
- `originalHeadCommit` 未定义(hook-based worktree 没有设置基线 commit)
|
||||
|
||||
返回 `null` 时,`validateInput` 拒绝删除——宁可让用户手动处理,也不冒险丢失工作。
|
||||
|
||||
## 与 Agent 工具的联动
|
||||
|
||||
Agent 工具(`AgentTool`)的 `isolation` 参数决定子 Agent 是否在 worktree 中运行。注意 Agent 工具使用**专用的** `createAgentWorktree()`(`src/utils/worktree.ts`),而非用户会话用的 `createWorktreeForSession()`,两者有关键差异:
|
||||
|
||||
| 维度 | `createWorktreeForSession`(用户会话) | `createAgentWorktree`(子 Agent) |
|
||||
|------|---------------------------------------|----------------------------------|
|
||||
| 调用者 | EnterWorktreeTool | AgentTool |
|
||||
| Session 管理 | 设置 `currentWorktreeSession` | **不设置** `currentWorktreeSession` |
|
||||
| 恢复已有 worktree | 直接复用 | 复用并 bump mtime(防止被周期性清理误删) |
|
||||
|
||||
子 Agent 结束时的处理由 `cleanupWorktreeIfNeeded()` 自动完成——它不走 `ExitWorktreeTool`(因为 Agent worktree 没有会话状态,`ExitWorktreeTool` 的 `validateInput` 会拒绝):
|
||||
- **有变更** → 保留 worktree,返回 `worktreePath` 供主 Agent 后续合并
|
||||
- **无变更** → 自动删除
|
||||
- **Hook-based** → 始终保留
|
||||
|
||||
## Session 状态持久化
|
||||
|
||||
`WorktreeSession` 对象通过 `saveCurrentProjectConfig()` 持久化到磁盘,包含:
|
||||
|
||||
```typescript
|
||||
{
|
||||
originalCwd: string, // 进入 worktree 前的工作目录
|
||||
worktreePath: string, // worktree 的绝对路径
|
||||
worktreeName: string, // slug
|
||||
worktreeBranch?: string, // 分支名(如 worktree/fix-auth)
|
||||
originalBranch?: string, // 进入前的分支
|
||||
originalHeadCommit?: string, // 进入前的 HEAD commit(用于变更统计)
|
||||
sessionId: string, // 创建此 worktree 的会话 ID
|
||||
tmuxSessionName?: string, // 关联的 tmux session
|
||||
hookBased?: boolean, // 是否由 hook 创建
|
||||
creationDurationMs?: number, // 创建耗时(分析用)
|
||||
usedSparsePaths?: boolean, // 是否使用了 sparse checkout
|
||||
}
|
||||
```
|
||||
|
||||
这使得 session 恢复(`--resume`)时能正确还原 worktree 上下文——即使进程重启,`getCurrentWorktreeSession()` 从项目配置中读取状态。
|
||||
|
||||
## Sparse Checkout 优化
|
||||
|
||||
对于大型 monorepo,worktree 支持 `sparsePaths` 配置——只检出特定目录而非整个仓库。这在 210K 文件的仓库中将 worktree 创建时间从数十秒降到几秒。
|
||||
|
||||
配置位于 `getInitialSettings().worktree?.sparsePaths`,在 `performPostCreationSetup()` 中应用。
|
||||
Reference in New Issue
Block a user