@migor/agentia 0.9.5 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +740 -1
  2. package/README.en.md +61 -3
  3. package/README.md +66 -4
  4. package/dist/AGENTS.md +54 -13
  5. package/dist/container/container.js +17 -11
  6. package/dist/container/container.js.map +1 -1
  7. package/dist/core/limits.d.ts +12 -5
  8. package/dist/core/limits.js +4 -4
  9. package/dist/core/limits.js.map +1 -1
  10. package/dist/engine/budget.js +14 -0
  11. package/dist/engine/budget.js.map +1 -1
  12. package/dist/engine/errors.d.ts +23 -1
  13. package/dist/engine/errors.js +24 -0
  14. package/dist/engine/errors.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/runtime/run.js +14 -2
  19. package/dist/runtime/run.js.map +1 -1
  20. package/dist/store/redisStore.d.ts +21 -0
  21. package/dist/store/redisStore.js +21 -0
  22. package/dist/store/redisStore.js.map +1 -1
  23. package/dist/store/store.d.ts +1 -1
  24. package/dist/transport/approval-supervisor.d.ts +73 -0
  25. package/dist/transport/approval-supervisor.js +252 -0
  26. package/dist/transport/approval-supervisor.js.map +1 -0
  27. package/dist/transport/async.d.ts +29 -105
  28. package/dist/transport/async.js +85 -513
  29. package/dist/transport/async.js.map +1 -1
  30. package/dist/transport/resume-scanner.d.ts +55 -0
  31. package/dist/transport/resume-scanner.js +219 -0
  32. package/dist/transport/resume-scanner.js.map +1 -0
  33. package/dist/transport/signal-supervisor.d.ts +80 -0
  34. package/dist/transport/signal-supervisor.js +129 -0
  35. package/dist/transport/signal-supervisor.js.map +1 -0
  36. package/dist/transport/task-events.js +8 -1
  37. package/dist/transport/task-events.js.map +1 -1
  38. package/package.json +11 -2
@@ -10,14 +10,19 @@ import { assertTimerDelay, TimeoutError } from '../core/timeout.js';
10
10
  import { zeroClauseOf } from '../core/limits.js';
11
11
  import { composeTraceEvents } from '../core/trace.js';
12
12
  import { SlotPool } from './slot-pool.js';
13
- import { approvalExpired, approvalsComplete, fillTimeoutDenials } from './approval-policy.js';
14
- import { summarizeSuspended, timerDue } from './wake-policy.js';
13
+ import { ApprovalSupervisor } from './approval-supervisor.js';
14
+ import { SignalSupervisor } from './signal-supervisor.js';
15
+ import { ResumeScanner } from './resume-scanner.js';
16
+ import { timerDue } from './wake-policy.js';
15
17
  import { DrainGate } from './drain-gate.js';
16
- import { resumeSkipReason } from './resume-policy.js';
17
18
  import { formatOwnerId } from './owner-id.js';
18
- import { ownerAlive } from './owner-liveness.js';
19
19
  import { TaskWaiters } from './task-waiters.js';
20
20
  import { TaskEventStreams } from './task-events.js';
21
+ // 三个监督件(2026-09-29 抽出)的实现与类型现在各自在文件里 —— 审批(approval-supervisor.ts)、
22
+ // 事件投递(signal-supervisor.ts)、恢复扫描(resume-scanner.ts)。下面 **re-export** 它们的
23
+ // 对外符号 ⇒ 一切 `from './async.js'` 的既有 import 路径不变。
24
+ export { TaskApproveError } from './approval-supervisor.js';
25
+ export { TaskEventError } from './signal-supervisor.js';
21
26
  /**
22
27
  * `streamTask` 的失败:带 HTTP 语义的状态码(404 = 任务不存在),HTTP 宿主据此回对应响应。
23
28
  * 形状与 `TaskApproveError` 同款(module 级 export,不进公共导出面)。
@@ -44,19 +49,9 @@ function isTerminalTask(rec) {
44
49
  return isTerminalStatus(rec.status);
45
50
  }
46
51
  /**
47
- * `approve` 的失败:带 HTTP 语义的状态码(404 = 任务不存在,409 = 任务当前不在
48
- * `suspended` 状态),HTTP 宿主据此回对应响应。
49
- * module 级 export —— 不进公共导出面(纯宿主内部实现细节)。
52
+ * `TaskApproveError`(`approve` 的失败,带 HTTP 语义状态码)已随审批监督簇迁到
53
+ * `approval-supervisor.ts`,本文件顶部 re-export 保持导出面不变(定义与理由见那里)。
50
54
  */
