# Agent 显式打断控制规范

> Canonical path: `specs/message/agent-interrupt-control.md`

## 1. 背景

Active-turn 期间的普通新消息现在优先进入 runtime-specific ack-aware pending input：daemon 把消息正文交给可 steer 的 runtime transport，并等待 runtime 在下一个 input/model boundary 发出 surfaced ack 后才推进 freshness。不可 steer、transport 失败或 session mismatch 时才回落到当前活进程的 daemon-local continuation inbox，并由 daemon 在安全 input boundary / idle 自动递交；held refresh action 按 local fallback 覆盖情况选择读取已附带新消息、等待 pending input boundary 或调用 exact-target `read_history`。这个机制解决的是“agent 在合适的模型边界看到新消息”，不是“立即停止当前工作”。

人类有另一类明确需求：**我已经在 AgentTeam 里发出了一条可见消息，现在希望显式带上“马上 interrupt 当前 run 并递交这条消息”的语义**。效果上类似 Codex CLI 中用户主动 interrupt 后继续输入新指令，但在 AgentTeam 里主入口应是对已发送消息执行一次“加急”动作，而不是要求用户先停止、再复制消息重发。这个语义必须来自显式交互入口，不能从消息正文推断。例如正文里出现“停一下”“urgent”“不要做了”都不应自动触发打断。

本规范定义显式打断入口，以及 UI、server routing、daemon/runtime、freshness 之间的契约。

## 2. 目标

- 提供明确可见的打断入口，让用户能主动停止正在运行的 agent。
- 支持最符合直觉的组合动作：**加急 / Mark urgent**，即用户对一条已发送的可见消息显式要求 daemon 先 interrupt 或唤醒目标 agent 的 communication run，再把这条消息作为下一次权威输入递交给 agent。
- 保持普通发送语义不变：普通消息发给 busy agent 时不 interrupt；可 steer runtime 走 ack-aware pending input，失败或不可用时回落到 daemon-local queue / refresh 路径。
- 打断语义必须是路由层元数据，不是 prompt 或消息内容分类结果。
- runtime 支持 graceful interrupt 时优先使用；不支持时允许降级为停止进程并 resume / restart。
- 保持 freshness 安全：被打断的旧 run 不能在没看到新消息的情况下继续发布 stale visible reply。

## 3. 非目标

- 不从消息正文、关键词、mention、优先级词或自然语言分类推断打断。
- 不把普通 notification / preview 当成 agent 已停止或已消费新任务的确认。
- 不要求所有 runtime 在第一版都具备同进程 graceful interrupt。
- 不在打断失败时假装 agent 已看到新消息；必须给出明确失败状态。
- 不提供“撤销已发生副作用”的承诺。打断只停止后续执行，不回滚已经写文件、发消息、push commit 或调用外部系统的动作。
- 不把主路径设计成“先只停止，再等待用户另行补充上下文”。只停止可以保留为独立控制，但用户已经发出目标消息时，应通过 **加急** 一次完成 interrupt + submit。

## 4. 用户交互

### 4.1 入口

当某个 agent 正在运行时，AgentTeam 应提供一个消息级主入口和一个辅助控制：

1. **加急 / Mark urgent**
   - 出现在已发送消息的更多菜单中。
   - 用户点击后，目标消息保持为同一条已发送的可见消息，同时这次 agent delivery 带上 interrupt policy。
   - daemon 先打断或唤醒目标 agent 的当前 communication run，再把这条消息作为下一次权威输入交给该 agent。
   - 这是用户希望“让 agent 立刻注意到并判断这条消息”时的推荐入口；消息内容是否要求停止当前工作、继续等待或调整计划，由 agent 看到消息后判断。
   - 交互上不要求用户先点击停止、等待 agent 停下、再重新发送一次；该动作应一次完成 interrupt + submit。

2. **停止**
   - 出现在 running agent activity / run status surface 上。
   - 只停止当前 run，不创建新的可见消息。
   - 结果显示为“已被 @user 打断”。
   - 这是独立控制面，不是用户希望递交已有消息时的主流程。

UI 必须明确区分：

- **发送**：普通发送；agent 可能继续当前工作，新消息进入 ack-aware pending runtime input，或在不可 steer / 失败时回落到 daemon-local continuation inbox；后续通过 runtime surfaced ack、automatic fallback delivery 或显式 `read_history` 刷新。
- **加急 / Mark urgent**：显式打断或唤醒目标 agent 的 communication run，然后加急目标消息并启动新请求。

### 4.2 Activity 反馈

Activity 中展示面向用户的生命周期，不暴露底层 signal 细节：

- `@user 请求打断当前 run`
- `正在停止当前 run…`
- `当前 run 已打断`
- `正在处理 @user 的新请求`

如果 runtime 需要降级到进程终止，普通 activity 仍应显示“打断 / 停止当前 run”。`SIGTERM`、`SIGKILL`、protocol error 等细节只进入 debug 日志或展开详情。

