修掉"Worker 会自己退出"的真缺陷(场外游标续租失败误取消主任务)

## 现象(2026-09-14 实测,非人为停止)
Worker 进程自己退出,退出码 1,日志末尾是
`ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个现有的连接`
(发生在 `offsite_worker.close()` 的 IMAP `logout()`),
而真正的起点是更上面那句 `asyncio.exceptions.CancelledError`。

## 根因(两处,都是真缺陷)
1. **取消错了对象**:`OffsiteMailWorker._process_batch` 把
   `asyncio.current_task()`(= `__main__.serve()` 的**主循环任务**)交给游标心跳,
   心跳在续租失败(`rowcount != 1`)或续租抛异常时执行 `task.cancel()` ——
   于是"放弃这一批"变成了"**杀掉整个 Worker**"。`CancelledError` 从 `run_once()`
   一路冒到 `serve()`,主循环直接结束。
2. **收尾异常盖掉退出原因**:`serve()` 的 `finally` 里 `await offsite_worker.close()`
   在网络已断时抛 `ConnectionResetError`,把 `CancelledError` 顶掉,
   表现为"关闭流程崩了",看不出真实原因。

## 改法
- `_process_batch` 把"这一批"跑在**独立任务** `batch` 里,心跳只取消 `batch`;
  调用方捕获 `CancelledError` 后区分两种情况:
  **本批被放弃**(`batch.cancelled()` 且当前任务自己没有在取消)→ 记 warning、返回 `False`、
  Worker 继续下一轮;**外层在取消当前任务**(Ctrl+C / 进程关闭)→ 原样上抛,绝不吞掉。
- `serve()` 的 `finally` 里关闭场外 Worker 包 try/except:收尾失败只记日志,
  **不改变退出码与退出原因**。
- 新增 `_current_task_is_cancelling()` 用 `Task.cancelling()` 做这个区分(3.11+)。

## 守卫
`tests/unit/worker/test_offsite_mail_worker.py::test_cursor_lease_loss_abandons_batch_without_cancelling_the_worker`
—— 续租失败(rowcount=0)时断言:批次确实跑起来过、返回 `False`、
**调用方任务没有被取消**。修复前这条用例会挂在"调用方被取消"上。

## 验证
- `pytest tests/unit/worker` → 112 passed
- `pytest tests/unit tests/contract` → 见下方(0 failed)
- `mypy app/worker/offsite_mail_worker.py app/worker/__main__.py` → 0 错(除组员文件里既有的 1 个)
- 重启 Worker 后跑记忆演示链路:候选 → verified → active → **2 秒**收敛,进程稳定

## 文档
`docs/演示用/记忆系统演示文档-2026-09-14.md`:
场景五补"如果发现 Worker 自己退了是怎么回事",问答补"Worker 会不会自己中途退出"。
This commit is contained in:
2026-09-15 08:01:00 +08:00
parent 9490e5043b
commit 01e4e6a687
4 changed files with 123 additions and 6 deletions
@@ -228,6 +228,14 @@ Milvus 没配时事件**保持 pending**(而不是标成"已同步")。
**这体现什么**:Agent 与记忆都是"**受理 → 排队 → Worker 执行**"的异步三段式。
**停了不会有报错,只会静默不动** —— 所以"看板正常但什么都没发生"时,第一个要查的就是 Worker。
> ⚠️ **如果发现 Worker 自己退了**(窗口关了 / 退出码 1 / 日志最后一行是
> `ConnectionResetError [WinError 10054]` 这种 IMAP 连接被重置),那是踩到了
> 2026-09-14 修掉的一个真缺陷:场外收件的**游标续租失败时,心跳取消的是主循环任务**
> (而不是"这一批"),于是整个 Worker 被取消、退出时 `finally` 里 IMAP `logout()`
> 又把真正的退出原因盖掉了。现在:**续租失败只放弃当前这一批**,Worker 继续跑;
> 关闭阶段的异常只记日志、不改变退出码。
> 回归守卫:`tests/unit/worker/test_offsite_mail_worker.py::test_cursor_lease_loss_abandons_batch_without_cancelling_the_worker`。
---
## 6. 建议的演示顺序与时长
@@ -282,6 +290,11 @@ A:会,但路径是**异步**的:记忆 → `user_facts`(事实层)→
A:会**真的写入**(记忆、事实、画像版本各加一条)。想隔离就用另一个客户号重跑;
演示完不用清理——历史版本本来就是设计的一部分(画像只新增、不覆盖)。
**Q:Worker 会不会自己中途退出?**
A:正常不会;曾经会 —— 场外收件的游标续租失败时,心跳误把**主循环任务**取消了,
整个 Worker 就退了(退出码 1,日志末尾是 IMAP 连接被重置)。**2026-09-14 已修**:
续租失败只放弃当前这一批。演示前跑一次 §8 自检,看到两个进程都在就没问题。
---
## 8. 演示前自检(30 秒)