51
- export class TaskApproveError extends Error {
52
- /** 400 = 入参语义不合法(decisions 里有本任务待决之外的 id,2026-09-28 深评 P1-2) */
53
- status;
54
- constructor(status, message) {
55
- super(message);
56
- this.name = 'TaskApproveError';
57
- this.status = status;
58
- }
59
- }
60
55
  /**
61
56
  * `cancel` 的失败:带 HTTP 语义的状态码(404 = 任务不存在,409 = 已终态 / 在飞 run 不在本进程)。
62
57
  * 形状与 `TaskApproveError` 同款(module 级 export,不进公共导出面)。
@@ -69,19 +64,6 @@ export class TaskCancelError extends Error {
69
64
  this.status = status;
70
65
  }
71
66
  }
72
- /**
73
- * `signalTask` 的失败:带 HTTP 语义的状态码(404 = 任务不存在,409 = 任务当前不在
74
- * `suspended` 状态 / 同 eventId 已投递)。形状与 `TaskCancelError` 同款
75
- * (module 级 export,不进公共导出面)。
76
- */
77
- export class TaskEventError extends Error {
78
- status;
79
- constructor(status, message) {
80
- super(message);
81
- this.name = 'TaskEventError';
82
- this.status = status;
83
- }
84
- }
85
67
  /**
86
68
  * `submit` 在**排队段已满**时的失败(`AsyncRunnerOptions.maxQueued`,2026-09-28 外部深评 T5)。
87
69
  *
@@ -103,30 +85,6 @@ export class TaskQueueFullError extends Error {
103
85
  this.name = 'TaskQueueFullError';
104
86
  }
105
87
  }
106
- /**
107
- * `deliveredEventIds` 簿记的条数上限(FIFO 裁最旧)。它是**去重簿记**不是审计日志:
108
- * 256 条对「webhook 重试窗口」绰绰有余,而无界增长会让每条 TaskRecord 随事件量膨胀
109
- * (记录随 trace 一起落库,每次 save 全文重写)。「数量有界」不是用户旋钮,不进
110
- * `core/limits.ts` 的 0 语义表。
111
- */
112
- const MAX_DELIVERED_EVENT_IDS = 256;
113
- /**
114
- * `pendingEvents`(待注入事件缓冲)的**条数上限**(2026-09-28 复审第三轮,定案 **B**)。
115
- *
116
- * 为什么要有:`deliveredEventIds` 那侧是有界的(256),缓冲这侧原先没有 —— 而它不需要攻击者
117
- * 就能长:投方每次投一条、这条 run 每次都在恢复段**再次挂起**(再挂起出口刻意不注入事件,
118
- * 见 spec §10 ⑥ 第 4 条)⇒ 每条都留在记录上,而记录随 trace 一起落库、每次 save 全文重写。
119
- *
120
- * 为什么是「满了拒绝」而不是「丢最旧」(A 的反面):缓冲**在下次跑通时就注入并清空**,所以
121
- * 上限只管「一个挂起窗口里能攒多少」。丢最旧必须**同时**把该 id 从 `deliveredEventIds` 摘掉,
122
- * 否则发件方重投会拿到「已投递」的 409 而事件其实已经没了 —— 那是最坏的一种谎。宁可不收下
123
- * 并说出来(调用方拿到 409 就能改主意:先让这条 run 真走起来)。
124
- *
125
- * 为什么是 64:一个挂起窗口里等到的输入,量级与 `deliveredEventIds` 的重试窗口(256)同类,
126
- * 取它的 1/4 —— 容得下真实的 webhook 突发(一次重试窗口里几十条),又不足以把挂起任务当队列灌。
127
- * **数量有界不是用户旋钮**(与 MAX_DELIVERED_EVENT_IDS 同档),不进 `core/limits.ts` 的 0 语义表。
128
- */
129
- const MAX_PENDING_EVENTS = 64;
130
88
  /**
131
89
  * 取消的**宽限**:取消请求发出后,留给那次 run 自己收尾的时间。
132
90
  *
@@ -197,6 +155,21 @@ export class AsyncRunner {
197
155
  }
198
156
  runTimeoutMs;
199
157
  approvalTimeoutMs;
158
+ /**
159
+ * 审批监督簇(挂起登记 + 在飞闸 + `approve` + 超时恢复)—— 实现见 approval-supervisor.ts。
160
+ * 依赖(store / ownerId / approvalTimeoutMs / dispatch 回调)在构造函数末尾注入。
161
+ */
162
+ #approvals;
163
+ /**
164
+ * 事件投递簇(`signalTask` + 串行闸 + 两个事件容量常量)—— 实现见 signal-supervisor.ts。
165
+ * 与 `#approvals` 同族(都是「挂起任务的外部唤醒入口」),故紧邻声明。
166
+ */
167
+ #signals;
168
+ /**
169
+ * 恢复扫描簇(`resumePending` 的四步扫描 + 到期唤醒)—— 实现见 resume-scanner.ts。
170
+ * 依赖 `#approvals`(读数重建 / 催超时)与 drain 读数(唤醒闸),构造时排在它之后。
171
+ */
172
+ #scanner;
200
173
  /** 并发槽位池(见 slot-pool.ts):本类不再自管 running/waitQueue */
201
174
  #slots;
202
175
  /** 已受理但未达终态的任务数(queued + running)—— /healthz 与 drain 共用 */
@@ -294,6 +267,32 @@ export class AsyncRunner {
294
267
  throw new Error(`approvalTimeoutMs 必须为 ≥ 0 的有限数(${zeroClauseOf('AsyncRunner.approvalTimeoutMs')}),收到 ${opts.approvalTimeoutMs}`);
295
268
  }
296
269
  this.ownerId = formatOwnerId(process.pid, this.#host, randomUUID().slice(0, 8));
270
+ // 审批监督簇在 ownerId 就位后构造(它要用 store 与 ownerId)。`dispatch` 用**回调**
271
+ // 注入而不是 import:派发入口 `#dispatch` 仍归本类(停机闸 + 唯一派发点 + 一次性告警),
272
+ // 依赖保持单向(审批 → 派发),也避免两个模块互相 import。
273
+ this.#approvals = new ApprovalSupervisor({
274
+ store: this.store,
275
+ ownerId: this.ownerId,
276
+ approvalTimeoutMs: this.approvalTimeoutMs,
277
+ dispatch: (rec) => this.#dispatch(rec),
278
+ });
279
+ // 事件投递与恢复扫描同样在 ownerId 就位后构造。两处**回调注入**的理由与 `#approvals` 一致:
280
+ // `#dispatch`(停机闸 + 唯一派发点 + 一次性告警)与 drain 读数都归本类,两个监督件只**消费**它们
281
+ // ⇒ 依赖保持单向(监督件 → 派发),也避免三个模块互相 import。
282
+ this.#signals = new SignalSupervisor({
283
+ store: this.store,
284
+ ownerId: this.ownerId,
285
+ approvals: this.#approvals,
286
+ dispatch: (rec) => this.#dispatch(rec),
287
+ });
288
+ this.#scanner = new ResumeScanner({
289
+ store: this.store,
290
+ ownerId: this.ownerId,
291
+ host: this.#host,
292
+ approvals: this.#approvals,
293
+ dispatch: (rec) => this.#dispatch(rec),
294
+ isDraining: () => this.#drain.isDraining,
295
+ });
297
296
  }
