# 任务类型与 Plan 模式 · 一轮里跑的不只是对话

> 会话同一时刻只跑一个任务，任务都实现 SessionTask：普通对话、压缩、审查与用户 shell 命令四种实现，共用同一套启动、结束与中断流程，中断时先给任务 100 毫秒自行收尾再强制终止。Plan 模式不是一种任务，而是协作模式的一个取值：它换了一套开发者指令，从回复里解析计划块，禁用 update_plan，让 request_user_input 可用；“不做改动”只写在提示词里，沙箱与审批并不因此收紧。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/tasks-and-plan-mode
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 任务类型与 Plan 模式 · 一轮里跑的不只是对话

[run_turn 主循环](https://daiw.net/manual/codex-source/turn-loop)讲的是普通对话怎么一轮轮转起来。但会话里的“一轮”不一定是对话：`/compact`、`/review`，以及空闲时行首的 `!`，也都各占一轮。这一篇看这些轮次共用的任务框架，以及建立在普通对话之上的 Plan 模式。

## 用户看到的样子

平时发消息是普通对话；`/compact` 手动压缩，`/review` 起一次代码审查，输入框行首写 `!` 直接执行一条本地 shell 命令；`Esc` 或 `Ctrl+C` 中断当前这一轮。`/plan` 或 `Shift+Tab` 切到 Plan 模式后，模型先探索、提问，最后给出一份完整计划，TUI 随即弹出 “Implement this plan?”，可以切回默认模式开始实现，或清空上下文带着计划开新线程。用法见[斜杠命令](https://daiw.net/manual/codex/slash-commands)与[代码审查](https://daiw.net/manual/codex/review)。

## SessionTask：一轮里跑什么

```rust
/// Async task that drives a [`Session`] turn.
///
/// Implementations encapsulate a specific Codex workflow (regular chat,
/// reviews, ghost snapshots, etc.). Each task instance is owned by a
/// [`Session`] and executed on a background Tokio task. The trait is
/// intentionally small: implementers identify themselves via
/// [`SessionTask::kind`], perform their work in [`SessionTask::run`], and may
/// release resources in [`SessionTask::abort`].
pub(crate) trait SessionTask: Send + Sync + 'static {
    /// Describes the type of work the task performs so the session can
    /// surface it in telemetry and UI.
    fn kind(&self) -> TaskKind;
    // ...
    fn run(
        self: Arc<Self>,
        session: Arc<Session>,
        ctx: Arc<TurnContext>,
        input: Vec<TurnInput>,
        cancellation_token: CancellationToken,
    ) -> impl std::future::Future<Output = SessionTaskResult> + Send;
```

（`codex-rs/core/src/tasks/mod.rs:170`）

`run` 返回可选的最后一条助手消息，`abort` 是可选的清理钩子。`TaskKind`（`codex-rs/core/src/state/turn.rs:68`）只有 `Regular`、`Review`、`Compact` 三个值，注释里提到的 ghost snapshots 已经不再生成（配置代码的注释说 `ghost_snapshot` 只为兼容旧配置而保留）；实现却有四个，用户 shell 命令报告的是 `Regular`：

| 实现 | 由谁发起 | 做什么 |
| --- | --- | --- |
| `RegularTask` | 用户消息、其他 agent 发来的待办 | 反复调用 `run_turn`，直到没有待处理输入 |
| `CompactTask` | `/compact` | 按提供方能力选远程压缩 V2 或本地压缩，见[上下文压缩](https://daiw.net/manual/codex-source/compaction) |
| `ReviewTask` | `/review` | 以审查提示词为基础指令，起一个一次性的子会话，关掉网络搜索与多 agent 工具 |
| `UserShellCommandTask` | 行首 `!`（会话空闲时） | 不经模型，直接执行命令并把结果记进历史 |

## 启动、结束与中断

```mermaid
flowchart TB
  A[用户消息 · compact · review · 行首感叹号] --> S[spawn_task<br/>先以 Replaced 中止旧任务]
  M[邮箱里的待办<br/>会话空闲时] --> T
  S --> T[start_task<br/>并入邮箱输入 · 登记 RunningTask · 起 tokio 任务]
  T --> R[task.run]
  R -->|正常返回或出错| F[on_task_finished<br/>TurnComplete 带最后消息与错误]
  R -->|收到中断| X[handle_task_abort<br/>取消 · 最多等 100 毫秒 · 强制终止]
  X --> K[用户中断时写入标记<br/>发出 TurnAborted]
  F --> P[maybe_start_turn_for_pending_work<br/>有需要开轮的待办就再起一轮]
  K --> P
```

会话的 `active_turn` 里最多挂一个 `RunningTask`：取消令牌、完成通知、tokio 任务句柄，以及这一轮的 `TurnContext`。`spawn_task` 总是先以 `Replaced` 为由中止正在跑的任务，再调用 `start_task`；已知空闲的路径（例如邮箱唤醒）直接调用 `start_task`。`start_task` 把邮箱里已到达的消息并入这一轮的待处理输入，通知扩展“一轮开始了”，然后起一个 tokio 任务执行 `run`。`run` 结束后先刷新 rollout，没被取消就交给 `on_task_finished`：没用完的待处理输入照样写进历史，统计本轮 token 用量，发出带 `last_agent_message` 与 `error` 的 `TurnComplete`，清空 `active_turn`，最后看邮箱里有没有要求开新一轮的消息。

中断走另一条路。`Op::Interrupt` 以 `Interrupted` 为由中止任务，`handle_task_abort` 的核心是：

```rust
        trace!(task_kind = ?task.kind, sub_id, "aborting running task");
        task.cancellation_token.cancel();
        // ...
        let session_task = task.task;

        select! {
            _ = task.done.notified() => {
            },
            _ = tokio::time::sleep(Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS)) => {
                warn!("task {sub_id} didn't complete gracefully after {}ms", GRACEFULL_INTERRUPTION_TIMEOUT_MS);
            }
        }

        task.handle.abort();

        session_task
            .abort(Arc::clone(self), Arc::clone(&task.turn_context))
            .await;
```

（`codex-rs/core/src/tasks/mod.rs:922`）

先取消令牌，最多等 100 毫秒（`GRACEFULL_INTERRUPTION_TIMEOUT_MS`）让任务自己收尾，然后终止 tokio 任务（已经结束的不受影响），再调任务自己的 `abort`。如果是用户中断，接着往历史里写一条中断标记：默认是 `<turn_aborted>` 包裹的 user 片段，告诉模型用户是有意中断的、后台进程可能还在运行、被中止的工具可能只执行了一半；多 agent V2 下改为 developer 消息；配置 `agents.interrupt_message = false` 可以关掉。标记先落盘，再发 `TurnAborted`，因为按注释的说法，有些客户端收到这个事件会立刻重读 rollout。

## 行首的 `!`：不进沙箱的一轮

`run_user_shell_command`（`codex-rs/core/src/session/handlers.rs:96`）分两种情况：会话空闲时起一个 `UserShellCommandTask`，自成一轮；已经有一轮在跑时，不打断它，而是借用当前轮的上下文与取消令牌另起一个 tokio 任务执行，输出并入这一轮的待处理输入，下一次采样时模型就能看到（那一轮已经结束的话就直接写进历史）。执行请求是这样构造的：

```rust
    let permission_profile = PermissionProfile::Disabled;
    let exec_env = ExecRequest {
        command: exec_command.clone(),
        cwd: cwd.clone().into(),
        env: exec_env_map,
        exec_server_env_config: None,
        exec_server_shell_snapshot: None,
        // `/shell` is the explicit full-access escape hatch, so it must not
        // inherit a managed proxy from the surrounding session or turn.
        network: None,
        network_environment_id: None,
        expiration: timeout_ms.unwrap_or(USER_SHELL_TIMEOUT_MS).into(),
        capture_policy: ExecCapturePolicy::ShellTool,
        sandbox: SandboxType::None,
```

（`codex-rs/core/src/tasks/user_shell.rs:213`）

权限配置是 `Disabled`，沙箱类型是 `None`，不走审批，也不继承托管的网络代理，默认超时 1 小时（`USER_SHELL_TIMEOUT_MS`）。命令在当前环境的 shell 里执行，保留管道、重定向等语法；结果连同退出码、耗时与按模型截断策略截断后的输出，包进 `<user_shell_command>` 片段写入历史，所以模型之后能看到用户自己跑过什么。TUI 里的 `!` 本身就是经 app-server 的 `thread/shellCommand` 发出的，协议文档写明它 “runs unsandboxed with full access”。

<Callout type="warn">
  《Codex 中文手册》的斜杠命令页写着行首 `!` “遵循当前的审批与沙箱设置”。按 0.158.0 的代码，这条命令不经审批、不进沙箱，以完全访问权限执行，与 app-server 协议文档的说法一致。
</Callout>

## Plan 模式：同一个 RegularTask，换一套约束

Plan 模式不是一种任务。协作模式 `ModeKind` 只有 `Default` 与 `Plan` 两个值，它是每步设置的一部分，Plan 模式下跑的仍是 `RegularTask`。代码层面它改变了这几件事：

- **指令**：内置预设把推理强度设为 `medium`，开发者指令换成 `plan.md`（9,312 字节，默认模式的 `default.md` 只有 1,307 字节），以 `<collaboration_mode>` 片段注入（见[指令从哪来](https://daiw.net/manual/codex-source/instructions)）。
- **输出解析**：每条助手消息都经过一个流式解析器，独占一行的 `<proposed_plan>` 与 `</proposed_plan>` 之间的内容被抽出来，以 `PlanDelta` 事件流给客户端，最终成为一个 `TurnItem::Plan`，ID 是轮次 ID 加 `-plan`；标签以外的文字照常作为助手消息。只有计划、没有其他文字时，不会出现一条空的助手消息。
- **工具**：`update_plan` 直接返回 “update_plan is a TODO/checklist tool and is not allowed in Plan mode”；`request_user_input` 只允许根线程调用，Plan 模式下总是可用、请求标记为阻塞，默认模式要打开开发中的 `default_mode_request_user_input` 才能用。
- **自动开轮**：以“空闲才开轮”方式提交、又不是用户消息的输入（直接注入的响应条目、agent 间消息）属于 `Automatic`，按注释的说法，它“既不能离开已有的 Plan 模式，也不能进入它”，开轮前的当前设置与拟用设置都要过这道检查：

```rust
/// Why input is starting a turn; shared by admission and input delivery.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TurnStartKind {
    User,
    Automatic,
    Recovery,
}

impl TurnStartKind {
    fn permits_mode(self, mode: ModeKind) -> bool {
        match self {
            Self::User | Self::Recovery => true,
            Self::Automatic => mode != ModeKind::Plan,
        }
    }
```

（`codex-rs/core/src/session/turn_input.rs:57`）

反过来，代码没有做的是：Plan 模式下沙箱、审批与可用的 shell 和改文件工具都不变。“只做不改动环境的探索、不做会改动的操作”写在 `plan.md` 里（“You must not perform **mutating** actions.”），是提示词层面的约定。用户选 “Yes, implement this plan” 时，TUI 以默认模式发送一条 “Implement the plan.”；选 “Yes, clear context and implement” 则开一个新线程，把一段说明连同计划原文作为第一条消息。

## 和《从 LLM 到 Coding Agent》对照

[权限系统](https://daiw.net/manual/llm-to-agent/permissions)把 plan 设计成最安全的一档权限模式：权限层对写操作一律拒绝，模型根本动不了手。Codex 的 Plan 模式走的是另一条路：它是协作模式而不是权限模式，靠提示词约束模型只做探索，代码只拦下 `update_plan`；想要硬性的只读，要靠沙箱与审批设置（见[沙箱、审批与安全](https://daiw.net/manual/codex/sandbox-approvals)）。[中断与转向](https://daiw.net/manual/llm-to-agent/interrupt)要求一根取消信号贯穿到底、为悬空的工具调用合成结果、写一条“用户中断”的标记并停在当前回合，这几点 Codex 都有对应：取消令牌以子令牌的形式一路传下去，没有输出的调用在构造请求时补上 `aborted`（见[上下文与历史](https://daiw.net/manual/codex-source/context-history)），中断标记写进历史，中断本身走 `TurnAborted` 而不是错误事件；中断后也不会自动继续，除非邮箱里有要求开轮的 agent 消息。不同的是转向：那篇把追加的消息接到下一轮，Codex 则把它并入当前这一轮，在下一次采样时交给模型（见 [run_turn 主循环](https://daiw.net/manual/codex-source/turn-loop)）。

---

上一篇：[模型目录与提供方 · 一个 CLI 接多家后端](https://daiw.net/manual/codex-source/models-and-providers) · 下一篇：[工具系统总览 · 暴露、注册、路由与并行](https://daiw.net/manual/codex-source/tool-architecture)
