Files
SuperBizAgent-java/mvp/engineering/harness/Harness执行控制笔记-终态检查与取消广播.md
T

166 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Harness 执行控制笔记:终态检查与取消广播
**用途**:面试复习用。回答"一次 Run 的执行如何被控制、取消如何生效、为什么是协作式"。
**代码基线**:`com.superbiz.agent.harness.core` + `guard/semantic/GuardModelCall` + `tool/mysql/JdbcMysqlReadOnlyExecutor`
**配套**:[RunBudget 预算流程时序图](RunBudget预算流程-一次Run的资源门禁时序图.md)
## 1. 一句话核心
> 执行控制由三个句柄组成:RunBudget 管"还能不能花"、RunCancellation 管"要不要停"、RunLifecycle 管"最终怎么定"。判断停止的机制有两套:**checkActive 轮询检查点**(读终态)和 **onCancel 订阅广播**(推送中断)——前者让"到了检查点的调用"被拒绝,后者让"正在阻塞的操作"被实时打断。
## 2. 执行控制三件套
| 句柄 | 回答的问题 | 关键机制 | 本质 |
|---|---|---|---|
| RunBudget | 还能不能继续消耗 | 调用前预扣,超限抛异常 | 门禁(止损) |
| RunCancellation | 是否要求停止 | first-reason-wins + 回调广播 | 事件(信号) |
| RunLifecycle | 最终哪个终态生效 | first-terminal-wins(CAS) | 事实(结果) |
**预扣 vs 记账 vs 落定**:预算在调用前拦,取消在运行中广播,终态在结束时定死。
## 3. checkActive:三层闸门(轮询)
`DiagnosisHarnessCore.checkActive` 在**每次模型/Tool 调用前**执行,顺序固定:
```java
① termination 已存在 → 抛 RunAbortedException // 已终态,无论什么原因
② deadline 已过 → finish(TIMED_OUT) + cancel(DEADLINE_EXCEEDED) + 抛异常
// 超时是主动动作:自己写终态、自己广播,不是等别人来
③ cancellation.isCancelled() → 抛 RunAbortedException
```
- 调用点:`beforeModelCall`(每轮模型)、`beforeToolCall`(每次 Tool)、`reserveRunBytes`(canonical 体积)
- 它是**轮询**:只在检查点生效。正在阻塞的操作(模型等待、SQL 查询)不会自己撞上它。
## 4. termination:终结事实快照
`RunLifecycle` 持有 `AtomicReference<RunTermination>`:
```java
record RunTermination(RunState state, String reason, Instant completedAt)
// 构造校验:state 必须 isTerminal(),reason 非空
// null = 还在 RUNNING;非 null = 已终结,不可变
```
- 终态只有 5 个:`SUCCESS / FAILED / CANCELLED / TIMED_OUT / BUDGET_EXHAUSTED`(`RUNNING` 非终态)
- 写入后永远定格,只能靠 CAS 换整个引用 → first-terminal-wins 的物理基础
- checkActive 第一道闸就是读它
## 5. 两个正交的 CAS
| 门 | 保护什么 | 语义 |
|---|---|---|
| `RunCancellation.reason`(AtomicReference) | **原因**:谁要求停、为什么停 | first-reason-wins |
| `RunLifecycle.termination`(AtomicReference) | **结果**:最终终态 | first-terminal-wins |
```java
// cancel() 两件事:写原因(CAS)+ 遍历 callbacks 广播
public boolean cancel(RunCancellationReason reason) {
if (!this.reason.compareAndSet(null, reason)) return false; // first-reason-wins
callbacks.forEach(...); // 广播给订阅者
return true;
}
// finish() 一件事:写终态(CAS)
public boolean finish(RunState state, String reason) {
return termination.compareAndSet(null, new RunTermination(state, reason, now));
}
```
**为什么不能合并**:
- 取消是"意图/原因"(可被多线程同时请求、可被订阅),终态是"结果/事实"(只读、不可变)
- 原因→终态是**多对一**映射:`DEADLINE_EXCEEDED→TIMED_OUT`、`INTERNAL_FAILURE→FAILED`、`CLIENT_DISCONNECTED/USER_REQUESTED→CANCELLED`
- 完成路径(`completeSuccess`/`completeFailure`)根本不经过 cancel;run 已终态时取消请求被拒绝(不翻案)
## 6. 取消广播:为什么必须有它(推送 vs 轮询)
只设置终态,只能让**下一次 checkActive** 拒绝——正在阻塞的操作不会自己醒来。广播通过 **onCancel 回调**直接打断阻塞中的操作。
代码里真实的订阅者只有三处:
| 订阅者 | 回调动作 | 打断机制 |
|---|---|---|
| Core `startRun` | `lifecycle.finish(terminalState(reason))` | 终态联动 |
| `GuardModelCall` | `future.cancel(true)` | 线程 interrupt |
| `JdbcMysqlReadOnlyExecutor` | `statement.cancel()` | JDBC 协议取消 |
## 7. 打断机制:两种物理中断
**机制一:Java 线程中断(`Future.cancel(true)`)**
```java
Future<String> future = executor.submit(() -> invoke(...)); // Guard 任务在独立线程池
context.cancellation().onCancel(ignored -> future.cancel(true)); // 订阅
return future.get(timeout, TimeUnit.NANOSECONDS); // 业务线程阻塞等待
```
取消线程执行回调 → `future.cancel(true)` → 向执行任务的线程发 `Thread.interrupt()` → 目标线程若阻塞在可中断等待则立刻抛 `InterruptedException` / `CancellationException` 醒来 → catch 后 `checkActive` → `RunAbortedException`。
**机制二:JDBC 协议取消(`Statement.cancel()`)**
```java
AtomicReference<Statement> statementRef = new AtomicReference<>(statement);
context.cancellation().onCancel(ignored -> cancel(statementRef.get())); // 订阅
... executeQuery() ...
finally { statementRef.set(null); }
```
取消线程 → `statement.cancel()` → 向 MySQL 服务器发取消请求 → 服务器终止查询 → 客户端 `executeQuery` 抛 `SQLException` 醒来。不走线程中断,走数据库协议,更"物理"。
**细节**:
- `statementRef` 用 AtomicReference 包:回调可能在执行前/中/后触发,`finally` 里 `set(null)`,读到 null 说明已结束、跳过 cancel
- `future.cancel()` 幂等,对已完成 Future 调用无害,Guard 侧无需此保护
- 被打断不是"裸死":Guard catch `CancellationException` → `checkActive`;MySQL 抛 `SQLException` → ToolBoundary 记 ERROR——**醒来后仍走统一状态机**,取消不会产生绕过 Harness 的野异常
## 8. 协作式取消的精确边界
能否推送中断,取决于 **Harness 是否持有该调用的执行句柄**:
| 调用 | 谁发起 | Harness 有句柄吗 | 取消时 |
|---|---|---|---|
| Agent 模型调用 | 框架 ReAct 内部 | 无(拦截器只环绕) | 只能等下一次 checkActive 轮询 |
| Guard 模型调用 | Harness `executor.submit` | 有 Future | `future.cancel(true)` 推送中断 |
| MySQL 查询 | Harness 自己执行 | 有 Statement | `statement.cancel()` 推送中断 |
> "协作式" = 愿意被打断的(订阅了 onCancel 且持有句柄)实时打断;框架持有的 Agent loop 物理上无法打断,只能等检查点。但无论哪种,最终结果都受 first-terminal-wins 保护。
## 9. 为什么"先 finish 再 cancel"(二次 finish 无害)
`exhaustBudget` / 超时路径都执行"自己设终态 + 自己广播":
```java
lifecycle.finish(BUDGET_EXHAUSTED, ...); // ① 先固化事实(主操作,不依赖回调)
cancellation.cancel(BUDGET_EXHAUSTED); // ② 再广播信号(副作用)
```
- cancel 触发回调 → 回调里再次 `finish` → **CAS 失败返回 false** → 无害、被静默吸收
- 顺序意义:终态不依赖回调注册/执行;即使回调异常或重复触发,终态都已正确
- 两个 CAS 各自 first-wins,最终状态永远一致(见下表,任何组合都无害):
| finish CAS | cancel CAS | 结果 |
|---|---|---|
| 成功 | 成功 | 正常 |
| 成功 | 失败 | 终态正确,广播已由先前 cancel 触发过 |
| 失败 | 成功 | 已有更早终态,回调里 finish 失败无害 |
| 失败 | 失败 | 早已终结,本次调用本就不该发生 |
## 10. 面试话术(三段式)
**执行控制**:
> 执行控制由预算、取消、终态三个句柄组成,统一走 Core 的检查链:每轮模型或 Tool 调用前先 checkActive——终态存在就拒绝,超时就主动写 TIMED_OUT 并广播取消,已取消就拒绝;然后预算预扣,超限时把 BUDGET_EXHAUSTED 固化为终态并广播取消。预算管"能不能花",取消管"要不要停",终态管"最终怎么定"。
**取消广播**:
> 取消是协作式的。取消信号可能由容器线程注入(SseEmitter 断连回调调 core.cancel),CAS 写原因后同步遍历订阅者回调:Guard 模型调用中断自己的 Future、MySQL 中断自己的 Statement,让业务线程从阻塞中立刻醒来,再在下一个检查点被 RunAbortedException 拒绝。能否推送中断取决于 Harness 是否持有该调用的句柄——自己提交的调用(Guard/MySQL)能打断,框架持有的 Agent 模型调用只能等下一次 checkActive。
**为什么两个 CAS**:
> cancel 和 finish 是两条正交的通道:cancel 传原因和广播信号,finish 落最终事实。取消必须走 cancel 是因为要保留原因维度、要广播给运行中的组件;但终态又必须由 finish 直接保证,不能依赖回调。所以预算耗尽时两个都调——事实先定,信号随后,重复写终态被 CAS 吸收。
## 11. 代码位置索引
| 内容 | 位置 |
|---|---|
| RunContext 九成员 | `harness/core/RunContext.java` |
| checkActive / startRun / exhaustBudget | `harness/core/DiagnosisHarnessCore.java` |
| 终态 CAS | `harness/core/RunLifecycle.java` + `RunTermination.java` |
| 原因 CAS + 广播 | `harness/core/RunCancellation.java` |
| 预算预扣/记账 | `harness/core/RunBudget.java` + `RunCapacityCounter.java` |
| Guard 模型取消订阅 | `harness/guard/semantic/GuardModelCall.java:58` |
| MySQL 查询取消订阅 | `harness/tool/mysql/JdbcMysqlReadOnlyExecutor.java:52` |
| 断连取消入口 | `controller/sse/ChatSseSession.java:44` → `harness/application/ChatApplicationUseCase.java:340` |