298
297
  /**
299
298
  * 提交一次异步任务。入参可为 string / messages / {prompt|text|messages}。
@@ -537,7 +536,7 @@ export class AsyncRunner {
537
536
  rec.suspendedSince = undefined;
538
537
  rec.suspendedReason = undefined;
539
538
  rec.wakeAt = undefined;
540
- this.#unmarkSuspended(taskId);
539
+ this.#approvals.unmarkSuspended(taskId);
541
540
  const saved = this.#safeSave(rec, 'outcome');
542
541
  this.#cancels.delete(taskId);
543
542
  if (wasQueued)
@@ -588,178 +587,24 @@ export class AsyncRunner {
588
587
  /**
589
588
  * 审批一个处于 `suspended` 的任务(HITL)。
590
589
  *
591
- * - **逐 tool_use_id 幂等**:已存在的决定不覆盖(第一次决定赢)—— 重复提交 /
592
- * 并发点击不会推翻已有决定,也不会让恢复段重复执行;
593
- * - **并发重入共享在飞那次**:同一任务的并发 approve(双击「批准」/两个审批人
594
- * 同时批)返回同一个 Promise —— 否则两个调用都在对方落库前读到
595
- * `suspended`、各自判「决定齐了」、**各派发一次**(同一任务重复执行,
596
- * 与 resumePending 的闸门同一 bug 类)。被共享的那次覆盖不到的决定不丢:
597
- * 调用方从返回的记录看到任务仍在等待,重试即并入;
598
- * - 决定齐了就恢复:`status` 回 `running`、**先落库再派发**(与 `#redispatch`
599
- * 同一条纪律 —— 没落库就恢复,进程崩在窗口里会丢决定);恢复段带着
600
- * `rec.approvals` 与扩展后的消息历史(`rec.spec.messages`,末尾是含未决
601
- * tool_use 的那条 assistant 消息)重进引擎循环;
602
- * - 决定**没**齐:只把本批决定落库,任务继续等(可能多轮审批);
603
- * - 返回当前 TaskRecord(快照)。任务不存在抛 404 语义、状态不对抛 409 语义
604
- * 的 `TaskApproveError`。
590
+ * 实现已外移到 `approval-supervisor.ts`(**逐字搬迁**,含四条语义与它们的理由:
591
+ * 逐 tool_use_id 幂等 / 并发重入共享在飞那次 / 决定齐了先落库再派发 / 状态不对抛
592
+ * `TaskApproveError`)。这里只留薄壳 —— `approve` 是 `AsyncRunner` 的公开 API,
593
+ * 签名与返回类型不变。
605
594
  */
606
595
  async approve(taskId, decisions, opts = {}) {
607
- const inflight = this.inflightApprovals.get(taskId);
608
- if (inflight)
609
- return inflight;
610
- const run = this.#approveInner(taskId, decisions, opts).finally(() => {
611
- this.inflightApprovals.delete(taskId);
612
- });
613
- this.inflightApprovals.set(taskId, run);
614
- return run;
615
- }
616
- /** 同一任务的在飞审批(approve 的重入闸,见上)。**与惰性超时恢复共用同一把闸** ——
617
- * approve 与 #expireAndResume 都会「填决定 + 落库 + 派发」,不互斥就是同一任务跑两遍。 */
618
- inflightApprovals = new Map();
619
- async #approveInner(taskId, decisions, opts) {
620
- const rec = await this.store.get(taskId);
621
- if (!rec)
622
- throw new TaskApproveError(404, `task 不存在: ${taskId}`);
623
- if (rec.status !== 'suspended' || rec.suspendedReason !== 'approval') {
624
- throw new TaskApproveError(409, rec.status === 'suspended'
625
- ? `task ${taskId} 的挂起原因是 ${rec.suspendedReason ?? '未知'},只有等人工审批的挂起能审批`
626
- : `task ${taskId} 当前状态为 ${rec.status},只有挂起在等审批的任务才能审批`);
627
- }
628
- const now = Date.now();
629
- // 入参校验(2026-09-28 外部深评 P1-2):本批只许批**本任务待决的** id。
630
- // 不校验会怎样(实证:一次 approve 塞 5000 个无关键 ⇒ 记录从 246 字节撑到 419 KB):
631
- // 多出来的键照样写进 `rec.approvals`、随每次 save 全文重写落库、并随任务**永久保留**
632
- // (终态也不清:审批记录是审计的一部分)⇒ 一个**认证调用方**单次请求就能把记录撑大,
633
- // 反复调用可无限叠加。拒整批而不是挑着收:与 `parseApproveBody`(形状全有或全无)、
634
- // `parseEventBody`(多一个字段即拒)同一条纪律 —— 调用方本来就该从记录的 `pendingApprovals`
635
- // 里读要批哪些 id。
636
- // ⚠️ 为什么**不需要**再给 `rec.approvals` 加常量上限:单次 approve 能新写的键 ⊆ 当前
637
- // `pendingApprovals`(挂起那一刻固定)。跨多轮挂起它会累计**历次**待决的并集
638
- // (决定是审计,终态也保留),但每一轮都对应真跑出来的模型回合与 tool_use ——
639
- // 调用方的单次输入无法放大体积,增长只能随真实运行发生。再加常量上限是一段
640
- // 永远触发不到的死代码(那不是护栏,是噪音)。
641
- const pendingIds = new Set(rec.pendingApprovals ?? []);
642
- const unknownIds = Object.keys(decisions).filter((id) => !pendingIds.has(id));
643
- if (unknownIds.length > 0) {
644
- throw new TaskApproveError(400, `decisions 里有 ${unknownIds.length} 个 id 不在本任务的待决列表里(如 ${unknownIds[0]})—— 整批拒掉,记录不动`);
645
- }
646
- rec.approvals ??= {};
647
- for (const [id, d] of Object.entries(decisions)) {
648
- if (rec.approvals[id])
649
- continue; // 逐 id 幂等:第一次决定赢
650
- rec.approvals[id] = {
651
- approved: d.approved,
652
- ...(d.reason !== undefined ? { reason: d.reason } : {}),
653
- ...(opts.decidedBy !== undefined ? { decidedBy: opts.decidedBy } : {}),
654
- decidedAt: now,
655
- ...(rec.suspendedSince !== undefined ? { requestedAt: rec.suspendedSince } : {}),
656
- };
657
- }
658
- // 惰性审批超时:人的决定先并入(先到先赢),仍空着的待决项由超时兜底成 deny
659
- if (approvalExpired(rec, now, this.approvalTimeoutMs))
660
- fillTimeoutDenials(rec, now);
661
- const complete = approvalsComplete(rec);
662
- if (complete) {
663
- rec.status = 'running'; // 由 #executeInner 接管(acquireSlot → 恢复执行)
664
- rec.ownerId = this.ownerId;
665
- }
666
- // 先落库再派发 —— **真纪律,不是注释**:save 失败(同步抛 / 异步 reject)就
667
- // 绝不恢复执行(落不了库的决定不算决定:进程崩在窗口里会丢决定,重启后把
668
- // 同一件事再判一次、可能改判)。调用方拿到 reject,重试即可。
669
- await this.store.save(rec);
670
- if (complete) {
671
- // 离开挂起态(读数纪律②):决定齐了这就是「醒来」那一刻
672
- this.#unmarkSuspended(taskId);
673
- // 派发走**唯一入口**(闸在 #dispatch 里判一次 —— 2026-09-28 外部深评 P1-1 的结构性修法):
674
- // 决定已经在记录里,停机窗口里放行的是「重新排期」(下次启动认领),不是「吞掉」。
675
- this.#dispatch(rec);
676
- }
677
- return { ...rec };
596
+ return this.#approvals.approve(taskId, decisions, opts);
678
597
  }
