docs(harness): add budget flow sequence and execution control notes
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
# 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` |
|
||||
Reference in New Issue
Block a user