## 5. Server 与路由契约

打断是显式 delivery 属性，不是消息内容属性。

### 5.1 加急 / Mark urgent

当用户选择 **加急** 时，客户端应针对已存在消息提交一次显式 agent delivery 请求。具体 wire shape 可沿用现有 API 风格，但语义必须等价于：

```http
POST /api/messages/msg_x/agent-delivery
Content-Type: application/json

{
  "interruptPolicy": "interrupt_current_run"
}
```

要求：

- 可见消息仍是同一条普通 scope message，拥有正常 target sequence。
- `interruptPolicy` 写入该消息的 agent delivery metadata / fanout payload，而不是在 daemon 侧重新解析正文。
- 同一条消息的幂等重试必须保留相同 interrupt policy。
- 普通 Send 不得携带该 policy。
- 对已经普通投递过的同一消息，server/daemon 必须把 interrupt replay 视为新的 delivery identity，不能被普通 delivery dedup 或旧 freshness frontier 提前吞掉。
- 这条消息就是 interrupt 后要递交给 agent 的指令；daemon 必须直接把正文交给 runtime，而不是要求 agent 再主动拉取本地 inbox。

### 5.2 只停止

只停止当前 run 可使用独立 agent control endpoint 或 control payload。例如：

```json
{
  "agentId": "agt_xxx",
  "runId": "run_xxx",
  "action": "interrupt_current_run"
}
```

只停止不创建可见用户消息，因此它本身不推进任何 message freshness frontier。

### 5.3 Daemon delivery payload

Server 下发给 daemon 的 delivery payload 必须区分普通 delivery 和打断 delivery：

```json
{
  "kind": "agent:deliver",
  "message": { "id": "msg_x", "targetSeq": 42 },
  "interruptPolicy": "interrupt_current_run"
}
```

Daemon 不得把普通 `agent:deliver` 重新解释成打断，即使正文要求 agent 停止。

## 6. Daemon 执行契约

### 6.1 加急 / Mark urgent 流程

对带 `interruptPolicy=interrupt_current_run` 的消息，daemon 应按以下顺序执行：

1. 解析目标 agent 当前 session / run generation。
2. 记录该 generation 收到 interrupt request。
3. 使用当前 runtime 可用的最佳能力打断或唤醒当前 communication turn / process，使目标消息能成为下一次权威输入。
4. fence 被打断 generation 的迟到输出、tool call 和可见发送。
5. 将 interrupting message 作为新的 authoritative current input 投递。
6. 只有当该消息通过 runtime-visible surface 投递成功时，才推进 observed freshness。
7. 发出 requested → stopping → interrupted → new request started，或 requested → failed 的 activity / status 事件。

### 6.2 Stale output fence

Daemon 应把 runtime 输出、tool call、visible send 与 run/session generation 关联。Interrupt 被接受后：

- 被打断 generation 的迟到 activity 可以作为诊断历史保留，但不能继续显示为新请求的当前 live progress。
- 被打断 generation 的迟到 `send_message` 仍必须经过 freshness CAS；如果没观察到 interrupting message，应被 held / rejected。
- local preflight 应优先返回清晰的“当前 run 已被打断，请刷新/停止发送”结果，而不是让旧 run 与新请求竞争发布。

### 6.3 失败行为

如果 graceful interrupt 失败或超时，daemon 应按 runtime capability 升级到 fallback。如果所有停止/恢复尝试都失败：

- 保留用户刚发送的可见消息。
- 标记该消息投递给 agent 失败或 blocked，并附带可操作诊断。
- 不声明 agent 已看到或接受该消息。
- 在出现真实 runtime-visible surface 前，不推进 freshness frontier。

## 7. Runtime 能力矩阵

| Runtime | 当前 AgentTeam 可用停止机制 | 显式打断推荐行为 | 备注 |
| --- | --- | --- | --- |
| `codex` | Codex app-server 暴露 `turn/interrupt(threadId, turnId)`；AgentTeam 代码已在 recovery / rollback 路径实现 `interruptTurn()`。 | 优先 `turn/interrupt`，等待 active turn 变为非 active / interrupted，再用 `turn/start` 投递 interrupting message。只有 protocol interrupt 失败或无法确定 active turn 时，才 fallback 到进程级停止。 | 需要 current `activeTurnID`；未知时先 `thread/read(includeTurns=true)` 刷新。 |
| `claude` | 当前 `ClaudeDriver` 启动 `claude --output-format stream-json --input-format stream-json` 进程；AgentTeam 有进程级 `Stop()` / process-group termination，但没有同进程 graceful turn interrupt。 | 短期可行方案：终止当前 Claude 进程组，resume / restart session，再投递 interrupting message。长期正路：迁到 Claude Agent SDK streaming client，使用 SDK `interrupt()`，drain interrupted result 后再发送新 query。 | 在 SDK 路径落地前，不声称当前 CLI driver 支持 graceful same-process interrupt。 |
| `opencode` | 当前路径偏 one-shot / restart。 | 如果运行中，停止当前进程/session，再按既有 restart 语义投递新消息。 | Rewind recovery 由 `opencode_session_revert` 单独定义并走原生 session revert；interrupt 不复用 rewind recovery，adapter 与 LAN E2E 完成前 rewind capability 禁用。 |
| `builtin` | AgentTeam-managed OpenCode 随 Desktop 发布，仍通过 OpenCode driver 运行。 | 使用 OpenCode 当前的 stop / restart 语义投递新消息。 | 不再表示独立 Crush driver；interrupt 语义跟随 builtin managed OpenCode；rewind recovery capability 跟随 OpenCode session revert。 |