679
598
  /**
680
- * 投递一个事件给**挂起**的任务(2026-09-28 ⑥,run 事件投入口;
681
- * `POST /tasks/:id/events` 的宿主方法侧,与 approve/cancel 对称)。
682
- *
683
- * 语义(设计稿 §3 逐条的落点):
684
- * - **只对 `suspended` 生效**:不存在 ⇒ 404;其余状态(含已终态)⇒ 409
685
- * (「不唤醒已终态」是这条状态闸的推论,与 approve 同款「状态不对要说出来」);
686
- * - **幂等**:`eventId` 给定时按 `rec.deliveredEventIds` 去重,重复 ⇒ 409
687
- * (簿记随记录落库,重启不丢;有界 FIFO,见 MAX_DELIVERED_EVENT_IDS)。
688
- * 不给 `eventId` 就**没有恰好一次**:重复投递 = 重复进历史(如实,不假装);
689
- * - **投完即续跑**(两种挂起原因都算 —— 对 timer 挂起这就是「提前醒」):
690
- * 事件进 `rec.pendingEvents`、状态回 `running`,**先落库再派发**
691
- * (与 approve / #wakeDueInner 同一条纪律:崩在窗口里不能丢事件)。
692
- * 事件**不**直接追加进 `rec.spec.messages` —— 续跑判定只认历史末尾一条
693
- * (`tailToolUses`),追加 user 消息会把续跑判成新对话;注入由引擎在
694
- * 未决 tool_use 解决之后做(见 engine/loop.ts 的 deliverTaskEvents);
695
- * - **不沿旧 wakeAt**:记录上的旧目标时刻不动(闸都 gate 在 suspended 上,
696
- * 状态一翻它就失效);醒来重跑那一批时工具若再次 `deferUntil`,
697
- * `wakeAt` 由新回合重新落定;
698
- * - drain 期间**仍允许**(与 approve 同理由:挂起的任务只有外部输入能推进)。
599
+ * 投递一个事件给**挂起**的任务(HITL 与 durable timer 两种挂起都适用)。
699
600
  *
700
- * 并发闸与 approve **不同款**:approve 共享在飞那次(决定是幂等合并),事件**串行成链**
701
- * (两条并发事件是两条不同的输入,共享 = 静默丢一条)。与其它恢复路径
702
- * (approve / 到期唤醒,各有自己的闸)的竞速:进闸后重读再判挡住绝大多数;
703
- * 剩下的窄窗口与既有「approve × wakeDue 分闸」同类(那两条按挂起原因天然互斥,
704
- * 事件两种原因都适用,是这个闸新盖的缝 —— 已记在 spec §10 2026-09-28 ⑥)。
601
+ * 实现已外移到 `signal-supervisor.ts`(**逐字搬迁**,含完整语义清单与并发闸的理由:
602
+ * 只对 `suspended` 生效 / 按 `eventId` 幂等 / 缓冲满了拒收并说出来 / 投完即续跑且
603
+ * **先落库再派发** / 并发时**串行成链**而不是像 approve 那样共享在飞那次)。
604
+ * 这里只留薄壳 —— `signalTask` 是 `AsyncRunner` 的公开 API,签名与返回类型不变。
705
605
  */
706
606
  signalTask(taskId, event) {
707
- // 串行化(**不是** approve 那种「共享在飞那次」):两个并发事件是**两条不同的输入**,
708
- // 共享在飞那次 = 后一条被静默丢掉;各自读-改-写而不互斥 = 双派发(与 resumePending
709
- // 的重入闸同一个 bug 类)。排成链:前一次落库完,这一次进闸后重读再判
710
- // (那时若已派发则状态是 running ⇒ 409 如实说出来,调用方可重试)。
711
- const prev = this.inflightSignals.get(taskId);
712
- const run = Promise.resolve(prev)
713
- .catch(() => undefined) // 前一次的 reject 已由它自己的调用方接走,这里只排队
714
- .then(() => this.#signalInner(taskId, event))
715
- .finally(() => {
716
- // 只摘自己:链上可能已有更新的节点(无条件 delete 会摘掉别人的)
717
- if (this.inflightSignals.get(taskId) === run)
718
- this.inflightSignals.delete(taskId);
719
- });
720
- this.inflightSignals.set(taskId, run);
721
- return run;
722
- }
723
- /** 同一任务的在飞事件投递(signalTask 的重入闸,见上) */
724
- inflightSignals = new Map();
725
- async #signalInner(taskId, event) {
726
- const rec = await this.store.get(taskId);
727
- if (!rec)
728
- throw new TaskEventError(404, `task 不存在: ${taskId}`);
729
- if (rec.status !== 'suspended') {
730
- throw new TaskEventError(409, `task ${taskId} 当前状态为 ${rec.status},只有挂起的任务能接收事件`);
731
- }
732
- // 幂等去重(先判再改):重复 ⇒ 409,记录一个字节不动(与 cancel 的「已终态」同款)
733
- if (event.eventId !== undefined && rec.deliveredEventIds?.includes(event.eventId)) {
734
- throw new TaskEventError(409, `事件 ${event.eventId} 已投递给 task ${taskId}(重复投递)`);
735
- }
736
- // 缓冲上限(2026-09-28 复审第三轮,定案 B:满了**说出来**,不静默丢)。
737
- // 判据次序 状态 ⇒ 幂等 ⇒ 容量:重复投递的那条本来就在缓冲里,报「重复投递」比报「满了」有用;
738
- // 两种 409 的**区别必须让调用方看得见** —— 重复投递是「已经在里面了」,满了是「没收下」。
739
- if ((rec.pendingEvents?.length ?? 0) >= MAX_PENDING_EVENTS) {
740
- throw new TaskEventError(409, `task ${taskId} 的待注入事件已达上限(${MAX_PENDING_EVENTS} 条)—— ${rec.suspendedReason === 'approval'
741
- ? '这条在等人工审批,而让它跑起来的唯一触发源是 approve(不在投递方手里)⇒ 要么等人批,要么 cancel 后重新提交'
742
- : '这条在等定时到点(approve 对它不适用)—— 到点它会自己醒来,缓冲里的事件在醒来那段注入;等不了就 cancel 后重新提交'};本次事件**没有**被记录。`);
743
- }
744
- rec.pendingEvents = [...(rec.pendingEvents ?? []), event];
745
- if (event.eventId !== undefined) {
746
- const ids = [...(rec.deliveredEventIds ?? []), event.eventId];
747
- rec.deliveredEventIds =
748
- ids.length > MAX_DELIVERED_EVENT_IDS
749
- ? ids.slice(ids.length - MAX_DELIVERED_EVENT_IDS)
750
- : ids;
751
- }
752
- rec.status = 'running'; // 由 #executeInner 接管(acquireSlot → 恢复执行)
753
- rec.ownerId = this.ownerId;
754
- // 先落库再派发 —— 与 #approveInner 同一条真纪律:save 失败就绝不恢复执行
755
- // (落不了库的事件不算投递:崩在窗口里会丢事件)。调用方拿到 reject,重试即可。
756
- await this.store.save(rec);
757
- // 离开挂起态(读数纪律②):事件到了这就是「醒来」那一刻 —— 与 approve 的决定齐了同形
758
- this.#unmarkSuspended(taskId);
759
- // 派发走唯一入口(闸在 #dispatch 里):停机窗口里不派发,事件留在 pendingEvents 上,
760
- // 下次启动的 resumePending 认领时注入。
761
- this.#dispatch(rec);
762
- return { ...rec };
607
+ return this.#signals.signalTask(taskId, event);
763
608
  }
764
609
  /** 已受理但未达终态的任务数(queued + running)—— 健康检查与 drain 共用同一口径 */
765
610
  get inFlight() {
@@ -768,33 +613,11 @@ export class AsyncRunner {
768
613
  /**
769
614
  * **本进程可见的挂起读数**(`/healthz` 的 `suspended` 段,配套 6)。
770
615
  *
771
- * 口径与 `inFlight` 同一张表:只数**本进程**经手的挂起 —— 跨进程要合并看板请自己聚合,
772
- * 不假装是全局面(别的进程挂起的记录这里看不见,重启后由首次扫描对齐,见下)。
773
- *
774
- * 三条维护纪律(这条读数的唯一风险是**漂移**,所以每条出口都要写清):
775
- * ① 进挂起时登记(`#executeInner` 的挂起分支,与落库同一个分支);
776
- * ② 离开挂起时除名(`approve` / 两条恢复路径 / 终态分支);
777
- * ③ **每次 `resumePending` 扫描按 store 重建** —— 扫描本来就 `list()` 了全表,
778
- * 顺手对齐即可,把「漏了某个出口」从永久漂移降级成「下一次扫描前的偏差」。
779
- *
780
- * 为什么不留成 store 查询(每次 /healthz 扫全表):`/healthz` 是探针端点(秒级频率),
781
- * 而 `list()` 要把**每条记录的完整 trace**取出来(sqlite/redis 下是全部反序列化)——
782
- * 拿它当健康检查的代价比它回答的问题大得多。
616
+ * 口径与三条维护纪律(挂起时登记 / 离开时除名 / 每次扫描按 store 重建)已随审批监督簇
617
+ * 迁到 `approval-supervisor.ts` 的 `#suspended`(那张注释就写在那里);这里只留对外 getter。
783
618
  */
784
- #suspended = new Map();
785
- /** 挂起读数:按原因分组的条数 + 最早的目标时刻(无时间挂起时为 `null`) */
786
619
  get suspendedSummary() {
787
- return summarizeSuspended(this.#suspended.values());
788
- }
789
- /** 登记一条挂起(纪律①)。原因缺失的记录不进表 —— 分不了组,宁可少数也不猜一个原因出来 */
790
- #markSuspended(rec) {
791
- if (rec.status !== 'suspended' || rec.suspendedReason === undefined)
792
- return;
793
- this.#suspended.set(rec.taskId, { reason: rec.suspendedReason, wakeAt: rec.wakeAt });
794
- }
795
- /** 除名(纪律②):任务离开挂起态时调用(恢复、终态都算) */
796
- #unmarkSuspended(taskId) {
797
- this.#suspended.delete(taskId);
620
+ return this.#approvals.summary;
798
621
  }
799
622
  /** 是否已进入优雅停机(drain 之后为 true)—— HTTP 宿主据此对新单回 503 */
800
623
  get isDraining() {
@@ -823,89 +646,26 @@ export class AsyncRunner {
823
646
  // 实现已外移到 drain-gate.ts(写前保留的契约注释仍在本方法上)
824
647
  return this.#drain.waitForIdle(() => this.active === 0, opts.timeoutMs ?? 0);
825
648
  }
826
- /**
827
- * 审批超时的**判定**已外移到 approval-policy.ts(`approvalExpired` / `fillTimeoutDenials` /
828
- * `approvalsComplete`)—— 下面是**编排**:进在飞闸、重读一遍、先落库再派发。
829
- */
830
- /**
831
- * 惰性超时扫描(HITL):读到一个已超时的 awaiting 任务 ⇒ 自动全拒 + 落库 + 重派。
832
- * 与 #redispatch 同一条纪律:**先落库再派发**(没落库就恢复,进程崩在窗口里会
833
- * 丢掉超时决定、把同一件事再判一次)。
834
- *
835
- * 重入闸(2026-09-20,见 spec §10 当日条):与 `approve` **共用同一把** per-taskId
836
- * 闸(inflightApprovals)。异步 store 的 `get` 返回**新副本**且有网络往返 —— 两个
837
- * 并发 poll(或 poll 与 approve)各自看到 awaiting 快照 ⇒ 双双填超时拒绝 + 双双
838
- * `#execute`(同一任务跑两遍)。在飞即跳过:另一路径自己会兜底(#approveInner
839
- * 里也有 approvalExpired 判定,见 approval-policy.ts),决定逐 id 幂等(第一次决定赢)。
840
- * 反之 approve 撞上在飞的超时恢复时共享其结果 —— 与人的决定竞速,先到先得。
841
- */
842
- #expireAndResume(rec, now) {
843
- if (this.inflightApprovals.has(rec.taskId))
844
- return;
845
- const run = this.#expireAndResumeInner(rec, now).finally(() => {
846
- this.inflightApprovals.delete(rec.taskId);
847
- });
848
- this.inflightApprovals.set(rec.taskId, run);
849
- // poll / resumePending 路径没有调用方接 reject —— 订阅掉,不得逃逸成 unhandled rejection
850
- run.catch(() => undefined);
851
- }
852
- async #expireAndResumeInner(rec, now) {
853
- // 进闸后**重读一遍**再判:闸只互斥「进入」,挡不住「进闸前已取到的旧副本」——
854
- // 本记录的快照可能是在另一次恢复落库**之前**取的(异步 store 的 get 有往返),
855
- // 凭陈旧快照放行会把同一任务再派发一次。
856
- const fresh = await this.store.get(rec.taskId);
857
- const target = fresh ?? rec;
858
- if (target.status !== 'suspended' || !approvalExpired(target, now, this.approvalTimeoutMs)) {
859
- return target;
860
- }
861
- fillTimeoutDenials(target, now);
862
- target.status = 'running';
863
- target.ownerId = this.ownerId;
864
- let saved;
865
- try {
866
- saved = this.store.save(target);
867
- }
868
- catch {
869
- return target; // 同步落库失败则不派发(与下一条异步分支同纪律:先落库再派发)
870
- }
871
- if (isThenable(saved)) {
872
- // 落库失败则不派发(认领没落地就派发 = 重新打开重复执行窗口)
873
- try {
874
- await saved;
875
- }
876
- catch {
877
- return target;
878
- }
879
- }
880
- // 离开挂起态(读数纪律②)—— 落在**落库成功之后**:落不了库就不算恢复(没派发,读数也不动)
881
- this.#unmarkSuspended(target.taskId);
882
- // 派发走唯一入口(闸在 #dispatch 里)。⚠️ **这一条正是外部深评抓到的第四条路径**:
883
- // `#expireAndResume`(审批超时自动全拒并恢复)与 `approve` 不同触发源、同一形状,
884
- // 由 `poll()` 的惰性闸驱动 —— 而「停机中照常可轮询」是 HTTP 宿主的明确承诺
885
- // (LB / K8s preStop / 前端轮询)⇒ 停机后照样能起新 run。
886
- this.#dispatch(target);
887
- return target;
888
- }
889
649
  /**
890
650
  * poll 的读路径钩子:两条**惰性**的挂起闸各判一次(都不起定时器 —— 没人读的挂起不会自己动,
891
651
  * 与审批超时同一条纪律;崩溃/重启也不依赖任何在飞回调)。
892
652
  *
893
- * 写成 `else if` 而不是两个独立的 `if`:两条闸的原因判据本来就互斥(`approval` / `timer`),
894
- * 排他在这里是**写出来的**而不是「两个函数各自恰好正确」—— C 之前正是「一个状态兼职两件事」
895
- * 让审批那条闸去叫醒了等时刻的 run。
653
+ * 审批那一半已外移到 `#approvals.expireIfExpired`(判据与副作用逐字等价,见
654
+ * approval-supervisor.ts);这里保留「命中审批就返回、不再判 timer」的形状 ——
655
+ * 两条闸的原因判据本来就互斥(`approval` / `timer`,`approvalExpired` 内部也卡原因),
656
+ * 排他在这里是**写出来的**而不是「两个函数各自恰好正确」—— C 之前正是「一个状态
657
+ * 兼职两件事」让审批那条闸去叫醒了等时刻的 run。
896
658
  */