## 8. Freshness 语义

- 普通 active-turn 新消息仍保持 unsurfaced：ack-aware accepted pending input 不进入 daemon-local continuation inbox，直到 runtime surfaced ack 或显式 `read_history` surface 给 runtime 后才推进 observed freshness；fallback inbox 消息则在 daemon 后续 automatic visible input surface 后推进。
- 普通 transport accept / notification / preview 永远不推进 observed freshness。
- **加急** 不同：daemon 成功打断或唤醒目标 agent communication run、fence 旧 run，并把目标消息作为 authoritative current input 投递后，该投递可以作为 runtime-visible surface 推进该 target 的 observed freshness。
- 如果 interrupt 在投递前失败，该消息对该 agent session 仍是 unsurfaced；freshness 不得推进。
- Server freshness CAS 仍是 agent visible reply 的最终保护。

## 9. 产品与安全约束

- 入口必须显式且可见，避免误触；常规 **加急** 不应默认弹 modal，除非当前 runtime 处于敏感状态，例如等待 human approval。
- 如果当前 runtime 正在等待 approval，interrupt 应优先取消/拒绝 pending approval；runtime 不支持时，UI 应显示“当前等待审批，无法直接打断”。
- 打断不撤销已完成副作用。Activity 应写“停止当前 run”，不要写“撤销之前工作”。
- 如果旧 run 在打断前已经发消息、写文件、push commit 或调用外部系统，这些结果仍然存在。

## 10. 实现轮廓

建议分阶段实现：

1. Server/API 增加已发送消息 delivery endpoint 和 metadata：`interruptPolicy=interrupt_current_run`，用于 **加急 / Mark urgent**。
2. UI 在消息更多菜单中增加 **加急 / Mark urgent**；running agent 上的 **停止** 作为辅助控制保留。
3. Daemon 增加 interrupt orchestrator 和 runtime capability interface，例如 `InterruptCurrentRun(ctx, sessionID) (InterruptResult, error)`。
4. Codex runtime 接入现有 app-server `turn/interrupt`。
5. Claude runtime 先实现进程级 terminate + resume/restart fallback；SDK streaming interrupt 作为后续增强。
6. 增加 run generation fencing，防止旧 run 的迟到 activity / send 成为当前输出。
7. 增加 activity/status projection 和测试。

## 11. 测试要求

- UI/API 测试证明只有显式入口会设置 interrupt policy；正文里写“停下”不会触发。
- Server/outbox 测试证明 delivery payload 持久化 interrupt policy，且幂等重试保持一致。
- Daemon 测试覆盖：
  - 普通 busy message → ack-aware pending input 或 fallback queue，不 interrupt。
  - interrupting busy message → 先打断或唤醒 leader communication run，再投递新输入。
  - Codex 路径先调用 `turn/interrupt`，再 `turn/start`。
  - Claude fallback 终止进程并 resume / restart 后再投递。
  - interrupt 失败时 freshness 不推进，并产生失败状态。
  - 被打断 generation 的迟到 send / activity 被 fence 或 held。
- E2E 测试覆盖：agent 正在运行时，用户在消息更多菜单点击 **加急**，看到 interrupted 状态，agent 处理目标消息，而不是先完成旧任务。

## 12. 与普通 pending delivery 的关系

普通 pending delivery 和显式 interrupt 解决不同问题：

- ack-aware pending input / automatic fallback delivery / `read_history` 解决“普通新消息如何安全进入 busy agent 的上下文”。
- 显式 interrupt 解决“用户如何让目标 agent 立即看到并判断一条已发送消息，而不是等普通 pending delivery 自然 surfaced”。

有了显式 interrupt 后，notification preview 不应被当成 stop-now 的替代方案。普通消息继续使用 ack-aware pending delivery，并在不可用时使用 daemon-local queued fallback；需要让 agent 立即注意并判断某条已发送消息时，用户使用 **加急 / Mark urgent**，一次消息操作完成 communication run interrupt/wake 和目标消息递交。