897
659
  #lazyGates(rec) {
898
660
  if (!rec)
899
661
  return rec;
900
662
  const now = Date.now();
901
- if (approvalExpired(rec, now, this.approvalTimeoutMs))
902
- this.#expireAndResume(rec, now);
903
- else if (timerDue(rec, now))
904
- this.#wakeDue(rec, now);
663
+ if (this.#approvals.expireIfExpired(rec, now))
664
+ return rec;
665
+ if (timerDue(rec, now))
666
+ this.#scanner.wakeDue(rec, now);
905
667
  return rec;
906
668
  }
907
- /** 在飞的到期唤醒(同一任务的重入闸,理由与 inflightApprovals 同款:两条路径都「落库 + 派发」)。 */
908
- inflightWakes = new Map();
909
669
  /**
910
670
  * 取消的**意图**(2026-09-28 ④)。落库状态按意图判、不从 signal 反推 ——
911
671
  * 同一条 signal 有三个来源(调用方传入 / `runTimeoutMs` / 取消),反推不出「谁按的」。
@@ -920,65 +680,6 @@ export class AsyncRunner {
920
680
  * finally 就只做它自己那份(在飞递减、通知等待者、排空通知)。
921
681
  */
922
682
  #cancelSettled = new Set();
923
- /**
924
- * 到期唤醒(durable timer):读到一条**在睡且到点**的挂起 ⇒ 认领 + 重派,醒来后重跑那一批。
925
- *
926
- * 与 `#expireAndResume` 逐条同形(进在飞闸、重读一遍、先落库再派发),理由一字不差;
927
- * 三处不同,都写在下面:drain 之后不唤(配套 5)、不填任何决定(时间挂起没有待决项)、
928
- * 以及**返回值**(见下)。
929
- *
930
- * @returns 是否真的接管了这次唤醒 —— decline 时 `false`。`resumePending` 的返回值是
931
- * 「我推进了几条」,把没推进的算进去就是谎报(drain 之后每次扫描都报「唤醒了 N 条」,
932
- * 而它们一条都没动)。⚠️ 审批那条(`#expireAndResume`)的计数口径略宽(在飞也计入),
933
- * 那是既有行为、本批不动它。
934
- */
935
- #wakeDue(rec, now) {
936
- // 配套 5(drain 后不唤醒):停机是「不再往前推」,与 submit 在 drain 后回 503 同一条纪律。
937
- // 不设这道闸,一条天级的 sleeping run 会在停机窗口里被叫起来接着跑 —— 部署卡在它身上。
938
- // (重启后由新进程的首次 resumePending 唤醒:那时停机窗口早过去了。)
939
- if (this.#drain.isDraining)
940
- return false;
941
- if (this.inflightWakes.has(rec.taskId))
942
- return false;
943
- const run = this.#wakeDueInner(rec, now).finally(() => {
944
- this.inflightWakes.delete(rec.taskId);
945
- });
946
- this.inflightWakes.set(rec.taskId, run);
947
- // poll / resumePending 路径没有调用方接 reject —— 订阅掉,不得逃逸成 unhandled rejection
948
- run.catch(() => undefined);
949
- return true;
950
- }
951
- async #wakeDueInner(rec, now) {
952
- // 进闸后**重读一遍**再判(与 #expireAndResumeInner 同因):闸只互斥「进入」,
953
- // 挡不住「进闸前已取到的旧副本」——异步 store 的 get 有往返,凭陈旧快照放行会重复派发。
954
- const fresh = await this.store.get(rec.taskId);
955
- const target = fresh ?? rec;
956
- if (!timerDue(target, now))
957
- return target;
958
- target.status = 'running';
959
- target.ownerId = this.ownerId;
960
- let saved;
961
- try {
962
- saved = this.store.save(target);
963
- }
964
- catch {
965
- return target; // 同步落库失败则不派发(先落库再派发,见 #expireAndResumeInner)
966
- }
967
- if (isThenable(saved)) {
968
- try {
969
- await saved;
970
- }
971
- catch {
972
- return target;
973
- }
974
- }
975
- // 离开挂起态(读数纪律②)—— 落库成功之后才算醒来
976
- this.#unmarkSuspended(target.taskId);
977
- // 派发走唯一入口(闸在 #dispatch 里判一次 —— 它同时盖住「进入时」与「窗口里刚进入停机」
978
- // 两个时点,这里不必再判一遍)。
979
- this.#dispatch(target);
980
- return target;
981
- }
982
683
  /**
983
684
  * **唯一的派发口**(2026-09-28 外部深评 P1-1 / P2-1 的结构性修法)。
984
685
  *
@@ -1063,145 +764,16 @@ export class AsyncRunner {
1063
764
  * (running 视为进程中断)。返回重派数量(异步 store 下返回 Promise<number>)。
1064
765
  * 幂等键去重照常生效。
1065
766
  *
1066
- * `suspended`(HITL)**不捡**:它在等人、不是孤儿(进程没死也可能挂着)。
1067
- * 但读到它会做**惰性判定**,两条闸各管各的原因:
1068
- * - `approval`:配了 `approvalTimeoutMs` 且已超时的挂起任务自动全拒
1069
- * (`denied, reason: '审批超时'`)并重派;
1070
- * - `timer`:`wakeAt` 已到的睡着的任务**唤醒并重派**(醒来重跑那一批)。
1071
- * ⚠️ `drain()` 之后不唤醒(配套 5):停机窗口里天级的 run 被叫起来接着跑 = 部署卡死。
767
+ * 实现在 `resume-scanner.ts`(**逐字搬迁**,含那四步扫描的**顺序**与各自理由、重入闸、
768
+ * 到期索引接线、多进程 `ownerId` 的判定分级)。这里只留薄壳 —— `resumePending` 是
769
+ * `AsyncRunner` 的公开 API,签名与返回类型不变。
1072
770
  *
1073
- * **认领先落库、再派发**(见 `#redispatch`);异步 store 的认领落库失败会让本方法
1074
- * reject —— 宁可让调用方看见「续跑没做」,也不要静默放出一批会被重复执行的任务。
1075
- *
1076
- * 多进程共用一个 store 时靠 `ownerId` 区分他我:
1077
- * - 本进程的记录一律跳过(它还在本进程内存里跑,重派 = 跑两遍);
1078
- * - `staleAfterMs > 0` 时启动他进程判定:**同主机就问 pid 还在不在**(在的不抢、
1079
- * 不在的立刻抢),问不到(异主机 / 旧格式 ownerId)才退回保鲜期启发式。
1080
- * 缺省 0 = 不判断、一律重派(单进程旧语义)。判定分级与边界见 `resume-policy.ts`。
771
+ * 一句话提要(细节见实现):`suspended` **不捡**(它在等人、不是孤儿),但读到它会做
772
+ * 惰性判定 —— 审批超时自动全拒并重派、`wakeAt` 已到的唤醒并重派(`drain()` 之后不唤,
773
+ * 配套 5);认领**先落库再派发**;多进程靠 `ownerId` 区分他我(`staleAfterMs > 0` 时问 pid)。
1081
774
  */
1082
775
  resumePending(opts = {}) {
1083
- // 重入闸:**并发**调用共享同一次扫描的结果,而不是各自再扫一遍。
1084
- //
1085
- // 为什么必须(2026-09-18 补):「先落库再派发」只堵住了**串行**重扫 —— 认领是异步的
1086
- // (异步 store 的 `list()` 返回反序列化的**新对象**),两个并发调用都在任一 `save`
1087
- // 落地前 `list()` 到旧快照,`ownerId === this.ownerId` 的过滤对两份旧快照**双双失效**
1088
- // ⇒ 同一个任务被派发两次(`app.run` 重复执行,副作用与花费翻倍)。
1089
- //
1090
- // 语义:闸门期间返回**在飞那次的 Promise**(同一个数),而不是 0 —— 0 会谎称
1091
- // 「没派发任何东西」,而实际上派发了。调用方拿到的始终是本次扫描的真实结果。
1092
- if (this.inflightResume)
1093
- return this.inflightResume;
1094
- const listed = this.store.list();
1095
- const staleAfterMs = opts.staleAfterMs ?? 0;
1096
- // 到期索引(2026-09-28 ⑤ 落地):store 实现了 `listDue` 时,「到期唤醒」那一半的
1097
- // 输入走索引(只回到期的记录),否则回退全表过滤 —— 语义不变,只是扫描规模不同。
1098
- // ⚠️ 它**只**替代那一半的输入:挂起读数重建 / 审批超时 / 孤儿认领的职责
1099
- // 仍是全表 `list()`(它们要的不只是「到期的」),别把整个扫描的输入源换掉。
1100
- const dueListed = this.store.listDue === undefined ? undefined : this.store.listDue(Date.now());
1101
- if (isThenable(listed) || (dueListed !== undefined && isThenable(dueListed))) {
1102
- const run = Promise.all([listed, dueListed])
1103
- .then(([recs, due]) => this.#redispatch(recs, staleAfterMs, due))
1104
- .finally(() => {
1105
- this.inflightResume = null;
1106
- });
1107
- this.inflightResume = run;
1108
- return run;
1109
- }
1110
- const dispatched = this.#redispatch(listed, staleAfterMs, dueListed);
1111
- if (isThenable(dispatched)) {
1112
- const run = Promise.resolve(dispatched).finally(() => {
1113
- this.inflightResume = null;
1114
- });
1115
- this.inflightResume = run;
1116
- return run;
1117
- }
1118
- return dispatched;
1119
- }
1120
- /**
1121
- * 在飞的 `resumePending`(重入闸,见上)。不用 boolean 而用 Promise:
1122
- * 重入方要拿到**同一次扫描**的结果,而不是一个「你等着」的空数。
1123
- */
1124
- inflightResume = null;
1125
- /**
1126
- * 重新派发前**必须**先把 `ownerId` 认领落库 —— 否则「认领」只是内存里的一个记号。
1127
- *
1128
- * 病灶:此前这里只改内存里的 `status`/`ownerId` 就 `void #execute(rec)`,真正的 save
1129
- * 要等到 `#executeInner`(还在 `#acquireSlot` 之后)。对 sqlite/redis 这类 `list()`
1130
- * 返回**反序列化新对象**的 store,这段窗口里再调一次 `resumePending()` 读到的仍是旧
1131
- * ownerId,`:305` 的过滤失效 → 同一个任务被再派发一次 → `app.run` 重复执行,副作用
1132
- * 与花费翻倍。`InMemoryTaskStore` 存的是对象引用,恰好掩盖了这个问题。
1133
- *
1134
- * 返回类型刻意保持 `number | Promise<number>`:同步 store 的 save 是同步的,认领当场
1135
- * 落地,返回数字(`submit`/`list` 那套「同步 store 保持同步门面」的约定不变);只有
1136
- * 真出现 thenable 才升级成 Promise,等所有认领落库后再统一派发。
1137
- */
1138
- #redispatch(recs, staleAfterMs, due) {
1139
- const now = Date.now();
1140
- // 挂起读数的对齐(读数纪律③):扫描本来就拿到了全表,顺手把 `/healthz` 的读数
1141
- // 按 store 重建一遍 —— 于是「漏了某个除名出口」只是下一次扫描前的偏差,不会永久漂移。
1142
- // 顺带把**他进程**(或本进程上一世)留下的挂起也算进本进程可见的那些(口径见
1143
- // suspendedSummary 的注释:本进程看得见的记录,不假装是全局面)。
1144
- this.#suspended.clear();
1145
- for (const rec of recs) {
1146
- if (rec.status !== 'suspended' || rec.suspendedReason === undefined)
1147
- continue;
1148
- this.#suspended.set(rec.taskId, { reason: rec.suspendedReason, wakeAt: rec.wakeAt });
1149
- }
1150
- // 惰性审批超时扫描(HITL):suspended **不捡走续跑**(它在等人,不是
1151
- // 孤儿 —— 崩溃续跑语义不适用于「等审批」),但读到它时顺手判超时:
1152
- // 到点自动全拒并重派(框架补的 deny 决定先进 store,再进引擎)。
1153
- let expired = 0;
1154
- for (const rec of recs) {
1155
- if (rec.status !== 'suspended' || !approvalExpired(rec, now, this.approvalTimeoutMs))
1156
- continue;
1157
- expired++;
1158
- this.#expireAndResume(rec, now);
1159
- }
1160
- // 到期唤醒(durable timer):在睡且到点的 timer 挂起 ⇒ 认领 + 重派(醒来重跑那一批)。
1161
- // 与上面那条循环并列而不是合并:两条闸的**原因判据互斥**,各读各的一眼可见;
1162
- // 合并成一个循环会让「谁的责任」藏进条件里。drain 之后不唤(#wakeDue 里那道闸)。
1163
- // 输入(2026-09-28 ⑤):store 提供到期索引时 `due` 是「只回到期的」那一半 ——
1164
- // 仍过 `timerDue` 复核(索引口径是 status+wakeAt,原因那一半由这里兜,且
1165
- // #wakeDueInner 进闸后还会重读再判一次)。
1166
- let woken = 0;
1167
- for (const rec of due ?? recs) {
1168
- if (!timerDue(rec, now))
1169
- continue;
1170
- // 只算**真接管**的那些(drain / 已在飞 ⇒ 不算)—— 返回值是「推进了几条」的承诺
1171
- if (this.#wakeDue(rec, now))
1172
- woken++;
1173
- }
1174
- // 认领判定外移到 resume-policy.ts:跳过原因具名化(terminal / suspended / own-process /
1175
- // owner-alive / too-fresh),每条规则与边界都由那份纯函数的单测钉住
1176
- const alive = (id) => this.#ownerLiveness(id);
1177
- const pending = recs.filter((r) => resumeSkipReason(r, { ownerId: this.ownerId, staleAfterMs, now, ownerAlive: alive }) ===
1178
- undefined);
1179
- const claims = [];
1180
- for (const rec of pending) {
1181
- rec.status = 'queued'; // 重新入队,由 #execute 统一推进
1182
- rec.ownerId = this.ownerId; // 认领:此后本进程的记录不再被(自己)重派
1183
- const saved = this.store.save(rec);
1184
- if (isThenable(saved)) {
1185
- // 落库失败则**不派发**:认领没落地,派发等于把上面那个重复执行的窗口重新打开
1186
- claims.push(Promise.resolve(saved).then(() => this.#dispatch(rec)));
1187
- }
1188
- else {
1189
- // 派发走唯一入口(闸在 #dispatch 里)—— 外部深评 P2-1:认领循环以前绕过了那道闸。
1190
- this.#dispatch(rec);
1191
- }
1192
- }
1193
- if (claims.length === 0)
1194
- return pending.length + expired + woken;
1195
- return Promise.all(claims).then(() => pending.length + expired + woken);
1196
- }
1197
- /**
1198
- * 「这条记录的主人还活着吗」—— 绑上本机主机名后的 pid 存活判定(见 `owner-liveness.ts`)。
1199
- *
1200
- * 只被 `#redispatch` 用,且只在 `staleAfterMs > 0` 时真正被问到(那道闸在 resume-policy
1201
- * 里,单源;缺省 0 = 不看他进程,与升级前逐字一致)。判定不写库、不改状态 —— 纯读数。
1202
- */
1203
- #ownerLiveness(ownerId) {
1204
- return ownerAlive(ownerId, this.#host);
776
+ return this.#scanner.resumePending(opts);
1205
777
  }
1206
778
  /**
1207
779
  * 在飞计数包裹层:#executeInner 是状态机主体,这里只负责 active 计数与排空通知。
@@ -1452,7 +1024,7 @@ export class AsyncRunner {
1452
1024
  // 目标时刻(时间挂起才有)—— 同上,从结果形状里取,不按原因反推
1453
1025
  rec.wakeAt = out.result.wakeAt;
1454
1026
  // 挂起读数(纪律①):与落库**同一个分支**登记,不另起一处判断
1455
- this.#markSuspended(rec);
1027
+ this.#approvals.markSuspended(rec);
1456
1028
  // 事件簿记(2026-09-28 ⑥ 复审收口):**注入过**就清 —— 事件已在挂起历史里
1457
1029
  // (suspendedMessages 含注入的 user 消息),簿记留着会让下次续跑**重复注入**
1458
1030
  // (真探针实证过:模型看到同一事件两次)。没注入(续跑入口的再挂起出口在
@@ -1489,7 +1061,7 @@ export class AsyncRunner {
1489
1061
  rec.pendingEvents = undefined;
1490
1062
  // 离开挂起态(读数纪律②):两处恢复路径负责「还没跑到终态」的那一半,
1491
1063
  // 这一支负责「跑到了终态」的那一半(兜底:恢复失败也会落到这里)
1492
- this.#unmarkSuspended(rec.taskId);
1064
+ this.#approvals.unmarkSuspended(rec.taskId);
1493
1065
  // HITL 恢复段 + 会话:本段没把 session 交给 run 层(见上面 callOpts 注释),
1494
1066
  // 会话回写由 runner 自己补 —— 口径与 run.ts 的 appendSession 一致。
1495
1067
  if (isResume && out.run.status === 'succeeded') {