@migor/agentia 0.9.4 → 0.10.0
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.
- package/CHANGELOG.md +1280 -4
- package/README.en.md +61 -3
- package/README.md +66 -4
- package/dist/AGENTS.md +330 -45
- package/dist/container/container.js +21 -0
- package/dist/container/container.js.map +1 -1
- package/dist/core/limits.d.ts +101 -14
- package/dist/core/limits.js +103 -13
- package/dist/core/limits.js.map +1 -1
- package/dist/core/line-framing.d.ts +36 -0
- package/dist/core/line-framing.js +39 -0
- package/dist/core/line-framing.js.map +1 -0
- package/dist/core/message.d.ts +11 -0
- package/dist/core/run.d.ts +68 -4
- package/dist/core/run.js +27 -1
- package/dist/core/run.js.map +1 -1
- package/dist/core/schema.js +59 -8
- package/dist/core/schema.js.map +1 -1
- package/dist/core/timeout.d.ts +43 -1
- package/dist/core/timeout.js +76 -3
- package/dist/core/timeout.js.map +1 -1
- package/dist/core/tool.d.ts +25 -1
- package/dist/core/trace.d.ts +40 -1
- package/dist/core/trace.js +26 -0
- package/dist/core/trace.js.map +1 -1
- package/dist/engine/budget.js +14 -0
- package/dist/engine/budget.js.map +1 -1
- package/dist/engine/defer.d.ts +49 -0
- package/dist/engine/defer.js +72 -0
- package/dist/engine/defer.js.map +1 -0
- package/dist/engine/errors.d.ts +23 -1
- package/dist/engine/errors.js +24 -0
- package/dist/engine/errors.js.map +1 -1
- package/dist/engine/forwarded.d.ts +14 -5
- package/dist/engine/forwarded.js +4 -1
- package/dist/engine/forwarded.js.map +1 -1
- package/dist/engine/loop-result.d.ts +65 -7
- package/dist/engine/loop-result.js +24 -5
- package/dist/engine/loop-result.js.map +1 -1
- package/dist/engine/loop.d.ts +2 -0
- package/dist/engine/loop.js +157 -17
- package/dist/engine/loop.js.map +1 -1
- package/dist/engine/mcp-server-http.d.ts +2 -0
- package/dist/engine/mcp-server-http.js +215 -0
- package/dist/engine/mcp-server-http.js.map +1 -0
- package/dist/engine/mcp-server-stdio.d.ts +15 -0
- package/dist/engine/mcp-server-stdio.js +106 -0
- package/dist/engine/mcp-server-stdio.js.map +1 -0
- package/dist/engine/mcp-server.d.ts +134 -0
- package/dist/engine/mcp-server.js +232 -0
- package/dist/engine/mcp-server.js.map +1 -0
- package/dist/engine/menu-drift.d.ts +34 -0
- package/dist/engine/menu-drift.js +81 -0
- package/dist/engine/menu-drift.js.map +1 -0
- package/dist/engine/policy.d.ts +2 -1
- package/dist/engine/policy.js +7 -1
- package/dist/engine/policy.js.map +1 -1
- package/dist/engine/replay.d.ts +2 -1
- package/dist/engine/replay.js +3 -2
- package/dist/engine/replay.js.map +1 -1
- package/dist/engine/resume-input.d.ts +10 -0
- package/dist/engine/resume-input.js +11 -0
- package/dist/engine/resume-input.js.map +1 -1
- package/dist/engine/run-config.d.ts +26 -2
- package/dist/engine/run-config.js +60 -0
- package/dist/engine/run-config.js.map +1 -1
- package/dist/engine/spec.d.ts +33 -4
- package/dist/engine/spec.js.map +1 -1
- package/dist/engine/tool-context.d.ts +10 -5
- package/dist/engine/tool-context.js +5 -1
- package/dist/engine/tool-context.js.map +1 -1
- package/dist/engine/tracer.d.ts +43 -5
- package/dist/engine/tracer.js +73 -10
- package/dist/engine/tracer.js.map +1 -1
- package/dist/engine/trimming.js +28 -2
- package/dist/engine/trimming.js.map +1 -1
- package/dist/engine/turn.d.ts +60 -3
- package/dist/engine/turn.js +180 -71
- package/dist/engine/turn.js.map +1 -1
- package/dist/engine/types.d.ts +89 -3
- package/dist/engine/types.js +38 -2
- package/dist/engine/types.js.map +1 -1
- package/dist/eval/export.d.ts +48 -0
- package/dist/eval/export.js +146 -0
- package/dist/eval/export.js.map +1 -0
- package/dist/eval/harvest.js +7 -2
- package/dist/eval/harvest.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/adapter-options.d.ts +22 -0
- package/dist/integrations/adapter-options.js +51 -0
- package/dist/integrations/adapter-options.js.map +1 -1
- package/dist/integrations/anthropic.d.ts +4 -2
- package/dist/integrations/anthropic.js +27 -27
- package/dist/integrations/anthropic.js.map +1 -1
- package/dist/integrations/file-sink.d.ts +20 -0
- package/dist/integrations/file-sink.js +27 -0
- package/dist/integrations/file-sink.js.map +1 -0
- package/dist/integrations/mcp-http.d.ts +3 -3
- package/dist/integrations/mcp-http.js +1 -1
- package/dist/integrations/mcp-http.js.map +1 -1
- package/dist/integrations/mcp-protocol.d.ts +115 -0
- package/dist/integrations/mcp-protocol.js +69 -0
- package/dist/integrations/mcp-protocol.js.map +1 -0
- package/dist/integrations/mcp-stdio.d.ts +3 -3
- package/dist/integrations/mcp-stdio.js +17 -3
- package/dist/integrations/mcp-stdio.js.map +1 -1
- package/dist/integrations/mcp.d.ts +6 -95
- package/dist/integrations/mcp.js +24 -66
- package/dist/integrations/mcp.js.map +1 -1
- package/dist/integrations/metrics-otlp.js +52 -7
- package/dist/integrations/metrics-otlp.js.map +1 -1
- package/dist/integrations/metrics-render.d.ts +15 -0
- package/dist/integrations/metrics-render.js +79 -2
- package/dist/integrations/metrics-render.js.map +1 -1
- package/dist/integrations/metrics-state.d.ts +99 -1
- package/dist/integrations/metrics-state.js +128 -4
- package/dist/integrations/metrics-state.js.map +1 -1
- package/dist/integrations/metrics.d.ts +62 -6
- package/dist/integrations/metrics.js +49 -8
- package/dist/integrations/metrics.js.map +1 -1
- package/dist/integrations/openai.js +9 -31
- package/dist/integrations/openai.js.map +1 -1
- package/dist/integrations/otlp.d.ts +3 -3
- package/dist/integrations/report.d.ts +7 -0
- package/dist/integrations/report.js +20 -1
- package/dist/integrations/report.js.map +1 -1
- package/dist/runtime/memory.d.ts +61 -1
- package/dist/runtime/memory.js +47 -7
- package/dist/runtime/memory.js.map +1 -1
- package/dist/runtime/run.d.ts +12 -2
- package/dist/runtime/run.js +126 -17
- package/dist/runtime/run.js.map +1 -1
- package/dist/store/fsStore.js +10 -4
- package/dist/store/fsStore.js.map +1 -1
- package/dist/store/record.d.ts +41 -0
- package/dist/store/record.js +54 -0
- package/dist/store/record.js.map +1 -0
- package/dist/store/redisStore.d.ts +24 -1
- package/dist/store/redisStore.js +42 -7
- package/dist/store/redisStore.js.map +1 -1
- package/dist/store/sqliteStore.d.ts +23 -0
- package/dist/store/sqliteStore.js +103 -8
- package/dist/store/sqliteStore.js.map +1 -1
- package/dist/store/store.d.ts +65 -2
- package/dist/store/store.js +5 -2
- package/dist/store/store.js.map +1 -1
- package/dist/toolkit/capability-cycles.d.ts +50 -0
- package/dist/toolkit/capability-cycles.js +88 -0
- package/dist/toolkit/capability-cycles.js.map +1 -0
- package/dist/toolkit/capability-slice.d.ts +86 -0
- package/dist/toolkit/capability-slice.js +98 -0
- package/dist/toolkit/capability-slice.js.map +1 -0
- package/dist/toolkit/module.d.ts +7 -1
- package/dist/toolkit/module.js +65 -32
- package/dist/toolkit/module.js.map +1 -1
- package/dist/transport/approval-policy.d.ts +1 -1
- package/dist/transport/approval-policy.js +8 -4
- package/dist/transport/approval-policy.js.map +1 -1
- package/dist/transport/approval-supervisor.d.ts +73 -0
- package/dist/transport/approval-supervisor.js +252 -0
- package/dist/transport/approval-supervisor.js.map +1 -0
- package/dist/transport/async.d.ts +159 -56
- package/dist/transport/async.js +627 -269
- package/dist/transport/async.js.map +1 -1
- package/dist/transport/drain-gate.d.ts +6 -2
- package/dist/transport/drain-gate.js +14 -2
- package/dist/transport/drain-gate.js.map +1 -1
- package/dist/transport/http-endpoints.d.ts +151 -0
- package/dist/transport/http-endpoints.js +480 -0
- package/dist/transport/http-endpoints.js.map +1 -0
- package/dist/transport/http-io.d.ts +66 -0
- package/dist/transport/http-io.js +145 -0
- package/dist/transport/http-io.js.map +1 -0
- package/dist/transport/http-route.d.ts +10 -3
- package/dist/transport/http-route.js +18 -3
- package/dist/transport/http-route.js.map +1 -1
- package/dist/transport/http-shapes.d.ts +11 -0
- package/dist/transport/http-shapes.js +31 -1
- package/dist/transport/http-shapes.js.map +1 -1
- package/dist/transport/http.d.ts +22 -35
- package/dist/transport/http.js +98 -476
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/owner-id.d.ts +29 -0
- package/dist/transport/owner-id.js +46 -0
- package/dist/transport/owner-id.js.map +1 -0
- package/dist/transport/owner-liveness.d.ts +8 -0
- package/dist/transport/owner-liveness.js +56 -0
- package/dist/transport/owner-liveness.js.map +1 -0
- package/dist/transport/resume-policy.d.ts +48 -10
- package/dist/transport/resume-policy.js +25 -10
- package/dist/transport/resume-policy.js.map +1 -1
- package/dist/transport/resume-scanner.d.ts +55 -0
- package/dist/transport/resume-scanner.js +219 -0
- package/dist/transport/resume-scanner.js.map +1 -0
- package/dist/transport/scheduler.js +9 -23
- package/dist/transport/scheduler.js.map +1 -1
- package/dist/transport/signal-supervisor.d.ts +80 -0
- package/dist/transport/signal-supervisor.js +129 -0
- package/dist/transport/signal-supervisor.js.map +1 -0
- package/dist/transport/slot-pool.d.ts +9 -1
- package/dist/transport/slot-pool.js +9 -1
- package/dist/transport/slot-pool.js.map +1 -1
- package/dist/transport/task-events.d.ts +34 -3
- package/dist/transport/task-events.js +93 -5
- package/dist/transport/task-events.js.map +1 -1
- package/dist/transport/wake-policy.d.ts +50 -0
- package/dist/transport/wake-policy.js +43 -0
- package/dist/transport/wake-policy.js.map +1 -0
- package/package.json +14 -4
package/dist/transport/async.js
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
2
3
|
import { classifyError } from '../engine/errors.js';
|
|
4
|
+
import { isTerminalStatus } from '../core/run.js';
|
|
3
5
|
import { normalizeMessages, TaskInputError } from '../engine/spec.js';
|
|
4
6
|
import { InMemoryTaskStore, isThenable, nextTaskId } from '../store/store.js';
|
|
5
7
|
import { combineSignals, releaseCombinedSignal } from '../core/abort.js';
|
|
6
|
-
import {
|
|
8
|
+
import { abortedError } from '../engine/turn.js';
|
|
9
|
+
import { assertTimerDelay, TimeoutError } from '../core/timeout.js';
|
|
7
10
|
import { zeroClauseOf } from '../core/limits.js';
|
|
8
11
|
import { composeTraceEvents } from '../core/trace.js';
|
|
9
12
|
import { SlotPool } from './slot-pool.js';
|
|
10
|
-
import {
|
|
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';
|
|
11
17
|
import { DrainGate } from './drain-gate.js';
|
|
12
|
-
import {
|
|
18
|
+
import { formatOwnerId } from './owner-id.js';
|
|
13
19
|
import { TaskWaiters } from './task-waiters.js';
|
|
14
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';
|
|
15
26
|
/**
|
|
16
27
|
* `streamTask` 的失败:带 HTTP 语义的状态码(404 = 任务不存在),HTTP 宿主据此回对应响应。
|
|
17
28
|
* 形状与 `TaskApproveError` 同款(module 级 export,不进公共导出面)。
|
|
@@ -27,36 +38,138 @@ export class TaskStreamError extends Error {
|
|
|
27
38
|
/**
|
|
28
39
|
* 任务是否**已到终态**(流可以收口了)。
|
|
29
40
|
*
|
|
30
|
-
* ⚠️ 与 `resume-policy.ts` 那个「可续跑」的判定**刻意不同**:那里 `
|
|
41
|
+
* ⚠️ 与 `resume-policy.ts` 那个「可续跑」的判定**刻意不同**:那里 `suspended`
|
|
31
42
|
* 算「不可续跑」(重启扫描不该去动它),这里是**非终态** —— 挂起在等人,人批了它会接着跑,
|
|
32
43
|
* 流必须**继续开着**(关掉的话「等审批结果的前端」正好在最需要的时候断线)。
|
|
33
44
|
*/
|
|
34
45
|
function isTerminalTask(rec) {
|
|
35
|
-
|
|
46
|
+
// 判定外移到 core/run.ts 的 `isTerminalStatus`(单一真源,2026-09-28 外部深评 S1):
|
|
47
|
+
// 这里原本是负向枚举(`status !== 'queued' && …`),而 Redis store 那边压根没判 ——
|
|
48
|
+
// 同一事实两份读数,写法还不一样。那两处现在都调同一个函数。
|
|
49
|
+
return isTerminalStatus(rec.status);
|
|
36
50
|
}
|
|
37
51
|
/**
|
|
38
|
-
* `approve`
|
|
39
|
-
* `
|
|
40
|
-
* module 级 export —— 不进公共导出面(纯宿主内部实现细节)。
|
|
52
|
+
* `TaskApproveError`(`approve` 的失败,带 HTTP 语义状态码)已随审批监督簇迁到
|
|
53
|
+
* `approval-supervisor.ts`,本文件顶部 re-export 保持导出面不变(定义与理由见那里)。
|
|
41
54
|
*/
|
|
42
|
-
|
|
55
|
+
/**
|
|
56
|
+
* `cancel` 的失败:带 HTTP 语义的状态码(404 = 任务不存在,409 = 已终态 / 在飞 run 不在本进程)。
|
|
57
|
+
* 形状与 `TaskApproveError` 同款(module 级 export,不进公共导出面)。
|
|
58
|
+
*/
|
|
59
|
+
export class TaskCancelError extends Error {
|
|
43
60
|
status;
|
|
44
61
|
constructor(status, message) {
|
|
45
62
|
super(message);
|
|
46
|
-
this.name = '
|
|
63
|
+
this.name = 'TaskCancelError';
|
|
47
64
|
this.status = status;
|
|
48
65
|
}
|
|
49
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* `submit` 在**排队段已满**时的失败(`AsyncRunnerOptions.maxQueued`,2026-09-28 外部深评 T5)。
|
|
69
|
+
*
|
|
70
|
+
* 为什么单独一个类型、而不是复用 `TaskInputError`:两者在 HTTP 上的**含义不同** ——
|
|
71
|
+
* 入参不合法是 **400**(调用方改请求就能过),排队满**不是调用方的错**,是 **503
|
|
72
|
+
* + `Retry-After`**(同一次请求稍后重发就该过)。宿主若把它们混成一支,运维看到的就是
|
|
73
|
+
* 「你的请求有问题」,而实际是「服务该扩容 / 该退避」—— 与 `maxConcurrentRuns` 的 503
|
|
74
|
+
* 那支刻意分开的理由同款。
|
|
75
|
+
*
|
|
76
|
+
* `status` 是自陈字段(本仓 `TaskApproveError` 起的惯例):宿主按它选状态码,不靠
|
|
77
|
+
* `instanceof` 猜;本类目前只有 503 一种。
|
|
78
|
+
*
|
|
79
|
+
* module 级 export —— 不进公共导出面(与 TaskApproveError / TaskCancelError / TaskEventError 同档)。
|
|
80
|
+
*/
|
|
81
|
+
export class TaskQueueFullError extends Error {
|
|
82
|
+
status = 503;
|
|
83
|
+
constructor(message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = 'TaskQueueFullError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 取消的**宽限**:取消请求发出后,留给那次 run 自己收尾的时间。
|
|
90
|
+
*
|
|
91
|
+
* 尊重 signal 的 client 是毫秒级收尾(SDK 一收到 abort 就 reject,run 随即以 `stopReason:
|
|
92
|
+
* 'aborted'` 收尾 —— 那条路连 `result` 与 trace 都保得住,本常量对它只是余量:一次网络往返 +
|
|
93
|
+
* trace 落盘)。不认 signal 的宿主**永远**不会自己收尾 —— 等多久都是白等,所以到点就撤回意图、
|
|
94
|
+
* 如实抛 409(见 `#awaitCancelled`),不假装取消成功。
|
|
95
|
+
*/
|
|
96
|
+
const CANCEL_GRACE_MS = 2_000;
|
|
50
97
|
export class AsyncRunner {
|
|
51
98
|
app;
|
|
52
99
|
store;
|
|
53
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* 本进程标识:写进认领的 `TaskRecord.ownerId`,供 resumePending 区分他我。
|
|
102
|
+
*
|
|
103
|
+
* 形状 `p<pid>@<host>-<8位十六进制>`(真源见 `owner-id.ts`)。为什么带主机名与 pid:
|
|
104
|
+
* 光有时间判不出「主人还在不在」(`staleAfterMs` 是**新鲜度**不是**租约**),
|
|
105
|
+
* 而「同主机 + pid 还在」是能直接问操作系统的**直接证据**(见 `owner-liveness.ts`)。
|
|
106
|
+
*/
|
|
54
107
|
ownerId;
|
|
108
|
+
/**
|
|
109
|
+
* 本机主机名(构造期取一次)。**同一个值**既写进 `ownerId`、也用于「同主机吗」的判定 ——
|
|
110
|
+
* 两处各自取一次就可能不一致(hostname 在进程存活期间是可能被改的),判定会因此静默失效。
|
|
111
|
+
*/
|
|
112
|
+
#host = hostname();
|
|
55
113
|
client;
|
|
56
114
|
sessionStore;
|
|
57
115
|
concurrency;
|
|
116
|
+
maxQueued;
|
|
117
|
+
/**
|
|
118
|
+
* 当前**在等槽位**的任务 id 集合(已受理、尚未拿到槽位)—— `maxQueued` 的判据,
|
|
119
|
+
* 也是 `queued` 读数。
|
|
120
|
+
*
|
|
121
|
+
* 为什么是**集合**而不是计数器:入口只有一处(`#execute` 的**同步前段**、第一个 await
|
|
122
|
+
* 之前 `add`),出口有两处(`#executeInner` 里**拿到槽位**那一刻 `delete`,`#execute`
|
|
123
|
+
* 的外层 finally 再 `delete` 一次兜底 —— 覆盖「幂等去重早退 / 中途抛错」)。`add` /
|
|
124
|
+
* `delete` 的语义**天然幂等**,所以多重释放无害、漏放不可能(兜底那道闸在 finally 里)。
|
|
125
|
+
* 用整数计数器要做到同一件事,得自己再造一个「只减一次」的闸 —— 那正是最容易写漏的东西
|
|
126
|
+
* (第一版就是这么写的,而释放点根本不在同一个函数作用域里,闭包跨不过去)。
|
|
127
|
+
*
|
|
128
|
+
* ⚠️ 同一 taskId 的两次 `#execute` 并发不在设计内(在飞闸挡着);万一发生,先退出那一趟
|
|
129
|
+
* 的 finally 会把仍在等槽位的那趟从集合里删掉 ⇒ 读数**偏小**(闸更宽松)。偏小是安全的
|
|
130
|
+
* 方向:它不会把服务永久锁在 503 上,而「多收一条」只是回到加本旋钮之前的行为。
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ **它本身不是排队深度**:队列长度要减掉「马上就能拿到空槽位的那些」,见 `#queueDepth`
|
|
133
|
+
* —— 一个 `submit` 循环里同步灌进来的每一条都会先落进本集合,而此时池子还空着,
|
|
134
|
+
* 直接拿 `size` 当深度会把「立刻就能跑的」也算成排队的(`concurrency: 1` + `maxQueued: 1`
|
|
135
|
+
* 下连灌两条就会把第 2 条误拒 —— 第一版就是这么写的,被用例当场抓出来)。
|
|
136
|
+
*/
|
|
137
|
+
#waiting = new Set();
|
|
138
|
+
/**
|
|
139
|
+
* **真正在排队的深度**(`maxQueued` 的判据,也是 `queued` 读数)。
|
|
140
|
+
*
|
|
141
|
+
* 公式:`max(0, 已受理未持槽的任务数 + adding − 当前空槽位数)`。
|
|
142
|
+
*
|
|
143
|
+
* 为什么不能只用等待队列长度、也不能只用 `#waiting.size`:
|
|
144
|
+
* - `#waiting` 里混了「刚 submit、还没走到 acquire」的任务 —— 池子空着时它们**不会排队**,
|
|
145
|
+
* 只是还没轮到执行那一刻。`adding` 就是给「正在提交的这一条」留的位置。
|
|
146
|
+
* - 空槽位数 = `concurrency − #slots.inUse`。于是「同步灌 N 条」时:先到的 N 条一边进
|
|
147
|
+
* `#waiting`、一边还算着同样的空槽位,两个量相抵 ⇒ 判据正好落在
|
|
148
|
+
* 「最多 `concurrency + maxQueued` 条在推进」这条对外口径上(见 `AsyncRunnerOptions.maxQueued`)。
|
|
149
|
+
* - `concurrency` 为 `Infinity`(缺省)时空槽位数是 `Infinity` ⇒ 深度恒为 0 ⇒ 闸天然不起作用,
|
|
150
|
+
* 与「缺省行为与加本旋钮之前逐字一致」对齐。
|
|
151
|
+
*/
|
|
152
|
+
#queueDepth(adding = 0) {
|
|
153
|
+
const freeSlots = Math.max(0, this.concurrency - this.#slots.inUse);
|
|
154
|
+
return Math.max(0, this.#waiting.size + adding - freeSlots);
|
|
155
|
+
}
|
|
58
156
|
runTimeoutMs;
|
|
59
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;
|
|
60
173
|
/** 并发槽位池(见 slot-pool.ts):本类不再自管 running/waitQueue */
|
|
61
174
|
#slots;
|
|
62
175
|
/** 已受理但未达终态的任务数(queued + running)—— /healthz 与 drain 共用 */
|
|
@@ -65,6 +178,21 @@ export class AsyncRunner {
|
|
|
65
178
|
#drain = new DrainGate();
|
|
66
179
|
/** 任务终态等待表(见 task-waiters.ts):仅覆盖本进程写终态,他进程写靠兜底轮询 */
|
|
67
180
|
#taskWaiters = new TaskWaiters();
|
|
181
|
+
/**
|
|
182
|
+
* 本进程**已受理并正在推进**的任务(= `#execute` 的存活区间)。
|
|
183
|
+
*
|
|
184
|
+
* 只为 `cancel` 的文案存在:命中「`status === 'running'` 但在飞句柄表里没有」时,
|
|
185
|
+
* 区分「**本进程排队未起跑**」与「**真不在本进程**」——
|
|
186
|
+
* - 四条恢复路径(approve / 到期唤醒 / 事件投递 / 崩溃重投)都**先**把状态置 `running`
|
|
187
|
+
* 再 `#dispatch`,而 `#runAborts` 要到 `#slots.acquire()` 之后才登记 ⇒ 中间整个窗口
|
|
188
|
+
* 里 `status` 已是 running、句柄却还没有。原文案一律说「不在本进程」,
|
|
189
|
+
* 对同进程排队的那条是**说反话**(2026-09-28 外部复核抓到)。
|
|
190
|
+
* - 本集合与 `#runAborts` 的**差集**恰好就是「已受理、未起跑」(见 `#cancel`)。
|
|
191
|
+
*
|
|
192
|
+
* ⚠️ **纯诊断**:不参与任何正确性判定,也不影响 409 闸本身(那道闸不放开 ——
|
|
193
|
+
* 放开会让等槽位那趟 `#execute` 的 finally 双发 `onFinished`,正是 spec §10 的变异 M23)。
|
|
194
|
+
*/
|
|
195
|
+
#executions = new Set();
|
|
68
196
|
/**
|
|
69
197
|
* 每任务的记账事件流(见 task-events.ts):`GET /tasks/:id/stream` 的重放 + 实时推送。
|
|
70
198
|
* 纯记账件,独立于 store / 槽位 —— 所以在 855 行的类里单独抽一个文件。
|
|
@@ -102,13 +230,25 @@ export class AsyncRunner {
|
|
|
102
230
|
this.sessionStore = opts.sessionStore;
|
|
103
231
|
this.taskSinks = opts.taskSinks ?? [];
|
|
104
232
|
this.onPersistError = opts.onPersistError;
|
|
105
|
-
this.#streams = new TaskEventStreams(
|
|
233
|
+
this.#streams = new TaskEventStreams({
|
|
234
|
+
...(opts.streamBufferEvents === undefined ? {} : { maxEvents: opts.streamBufferEvents }),
|
|
235
|
+
...(opts.streamNonTerminalBuffers === undefined
|
|
236
|
+
? {}
|
|
237
|
+
: { nonTerminalBuffers: opts.streamNonTerminalBuffers }),
|
|
238
|
+
});
|
|
106
239
|
this.concurrency = opts.concurrency ?? Number.POSITIVE_INFINITY;
|
|
107
240
|
if (!(this.concurrency > 0)) {
|
|
108
|
-
throw new Error(`concurrency
|
|
241
|
+
throw new Error(`concurrency 必须为正数(${zeroClauseOf('AsyncRunner.concurrency')}),收到 ${opts.concurrency}`);
|
|
109
242
|
}
|
|
110
243
|
// 放在校验之后:字段初始化式在构造函数体之前求值,那时 concurrency 还是 undefined
|
|
111
244
|
this.#slots = new SlotPool(this.concurrency);
|
|
245
|
+
this.maxQueued = opts.maxQueued ?? 0;
|
|
246
|
+
if (!Number.isInteger(this.maxQueued) || this.maxQueued < 0) {
|
|
247
|
+
// 与 concurrency 同族:`0` 本身**合法**(= 不限),坏的是「负数 / 小数 / NaN / Infinity」。
|
|
248
|
+
// 负数的读法无解(「最多排 -1 个」是什么),小数会让比较变成「有时收有时不收」——
|
|
249
|
+
// 都不是「配置宽一点」而是「配置说了另一件事」,所以构造期响亮失败。
|
|
250
|
+
throw new Error(`maxQueued 必须为 ≥ 0 的整数(${zeroClauseOf('AsyncRunner.maxQueued')}),收到 ${opts.maxQueued}`);
|
|
251
|
+
}
|
|
112
252
|
this.runTimeoutMs = opts.runTimeoutMs ?? 0;
|
|
113
253
|
if (!Number.isFinite(this.runTimeoutMs) || this.runTimeoutMs < 0) {
|
|
114
254
|
// NaN/Infinity 都不能放给 setTimeout:两者都会被钳到 1ms,每个任务立即「超时」失败
|
|
@@ -116,12 +256,43 @@ export class AsyncRunner {
|
|
|
116
256
|
// 文案里的「0 = 不限」取自 core/limits.ts 的表 —— 口径与实现同一处,不可能各说各话。
|
|
117
257
|
throw new Error(`runTimeoutMs 必须为 ≥ 0 的有限数(${zeroClauseOf('AsyncRunner.runTimeoutMs')}),收到 ${opts.runTimeoutMs}`);
|
|
118
258
|
}
|
|
259
|
+
// 上界(2026-09-28):「≥0 且有限」还不够 —— 超过 2^31-1ms(约 24.86 天)的延迟
|
|
260
|
+
// Node **不会遵守**(stderr 一行 TimeoutOverflowWarning + 钳到 1ms),于是「配 30 天
|
|
261
|
+
// 超时」变成「每个任务立即超时失败」,静默且与配置相反。上限单源与五处站点共用:
|
|
262
|
+
// 这一句就是 scheduler.ts 注释里承诺过的「async.ts 同款防线」(此前那句话与实现不符)。
|
|
263
|
+
assertTimerDelay(this.runTimeoutMs, 'AsyncRunner 的 runTimeoutMs');
|
|
119
264
|
this.approvalTimeoutMs = opts.approvalTimeoutMs ?? 0;
|
|
120
265
|
if (!Number.isFinite(this.approvalTimeoutMs) || this.approvalTimeoutMs < 0) {
|
|
121
266
|
// 同 runTimeoutMs:非有限数会让「已挂起多久」的比较静默失效或立即超时
|
|
122
267
|
throw new Error(`approvalTimeoutMs 必须为 ≥ 0 的有限数(${zeroClauseOf('AsyncRunner.approvalTimeoutMs')}),收到 ${opts.approvalTimeoutMs}`);
|
|
123
268
|
}
|
|
124
|
-
this.ownerId =
|
|
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
|
+
});
|
|
125
296
|
}
|
|
126
297
|
/**
|
|
127
298
|
* 提交一次异步任务。入参可为 string / messages / {prompt|text|messages}。
|
|
@@ -156,10 +327,28 @@ export class AsyncRunner {
|
|
|
156
327
|
// 处理,拒绝即视为无记录,交 #execute 的去重兜底。
|
|
157
328
|
existing.catch(() => undefined);
|
|
158
329
|
}
|
|
159
|
-
else if (existing && existing.status !== 'failed') {
|
|
160
|
-
|
|
330
|
+
else if (existing && existing.status !== 'failed' && existing.status !== 'cancelled') {
|
|
331
|
+
// at-least-once 去重:不重复执行。`failed` 被刻意排除(失败后同键重提要真跑);
|
|
332
|
+
// `cancelled` 同理 —— 排队被取消的任务**一次都没跑过**,同键再提交若直接认回
|
|
333
|
+
// 那条 cancelled 记录,这次提交就被静默吞掉(且与 #executeInner 只采纳
|
|
334
|
+
// `succeeded` 的异步路径不一致:同一件事两种 store 两种结局)。
|
|
335
|
+
return { ...existing };
|
|
161
336
|
}
|
|
162
337
|
}
|
|
338
|
+
// 排队段闸(2026-09-28 外部深评 T5):放在**去重之后** —— 幂等键命中既有记录时
|
|
339
|
+
// 本次提交既不产生新活也不排队,不该吃 503(否则「重试同一个键」会变成看运气)。
|
|
340
|
+
// 读 `#waiting` 是**同步准确**的:别的 submit 在它自己的 `#execute` 同步前段就入过集合
|
|
341
|
+
// (`#dispatch` 是 submit 的最后一行,`void this.#execute(rec)` 的 body 到第一个 await
|
|
342
|
+
// 之前全同步)⇒ 「一个循环里同步灌 N 条」不可能绕过这道闸,不靠人记得 await。
|
|
343
|
+
// 判据是**真正在排队的深度**(`#queueDepth`),不是「已受理未持槽」的集合大小:
|
|
344
|
+
// 后者把「池子还空着、马上就能跑」的那些也算成排队的,会把第 2 条合法提交误拒。
|
|
345
|
+
// `(1)` = 把正在提交的这一条算进去 —— 闸要拦的是「这条交了以后会溢出」。
|
|
346
|
+
const queueDepth = this.#queueDepth(1);
|
|
347
|
+
if (this.maxQueued > 0 && queueDepth > this.maxQueued) {
|
|
348
|
+
throw new TaskQueueFullError(`排队已满:这条提交会让 ${queueDepth} 条任务排在槽位后` +
|
|
349
|
+
`(上限 maxQueued=${this.maxQueued},concurrency=${this.concurrency})` +
|
|
350
|
+
'—— 请退避重试,或调大 concurrency / maxQueued');
|
|
351
|
+
}
|
|
163
352
|
const rec = {
|
|
164
353
|
taskId: nextTaskId(),
|
|
165
354
|
status: 'queued',
|
|
@@ -182,17 +371,25 @@ export class AsyncRunner {
|
|
|
182
371
|
void this.#safeSave(rec, 'initial');
|
|
183
372
|
});
|
|
184
373
|
}
|
|
185
|
-
|
|
374
|
+
// 派发走唯一入口(闸在 #dispatch 里)—— ⚠️ 这里是**结构一致性**,不是行为依赖:
|
|
375
|
+
// 本方法开头那道 `isDraining` 检查(拒绝语义:对调用方的承诺是抛错 / 503,不是静默排期)
|
|
376
|
+
// 与这一行之间**没有 await** —— `submit` 是**全同步**的(异步 store 的 `save` 也只是挂个
|
|
377
|
+
// `.catch`,不 await)⇒ JS 不可能在中间插入 drain 的置位 ⇒ `#dispatch` 那道闸在**本方法内
|
|
378
|
+
// 永远不触发**。(2026-09-28 PR #164 复核 §3:此前的注释与提交信息把它说成「挡进闸与派发
|
|
379
|
+
// 之间刚开始停机的窄窗口」,那是个不存在的窗口。)
|
|
380
|
+
// 仍然走它:**「派发一律走唯一入口」这条纪律不该有例外** —— 少一个例外,就少一处将来
|
|
381
|
+
// 新增恢复路径时会漏的枚举。
|
|
382
|
+
this.#dispatch(rec);
|
|
186
383
|
// 返回浅拷贝:记录会被后台状态机原地推进,调用方拿到的是提交时刻的快照
|
|
187
384
|
return { ...rec };
|
|
188
385
|
}
|
|
189
386
|
/** 查任务当前记录(异步 store 下返回 Promise,调用方 await) */
|
|
190
387
|
poll(taskId) {
|
|
191
388
|
const rec = this.store.get(taskId);
|
|
192
|
-
//
|
|
389
|
+
// 惰性挂起闸(HITL 超时 + 到期唤醒):读到挂起记录时顺手判定,到点就恢复并重派
|
|
193
390
|
if (isThenable(rec))
|
|
194
|
-
return rec.then((r) => this.#
|
|
195
|
-
return this.#
|
|
391
|
+
return rec.then((r) => this.#lazyGates(r));
|
|
392
|
+
return this.#lazyGates(rec);
|
|
196
393
|
}
|
|
197
394
|
/**
|
|
198
395
|
* 订阅某任务的**记账事件流**(`GET /tasks/:id/stream` 的引擎侧)。
|
|
@@ -201,7 +398,7 @@ export class AsyncRunner {
|
|
|
201
398
|
* 1. **先重放、后实时**:`opts.from`(= SSE 的 `Last-Event-ID` / `?from=`)之后的事件先补发,
|
|
202
399
|
* 再挂实时订阅 —— 「连上时已经跑了一半」的客户端因此也能拿到完整前缀。
|
|
203
400
|
* 2. **终态收口**:任务已终态 ⇒ 补发完缓冲 + 一帧 `end`,**不留**一条永远不会再产出事件的流。
|
|
204
|
-
* 挂起(`
|
|
401
|
+
* 挂起(`suspended`)**不是**终态:批了(或睡到点了)会接着跑,流必须开着(见 isTerminalTask)。
|
|
205
402
|
* 3. **跨进程不假装**:本进程没有这条流时(别的进程在跑 / 任务早于本进程),发一帧
|
|
206
403
|
* `unavailable` 后**立即收口**:终态补 `end`,非终态补 `closed`(流级收尾,不是
|
|
207
404
|
* 伪造终态)—— 非终态只发 `unavailable` 就返回的话,这条 SSE 只剩心跳永远挂着。
|
|
@@ -260,78 +457,168 @@ export class AsyncRunner {
|
|
|
260
457
|
return this.store.list();
|
|
261
458
|
}
|
|
262
459
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
460
|
+
* 取消一个任务(durable 配套 5 的另一半;设计稿 `docs/plans/2026-09-28-cancel-api.md`)。
|
|
461
|
+
*
|
|
462
|
+
* 三种「在服」状态各自的语义 —— 都落在这一处,不分散:
|
|
463
|
+
* - **running**:中止在飞 signal ⇒ 引擎以 `stopReason: 'aborted'` 收尾(尊重 signal 的
|
|
464
|
+
* client 是**真中断**,token 不再烧;不尊重者等价「放弃等待」—— 与 `runTimeoutMs`
|
|
465
|
+
* 同一份契约)。等它真落库再返回:不等的话调用方拿到的是 `status` 仍为 running 的快照,
|
|
466
|
+
* 「取消了但状态还在跑」比不返回更误导。
|
|
467
|
+
* - **queued**(还没起跑):落终态;`#executeInner` 拿到槽位后会**重读**再判,
|
|
468
|
+
* 所以被取消的排队任务**绝不起跑**(这条是本次实现顺带补上的洞)。记账由本方法
|
|
469
|
+
* 自己做;那一趟还活着的 `#execute`(在等槽位)靠 `#cancelSettled` 在 finally 里
|
|
470
|
+
* 跳过它那一份(否则 onFinished 两次)。
|
|
471
|
+
* - **suspended**(两种原因都算):落终态 —— 两条唤醒闸都 gate 在 `status === 'suspended'`
|
|
472
|
+
* 上,「不唤醒」因此是翻转状态的**推论**,不需要第二处标志。它没有活着的 `#execute`
|
|
473
|
+
* (挂起时那趟就走完了),所以记账(sinks / 事件流 / 认领释放 / 等待者 / 会话清理)
|
|
474
|
+
* 由本方法自己做 —— 也因此它**不进** `#cancelSettled`(没有消费者,加了就是泄漏)。
|
|
475
|
+
*
|
|
476
|
+
* 状态按**意图**落,不按机制落:超时的收尾语义**不变**(仍是 `failed` + `error.type` 为
|
|
477
|
+
* `'timeout'`),取消带 `error = abortedError()`(取消不是失败,但原因要可查)。
|
|
478
|
+
*
|
|
479
|
+
* 不做静默 no-op:已终态 ⇒ `TaskCancelError(409)`(状态不对要说出来),不存在 ⇒ 404。
|
|
480
|
+
* 边界(如实写在这里):`running` 的 run **不在本进程**(多进程部署 / 上一世留下的 running)
|
|
481
|
+
* 时抛 409 —— 本进程没有句柄可中断,改状态假装取消只会在那条 run 跑完时被覆盖回去。
|
|
482
|
+
*
|
|
483
|
+
* ⚠️ 同一个 409 判据下还有**第二种**情形(2026-09-28 外部复核):任务已在本进程受理、
|
|
484
|
+
* 但还没走到 `#runAborts` 登记(在等并发槽位,或恢复路径刚写完 `running` 尚未派发完)。
|
|
485
|
+
* 它**不是**「不在本进程」—— 原文案一律那么说,对这一类是说反话。两种情形分开措辞
|
|
486
|
+
* (见 `#cancel` 的判据),但**闸不放开**:放开会让等槽位那趟 `#execute` 的 finally
|
|
487
|
+
* 双发 `onFinished`(spec §10 的变异 M23)。
|
|
279
488
|
*/
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
return
|
|
284
|
-
|
|
285
|
-
this.inflightApprovals.delete(taskId);
|
|
286
|
-
});
|
|
287
|
-
this.inflightApprovals.set(taskId, run);
|
|
288
|
-
return run;
|
|
489
|
+
cancel(taskId) {
|
|
490
|
+
const found = this.store.get(taskId);
|
|
491
|
+
if (isThenable(found))
|
|
492
|
+
return found.then((rec) => this.#cancel(rec, taskId));
|
|
493
|
+
return this.#cancel(found, taskId);
|
|
289
494
|
}
|
|
290
|
-
|
|
291
|
-
* approve 与 #expireAndResume 都会「填决定 + 落库 + 派发」,不互斥就是同一任务跑两遍。 */
|
|
292
|
-
inflightApprovals = new Map();
|
|
293
|
-
async #approveInner(taskId, decisions, opts) {
|
|
294
|
-
const rec = await this.store.get(taskId);
|
|
495
|
+
#cancel(rec, taskId) {
|
|
295
496
|
if (!rec)
|
|
296
|
-
throw new
|
|
297
|
-
if (rec
|
|
298
|
-
throw new
|
|
497
|
+
throw new TaskCancelError(404, `task 不存在: ${taskId}`);
|
|
498
|
+
if (isTerminalTask(rec)) {
|
|
499
|
+
throw new TaskCancelError(409, `task ${taskId} 已终态(${rec.status}),不能取消`);
|
|
299
500
|
}
|
|
300
|
-
const
|
|
301
|
-
rec.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
501
|
+
const inflight = this.#runAborts.get(taskId);
|
|
502
|
+
if (rec.status === 'running' && !inflight) {
|
|
503
|
+
// 「假装取消」是这里最坏的选项:那条 run 还活着,跑完会把状态覆盖回去
|
|
504
|
+
// (调用方以为成功了,实际什么都没发生)。要说出来。
|
|
505
|
+
// ⚠️ 但这两类情形**必须分开说**(2026-09-28 外部复核):`#executions` 是
|
|
506
|
+
// 「本进程已受理并正在推进」的集合,与 `#runAborts`(已登记在飞句柄)的差集
|
|
507
|
+
// 就是「**已受理、未起跑**」。四条恢复路径先置 `running` 再 `#dispatch`、
|
|
508
|
+
// 而句柄要到拿到槽位才登记 ⇒ 中间整个窗口都会落进这个差集 —— 对它说
|
|
509
|
+
// 「不在本进程」是反话。调用方据此区分「稍后重试」与「得去找那个进程」。
|
|
510
|
+
const acceptedHere = this.#executions.has(taskId);
|
|
511
|
+
throw new TaskCancelError(409, acceptedHere
|
|
512
|
+
? `task ${taskId} 已在本进程受理,但尚未起跑(正在等并发槽位 / 走恢复前置)—— ` +
|
|
513
|
+
`此刻取消无法与那条在等槽位的执行路径安全收口(会重复记账),暂不支持;` +
|
|
514
|
+
`请待它起跑后再取消,或先降低并发压力`
|
|
515
|
+
: `task ${taskId} 正在运行但不在本进程,本进程无法中断它(多进程部署见 spec §10 2026-09-28 ④)`);
|
|
314
516
|
}
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
rec.status = 'running'; // 由 #executeInner 接管(acquireSlot → 恢复执行)
|
|
321
|
-
rec.ownerId = this.ownerId;
|
|
517
|
+
// 意图先记下:在飞那条路要靠它把 aborted 的收尾落成 cancelled(否则落成 failed)
|
|
518
|
+
this.#cancels.add(taskId);
|
|
519
|
+
if (inflight) {
|
|
520
|
+
inflight.abort();
|
|
521
|
+
return this.#awaitCancelled(taskId);
|
|
322
522
|
}
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
523
|
+
// queued / suspended:没有在飞 run(或它还没起跑),直接落终态 —— 记账由本方法做完,
|
|
524
|
+
// 等槽位那趟 #execute 的 finally 靠 `#cancelSettled` 跳过它那一份(否则 onFinished 两次)。
|
|
525
|
+
// ⚠️ `#cancelSettled` 只为 **queued** 而存(它有活着的、在等槽位的 #execute 来消费);
|
|
526
|
+
// suspended 的那趟 #execute 早在挂起时就走完了,条目加进去永远没人摘(泄漏)。
|
|
527
|
+
// 既知边界(多进程):他进程取消本进程**排队中**的任务时,本进程的 execute finally
|
|
528
|
+
// 在自己的 `#cancelSettled` 里查不到 ⇒ onFinished 会双发(cancel 进程一次 + owner 一次)。
|
|
529
|
+
const wasQueued = rec.status === 'queued';
|
|
530
|
+
rec.status = 'cancelled';
|
|
531
|
+
rec.error = abortedError();
|
|
532
|
+
rec.finishedAt = Date.now();
|
|
533
|
+
// 挂起痕迹清掉(`pendingApprovals` 与 `approvals` 的区别与既有终态口径一致:
|
|
534
|
+
// 未决清单清掉、**已做出的决定**保留 —— 那是审计的一部分,随任务走)
|
|
535
|
+
rec.pendingApprovals = undefined;
|
|
536
|
+
rec.suspendedSince = undefined;
|
|
537
|
+
rec.suspendedReason = undefined;
|
|
538
|
+
rec.wakeAt = undefined;
|
|
539
|
+
this.#approvals.unmarkSuspended(taskId);
|
|
540
|
+
const saved = this.#safeSave(rec, 'outcome');
|
|
541
|
+
this.#cancels.delete(taskId);
|
|
542
|
+
if (wasQueued)
|
|
543
|
+
this.#cancelSettled.add(taskId);
|
|
544
|
+
const finish = () => {
|
|
545
|
+
this.#taskWaiters.notify(taskId);
|
|
546
|
+
this.#streams.markDone(taskId);
|
|
547
|
+
this.sessionInputs.delete(taskId);
|
|
548
|
+
const key = rec.idempotencyKey;
|
|
549
|
+
if (key !== undefined && this.#claims.get(key)?.taskId === taskId)
|
|
550
|
+
this.#claims.delete(key);
|
|
551
|
+
this.#notifyDrained();
|
|
552
|
+
return { ...rec };
|
|
553
|
+
};
|
|
554
|
+
return isThenable(saved)
|
|
555
|
+
? Promise.resolve(saved)
|
|
556
|
+
.then(() => this.#notifySinks(rec))
|
|
557
|
+
.then(finish)
|
|
558
|
+
: this.#notifySinks(rec).then(finish);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* 等一条**已请求取消**的在飞任务真收尾(宽限见 `CANCEL_GRACE_MS`)。
|
|
562
|
+
*
|
|
563
|
+
* 终态判据用 `isTerminalTask`(单一真源)—— 手写两值会让 `cancelled` 在这里被漏掉,
|
|
564
|
+
* 等待就变成「等到超时」,症状是「取消没生效」。
|
|
565
|
+
*
|
|
566
|
+
* ⚠️ 宽限内没收尾 ⇒ **撤回意图 + 抛 409**,不假装:敢说「取消了」而那条 run 还活着的话,
|
|
567
|
+
* 它跑完会把状态覆盖回去(调用方以为成功、实际什么都没发生)。不认 `signal` 的宿主正是
|
|
568
|
+
* 这一类;`runTimeoutMs` 那条老路对同样情形是「放弃等待」(记录落 failed、槽位释放、
|
|
569
|
+
* 底层执行照样跑完)—— 两者**刻意不同**:超时是宿主自己定的预算,取消是人手按的动作,
|
|
570
|
+
* 骗人的代价不一样。
|
|
571
|
+
*/
|
|
572
|
+
async #awaitCancelled(taskId) {
|
|
573
|
+
const deadline = Date.now() + CANCEL_GRACE_MS;
|
|
574
|
+
for (;;) {
|
|
575
|
+
const rec = await this.store.get(taskId);
|
|
576
|
+
if (rec && isTerminalTask(rec))
|
|
577
|
+
return rec;
|
|
578
|
+
const left = deadline - Date.now();
|
|
579
|
+
if (left <= 0) {
|
|
580
|
+
// 撤回意图(不删 `#runAborts`:那条 run 还在本进程里活着,句柄留给它自己的 finally 摘)
|
|
581
|
+
this.#cancels.delete(taskId);
|
|
582
|
+
throw new TaskCancelError(409, `task ${taskId} 的中断没有在 ${CANCEL_GRACE_MS}ms 内生效(宿主不认 signal?)—— 取消未发生`);
|
|
583
|
+
}
|
|
584
|
+
await this.#taskWaiters.wait(taskId, Math.min(250, left));
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* 审批一个处于 `suspended` 的任务(HITL)。
|
|
589
|
+
*
|
|
590
|
+
* 实现已外移到 `approval-supervisor.ts`(**逐字搬迁**,含四条语义与它们的理由:
|
|
591
|
+
* 逐 tool_use_id 幂等 / 并发重入共享在飞那次 / 决定齐了先落库再派发 / 状态不对抛
|
|
592
|
+
* `TaskApproveError`)。这里只留薄壳 —— `approve` 是 `AsyncRunner` 的公开 API,
|
|
593
|
+
* 签名与返回类型不变。
|
|
594
|
+
*/
|
|
595
|
+
async approve(taskId, decisions, opts = {}) {
|
|
596
|
+
return this.#approvals.approve(taskId, decisions, opts);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* 投递一个事件给**挂起**的任务(HITL 与 durable timer 两种挂起都适用)。
|
|
600
|
+
*
|
|
601
|
+
* 实现已外移到 `signal-supervisor.ts`(**逐字搬迁**,含完整语义清单与并发闸的理由:
|
|
602
|
+
* 只对 `suspended` 生效 / 按 `eventId` 幂等 / 缓冲满了拒收并说出来 / 投完即续跑且
|
|
603
|
+
* **先落库再派发** / 并发时**串行成链**而不是像 approve 那样共享在飞那次)。
|
|
604
|
+
* 这里只留薄壳 —— `signalTask` 是 `AsyncRunner` 的公开 API,签名与返回类型不变。
|
|
605
|
+
*/
|
|
606
|
+
signalTask(taskId, event) {
|
|
607
|
+
return this.#signals.signalTask(taskId, event);
|
|
330
608
|
}
|
|
331
609
|
/** 已受理但未达终态的任务数(queued + running)—— 健康检查与 drain 共用同一口径 */
|
|
332
610
|
get inFlight() {
|
|
333
611
|
return this.active;
|
|
334
612
|
}
|
|
613
|
+
/**
|
|
614
|
+
* **本进程可见的挂起读数**(`/healthz` 的 `suspended` 段,配套 6)。
|
|
615
|
+
*
|
|
616
|
+
* 口径与三条维护纪律(挂起时登记 / 离开时除名 / 每次扫描按 store 重建)已随审批监督簇
|
|
617
|
+
* 迁到 `approval-supervisor.ts` 的 `#suspended`(那张注释就写在那里);这里只留对外 getter。
|
|
618
|
+
*/
|
|
619
|
+
get suspendedSummary() {
|
|
620
|
+
return this.#approvals.summary;
|
|
621
|
+
}
|
|
335
622
|
/** 是否已进入优雅停机(drain 之后为 true)—— HTTP 宿主据此对新单回 503 */
|
|
336
623
|
get isDraining() {
|
|
337
624
|
return this.#drain.isDraining;
|
|
@@ -343,84 +630,112 @@ export class AsyncRunner {
|
|
|
343
630
|
* `resumePending` 续跑(所以 drain 不是「丢弃」,是「不再往前推」)。
|
|
344
631
|
* - `timeoutMs` 缺省 0 = 一直等。
|
|
345
632
|
* - 等待的是**所有已受理**的任务(queued 的也在内),不只是正在占槽位的那些。
|
|
633
|
+
*
|
|
634
|
+
* ⚠️ **这是一道单向闩:置位之后本进程不再推进任何任务,且没有复位路径**(`DrainGate` 只有
|
|
635
|
+
* 置位,没有复位)。所以 `false`(排空超时)的含义不只是「还有在飞的」——它同时给宿主
|
|
636
|
+
* 加了一条义务:**必须让进程退出**。理由(2026-09-28 PR #164 复核 §1,有探针实证):
|
|
637
|
+
* 停机窗口里被 `#dispatch` 拒掉的记录**已经落库**(`running`/`queued` + **本进程的 ownerId**),
|
|
638
|
+
* 而本进程的 `resumePending` 会按 `own-process` 跳过它们(见 resume-policy.ts),`submit` 又
|
|
639
|
+
* 已整体关闭 ⇒ 本进程内**没有任何自愈路径**。宿主若在 `false` 之后继续服务,这些记录会永远
|
|
640
|
+
* 停在 `running`:`awaitTask` 不返回,`GET /tasks/:id/stream` 还会告诉客户端
|
|
641
|
+
* `not-in-this-process`(说不对话:记录里的 ownerId 正是本进程)。只有**下一次进程启动**
|
|
642
|
+
* 才会有人认领它们(ownerId 含 pid)。参考宿主(`examples/` 各示例的 `src/main.ts`)都是
|
|
643
|
+
* `await drain(...)` 之后 `process.exit(...)`,与本条契约一致。
|
|
346
644
|
*/
|
|
347
645
|
async drain(opts = {}) {
|
|
348
646
|
// 实现已外移到 drain-gate.ts(写前保留的契约注释仍在本方法上)
|
|
349
647
|
return this.#drain.waitForIdle(() => this.active === 0, opts.timeoutMs ?? 0);
|
|
350
648
|
}
|
|
351
649
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
650
|
+
* poll 的读路径钩子:两条**惰性**的挂起闸各判一次(都不起定时器 —— 没人读的挂起不会自己动,
|
|
651
|
+
* 与审批超时同一条纪律;崩溃/重启也不依赖任何在飞回调)。
|
|
652
|
+
*
|
|
653
|
+
* 审批那一半已外移到 `#approvals.expireIfExpired`(判据与副作用逐字等价,见
|
|
654
|
+
* approval-supervisor.ts);这里保留「命中审批就返回、不再判 timer」的形状 ——
|
|
655
|
+
* 两条闸的原因判据本来就互斥(`approval` / `timer`,`approvalExpired` 内部也卡原因),
|
|
656
|
+
* 排他在这里是**写出来的**而不是「两个函数各自恰好正确」—— C 之前正是「一个状态
|
|
657
|
+
* 兼职两件事」让审批那条闸去叫醒了等时刻的 run。
|
|
354
658
|
*/
|
|
659
|
+
#lazyGates(rec) {
|
|
660
|
+
if (!rec)
|
|
661
|
+
return rec;
|
|
662
|
+
const now = Date.now();
|
|
663
|
+
if (this.#approvals.expireIfExpired(rec, now))
|
|
664
|
+
return rec;
|
|
665
|
+
if (timerDue(rec, now))
|
|
666
|
+
this.#scanner.wakeDue(rec, now);
|
|
667
|
+
return rec;
|
|
668
|
+
}
|
|
355
669
|
/**
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
* 重入闸(2026-09-20,见 spec §10 当日条):与 `approve` **共用同一把** per-taskId
|
|
361
|
-
* 闸(inflightApprovals)。异步 store 的 `get` 返回**新副本**且有网络往返 —— 两个
|
|
362
|
-
* 并发 poll(或 poll 与 approve)各自看到 awaiting 快照 ⇒ 双双填超时拒绝 + 双双
|
|
363
|
-
* `#execute`(同一任务跑两遍)。在飞即跳过:另一路径自己会兜底(#approveInner
|
|
364
|
-
* 里也有 approvalExpired 判定,见 approval-policy.ts),决定逐 id 幂等(第一次决定赢)。
|
|
365
|
-
* 反之 approve 撞上在飞的超时恢复时共享其结果 —— 与人的决定竞速,先到先得。
|
|
670
|
+
* 取消的**意图**(2026-09-28 ④)。落库状态按意图判、不从 signal 反推 ——
|
|
671
|
+
* 同一条 signal 有三个来源(调用方传入 / `runTimeoutMs` / 取消),反推不出「谁按的」。
|
|
672
|
+
* 只在一次 run 的收尾判定里活着:登记在认领处,摘除在同一次 run 的 finally。
|
|
366
673
|
*/
|
|
367
|
-
#
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
674
|
+
#cancels = new Set();
|
|
675
|
+
/** 在飞 run 的中止句柄(**每任务一个**,同时服务超时与取消 —— 机制相同、意图不同)。 */
|
|
676
|
+
#runAborts = new Map();
|
|
677
|
+
/**
|
|
678
|
+
* 已由 `cancel` **自己**收尾过的任务。那趟等槽位的 `#execute` 还活着,它的 finally 会再记一遍账
|
|
679
|
+
* (onFinished 触发两次 / 流被关两次)—— 用这个集合把「谁负责记账」说定:cancel 收尾了,
|
|
680
|
+
* finally 就只做它自己那份(在飞递减、通知等待者、排空通知)。
|
|
681
|
+
*/
|
|
682
|
+
#cancelSettled = new Set();
|
|
683
|
+
/**
|
|
684
|
+
* **唯一的派发口**(2026-09-28 外部深评 P1-1 / P2-1 的结构性修法)。
|
|
685
|
+
*
|
|
686
|
+
* 为什么要有它:所有「先把状态落库成 `running`/`queued`、再派发 `#execute`」的路径
|
|
687
|
+
* (`submit` / `approve` / `signalTask` / 到期唤醒 / 审批超时兜底 / `resumePending` 的认领)
|
|
688
|
+
* **形状相同**,而停机闸原先散在各支里 —— 于是「这道闸覆盖几条路径」变成一份**靠人记**的清单:
|
|
689
|
+
* 第二批修了三支、第四支(审批超时兜底,走 `poll()`)与第五支(认领循环)漏了,两处都在
|
|
690
|
+
* `drain()` 返回 `true`(「排空干净」)之后**又起了新 run**(实证:`drain-race.test.ts`)。
|
|
691
|
+
*
|
|
692
|
+
* 修法:闸**只在这里判一次**,各支只管「先落库」的顺序(那条纪律不变)。
|
|
693
|
+
* 源码级穷尽守卫在 `tests/transport/dispatch-guard.test.ts`:`this.#execute(` 与
|
|
694
|
+
* `this.#executeInner(` 都只许出现在自己的那一个家里 —— 第五次新增恢复路径时构建就红,
|
|
695
|
+
* 不靠记性。(钉两个而不是一个:`#executeInner` 是同一个旁路的另一半,绕过它还会连带
|
|
696
|
+
* 绕过 `active++` 与 `#streams.open`,见那份用例的注释。)
|
|
697
|
+
*
|
|
698
|
+
* 语义:停机窗口里**不派发**。记录已落库(`running`/`queued` + 本进程 ownerId),
|
|
699
|
+
* 留给下次启动的 `resumePending` 认领(ownerId 含 pid ⇒ 新进程必认领)—— at-least-once 兜底,
|
|
700
|
+
* 与「先落库再派发」的崩窗同形;决定 / 事件都在记录里,不丢。
|
|
701
|
+
* ⚠️ 「留给下次启动」成立的前提是**宿主真的退出**(见 `drain()` 的契约:单向闩 + 本进程内
|
|
702
|
+
* 无自愈路径)。所以这里的拒绝**不静默**:首次拒绝打一条 `console.warn` 说清「谁、什么状态、
|
|
703
|
+
* 谁来认领、宿主该做什么」—— 否则运维只看到一个停在 `running` 的任务,无从解释。
|
|
704
|
+
* ⚠️ 对调用方**不抛错**(`submit` 的「停机中不接单 ⇒ 503」在那之前的早返回里,不在这一层)。
|
|
705
|
+
*/
|
|
706
|
+
#dispatch(rec) {
|
|
707
|
+
if (this.#drain.isDraining) {
|
|
708
|
+
// **不静默**(2026-09-28 PR #164 复核 §1):这里的拒绝等于「本进程此后不再推进任何任务」,
|
|
709
|
+
// 而它此前是一条裸 `return` —— 运维只会看到一个停在 `running` 的任务,无从解释它为什么不跑
|
|
710
|
+
// (`drain()` 的契约见上:宿主此刻必须退出,记录留给下次启动认领)。
|
|
711
|
+
// 只报一次:停机窗口里每条被拒记录各报一条会把日志刷满,而它们的原因完全相同。
|
|
712
|
+
if (!this.#dispatchRefusedWarned) {
|
|
713
|
+
this.#dispatchRefusedWarned = true;
|
|
714
|
+
console.warn(`[agentia] 停机中:已拒绝派发 task ${rec.taskId}(记录已落库成 ${rec.status})—— ` +
|
|
715
|
+
'本进程此后不再推进任何任务,它留给下次启动的 resumePending 认领;' +
|
|
716
|
+
'宿主必须在 drain() 之后退出,否则这条记录会一直停在 running(见 usage-guide §7)');
|
|
404
717
|
}
|
|
718
|
+
return;
|
|
405
719
|
}
|
|
406
|
-
void this.#execute(
|
|
407
|
-
return target;
|
|
720
|
+
void this.#execute(rec);
|
|
408
721
|
}
|
|
409
|
-
/**
|
|
410
|
-
#
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
722
|
+
/** 「停机窗口里拒绝派发」的告警是否已出过(每次停机只报一条,见 `#dispatch`) */
|
|
723
|
+
#dispatchRefusedWarned = false;
|
|
724
|
+
/**
|
|
725
|
+
* 当前**真正在排队**的任务数(`maxQueued` 的实时读数,诊断用)—— 与闸用的是**同一个**
|
|
726
|
+
* `#queueDepth`,所以「看到的数」就是「判据的数」,不会各说各话。
|
|
727
|
+
* 与 `inFlight` 的分工:`inFlight` 是「本进程已受理、未达终态」的全量(含在跑、含挂起),
|
|
728
|
+
* 本读数是其中「排不上、在等槽位」的那一段。
|
|
729
|
+
*/
|
|
730
|
+
get queued() {
|
|
731
|
+
return this.#queueDepth();
|
|
417
732
|
}
|
|
418
733
|
/** 任务终态唤醒与等待:实现见 task-waiters.ts(`notify` / `wait`) */
|
|
419
734
|
/** 排空通知:只在确无在飞任务时唤醒等待者(drain 的唯一出口)—— 判定在 drain-gate.ts */
|
|
420
735
|
#notifyDrained() {
|
|
421
736
|
this.#drain.signalIdle(() => this.active === 0);
|
|
422
737
|
}
|
|
423
|
-
/** 等到任务终态;超时抛错。`
|
|
738
|
+
/** 等到任务终态;超时抛错。`suspended` 不是终态 —— 继续等(人在路上 / 时刻未到)。 */
|
|
424
739
|
async awaitTask(taskId, opts = {}) {
|
|
425
740
|
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
426
741
|
// intervalMs 现在只是**兜底轮询**间隔,不是主路径:本进程把任务写到终态会主动唤醒
|
|
@@ -434,7 +749,9 @@ export class AsyncRunner {
|
|
|
434
749
|
const rec = await this.poll(taskId);
|
|
435
750
|
if (!rec)
|
|
436
751
|
throw new Error(`task 不存在: ${taskId}`);
|
|
437
|
-
|
|
752
|
+
// ⚠️ 终态集合走 `isTerminalTask` 这个**单一真源**:手写枚举会让新加的终态(`cancelled`)
|
|
753
|
+
// 在这里被漏掉 —— 表现为「取消了但 awaitTask 一直等到超时」,看起来像取消没生效
|
|
754
|
+
if (isTerminalTask(rec))
|
|
438
755
|
return rec;
|
|
439
756
|
const left = deadline - Date.now();
|
|
440
757
|
if (left <= 0)
|
|
@@ -447,100 +764,16 @@ export class AsyncRunner {
|
|
|
447
764
|
* (running 视为进程中断)。返回重派数量(异步 store 下返回 Promise<number>)。
|
|
448
765
|
* 幂等键去重照常生效。
|
|
449
766
|
*
|
|
450
|
-
* `
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
454
|
-
* **认领先落库、再派发**(见 `#redispatch`);异步 store 的认领落库失败会让本方法
|
|
455
|
-
* reject —— 宁可让调用方看见「续跑没做」,也不要静默放出一批会被重复执行的任务。
|
|
767
|
+
* 实现在 `resume-scanner.ts`(**逐字搬迁**,含那四步扫描的**顺序**与各自理由、重入闸、
|
|
768
|
+
* 到期索引接线、多进程 `ownerId` 的判定分级)。这里只留薄壳 —— `resumePending` 是
|
|
769
|
+
* `AsyncRunner` 的公开 API,签名与返回类型不变。
|
|
456
770
|
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* (大概正被那个进程执行)。缺省 0 = 不判断、一律重派(单进程旧语义)。
|
|
771
|
+
* 一句话提要(细节见实现):`suspended` **不捡**(它在等人、不是孤儿),但读到它会做
|
|
772
|
+
* 惰性判定 —— 审批超时自动全拒并重派、`wakeAt` 已到的唤醒并重派(`drain()` 之后不唤,
|
|
773
|
+
* 配套 5);认领**先落库再派发**;多进程靠 `ownerId` 区分他我(`staleAfterMs > 0` 时问 pid)。
|
|
461
774
|
*/
|
|
462
775
|
resumePending(opts = {}) {
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
// 为什么必须(2026-09-18 补):「先落库再派发」只堵住了**串行**重扫 —— 认领是异步的
|
|
466
|
-
// (异步 store 的 `list()` 返回反序列化的**新对象**),两个并发调用都在任一 `save`
|
|
467
|
-
// 落地前 `list()` 到旧快照,`ownerId === this.ownerId` 的过滤对两份旧快照**双双失效**
|
|
468
|
-
// ⇒ 同一个任务被派发两次(`app.run` 重复执行,副作用与花费翻倍)。
|
|
469
|
-
//
|
|
470
|
-
// 语义:闸门期间返回**在飞那次的 Promise**(同一个数),而不是 0 —— 0 会谎称
|
|
471
|
-
// 「没派发任何东西」,而实际上派发了。调用方拿到的始终是本次扫描的真实结果。
|
|
472
|
-
if (this.inflightResume)
|
|
473
|
-
return this.inflightResume;
|
|
474
|
-
const listed = this.store.list();
|
|
475
|
-
const staleAfterMs = opts.staleAfterMs ?? 0;
|
|
476
|
-
if (isThenable(listed)) {
|
|
477
|
-
const run = Promise.resolve(listed)
|
|
478
|
-
.then((recs) => this.#redispatch(recs, staleAfterMs))
|
|
479
|
-
.finally(() => {
|
|
480
|
-
this.inflightResume = null;
|
|
481
|
-
});
|
|
482
|
-
this.inflightResume = run;
|
|
483
|
-
return run;
|
|
484
|
-
}
|
|
485
|
-
const dispatched = this.#redispatch(listed, staleAfterMs);
|
|
486
|
-
if (isThenable(dispatched)) {
|
|
487
|
-
const run = Promise.resolve(dispatched).finally(() => {
|
|
488
|
-
this.inflightResume = null;
|
|
489
|
-
});
|
|
490
|
-
this.inflightResume = run;
|
|
491
|
-
return run;
|
|
492
|
-
}
|
|
493
|
-
return dispatched;
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
|
-
* 在飞的 `resumePending`(重入闸,见上)。不用 boolean 而用 Promise:
|
|
497
|
-
* 重入方要拿到**同一次扫描**的结果,而不是一个「你等着」的空数。
|
|
498
|
-
*/
|
|
499
|
-
inflightResume = null;
|
|
500
|
-
/**
|
|
501
|
-
* 重新派发前**必须**先把 `ownerId` 认领落库 —— 否则「认领」只是内存里的一个记号。
|
|
502
|
-
*
|
|
503
|
-
* 病灶:此前这里只改内存里的 `status`/`ownerId` 就 `void #execute(rec)`,真正的 save
|
|
504
|
-
* 要等到 `#executeInner`(还在 `#acquireSlot` 之后)。对 sqlite/redis 这类 `list()`
|
|
505
|
-
* 返回**反序列化新对象**的 store,这段窗口里再调一次 `resumePending()` 读到的仍是旧
|
|
506
|
-
* ownerId,`:305` 的过滤失效 → 同一个任务被再派发一次 → `app.run` 重复执行,副作用
|
|
507
|
-
* 与花费翻倍。`InMemoryTaskStore` 存的是对象引用,恰好掩盖了这个问题。
|
|
508
|
-
*
|
|
509
|
-
* 返回类型刻意保持 `number | Promise<number>`:同步 store 的 save 是同步的,认领当场
|
|
510
|
-
* 落地,返回数字(`submit`/`list` 那套「同步 store 保持同步门面」的约定不变);只有
|
|
511
|
-
* 真出现 thenable 才升级成 Promise,等所有认领落库后再统一派发。
|
|
512
|
-
*/
|
|
513
|
-
#redispatch(recs, staleAfterMs) {
|
|
514
|
-
const now = Date.now();
|
|
515
|
-
// 惰性审批超时扫描(HITL):awaiting_approval **不捡走续跑**(它在等人,不是
|
|
516
|
-
// 孤儿 —— 崩溃续跑语义不适用于「等审批」),但读到它时顺手判超时:
|
|
517
|
-
// 到点自动全拒并重派(框架补的 deny 决定先进 store,再进引擎)。
|
|
518
|
-
let expired = 0;
|
|
519
|
-
for (const rec of recs) {
|
|
520
|
-
if (rec.status !== 'awaiting_approval' || !approvalExpired(rec, now, this.approvalTimeoutMs))
|
|
521
|
-
continue;
|
|
522
|
-
expired++;
|
|
523
|
-
this.#expireAndResume(rec, now);
|
|
524
|
-
}
|
|
525
|
-
// 认领判定外移到 resume-policy.ts:跳过原因具名化(terminal / own-process / too-fresh),
|
|
526
|
-
// 每条规则与边界都由那份纯函数的单测钉住
|
|
527
|
-
const pending = recs.filter((r) => resumeSkipReason(r, { ownerId: this.ownerId, staleAfterMs, now }) === undefined);
|
|
528
|
-
const claims = [];
|
|
529
|
-
for (const rec of pending) {
|
|
530
|
-
rec.status = 'queued'; // 重新入队,由 #execute 统一推进
|
|
531
|
-
rec.ownerId = this.ownerId; // 认领:此后本进程的记录不再被(自己)重派
|
|
532
|
-
const saved = this.store.save(rec);
|
|
533
|
-
if (isThenable(saved)) {
|
|
534
|
-
// 落库失败则**不派发**:认领没落地,派发等于把上面那个重复执行的窗口重新打开
|
|
535
|
-
claims.push(Promise.resolve(saved).then(() => void this.#execute(rec)));
|
|
536
|
-
}
|
|
537
|
-
else {
|
|
538
|
-
void this.#execute(rec);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
if (claims.length === 0)
|
|
542
|
-
return pending.length + expired;
|
|
543
|
-
return Promise.all(claims).then(() => pending.length + expired);
|
|
776
|
+
return this.#scanner.resumePending(opts);
|
|
544
777
|
}
|
|
545
778
|
/**
|
|
546
779
|
* 在飞计数包裹层:#executeInner 是状态机主体,这里只负责 active 计数与排空通知。
|
|
@@ -549,47 +782,66 @@ export class AsyncRunner {
|
|
|
549
782
|
*/
|
|
550
783
|
async #execute(rec) {
|
|
551
784
|
// 同步认领(在任何 await 之前):同键并发提交的第二个 submit 立刻能看见它。
|
|
552
|
-
// 挂起(
|
|
785
|
+
// 挂起(suspended)**不释放** —— 那是「等人工」,不是终态;放了会让同键再起一个新任务。
|
|
553
786
|
const key = rec.idempotencyKey;
|
|
554
787
|
if (key !== undefined && !this.#claims.has(key))
|
|
555
788
|
this.#claims.set(key, rec);
|
|
556
789
|
// 同步开流(在任何 await 之前):`GET /tasks/:id/stream` 从这一刻起可以订阅。
|
|
557
790
|
// 复用语义见 task-events.ts —— HITL 恢复 / 崩溃重投是**同一个任务**,序号接着走。
|
|
558
791
|
this.#streams.open(rec.taskId);
|
|
792
|
+
// 「本进程已受理」的起点(同步段,与 #streams.open 同处):cancel 的 409 文案据此
|
|
793
|
+
// 区分「排队未起跑」与「真不在本进程」。终点是整个 #execute 退出的 finally。
|
|
794
|
+
this.#executions.add(rec.taskId);
|
|
559
795
|
this.active++;
|
|
796
|
+
// **在等槽位**的入口(同步段,与 `active++` 同处)—— 只看 id,出集合在「拿到槽位」
|
|
797
|
+
// 那一刻(`#executeInner`)与本节 finally 两处,`add`/`delete` 幂等所以两处都安全。
|
|
798
|
+
// 同步段入集合是这道闸**挡得住同步突发**的前提:`#dispatch` 是 submit 的最后一行,
|
|
799
|
+
// `void this.#execute(rec)` 的 body 到第一个 await 之前全同步 ⇒ 一个循环里连续 submit
|
|
800
|
+
// 时,每次读到的都是已含前几条的**准确**读数。
|
|
801
|
+
this.#waiting.add(rec.taskId);
|
|
560
802
|
try {
|
|
561
803
|
await this.#executeInner(rec);
|
|
562
804
|
}
|
|
563
805
|
finally {
|
|
806
|
+
this.#waiting.delete(rec.taskId);
|
|
564
807
|
// 通知在飞递减**之前**:drain() 返回时保证「任务已终态 + 回调已发完」。
|
|
565
808
|
// 内层 finally 保证回调万一抛错(理论上被吞掉)也不泄漏在飞计数。
|
|
566
809
|
try {
|
|
567
|
-
// HITL:挂起不是终态 —— onFinished
|
|
568
|
-
|
|
810
|
+
// HITL:挂起不是终态 —— onFinished 的承诺是「任务达终态」,对它不开火。
|
|
811
|
+
// 已由 `cancel` 自己收尾过的那一条也不开火(`#cancelSettled`):两处都记会触发两次
|
|
812
|
+
if (rec.status !== 'suspended' && !this.#cancelSettled.has(rec.taskId)) {
|
|
569
813
|
await this.#notifySinks(rec);
|
|
814
|
+
}
|
|
570
815
|
}
|
|
571
816
|
finally {
|
|
572
817
|
this.active--;
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
818
|
+
// 记账的归属:cancel 收尾过的那一份已经做过(流收口 / 会话清理 / 认领释放),
|
|
819
|
+
// 这里只消费掉标记,不重复 —— 在飞递减、等待者、排空通知照旧必达
|
|
820
|
+
if (!this.#cancelSettled.delete(rec.taskId)) {
|
|
821
|
+
// 终态即清理 HITL 会话回写快照(挂起则保留 —— 恢复段成功后还要用它)
|
|
822
|
+
if (rec.status !== 'suspended')
|
|
823
|
+
this.sessionInputs.delete(rec.taskId);
|
|
824
|
+
// 任务流收口(挂起不算终态 —— 见 isTerminalTask 的注释:批了会接着跑,流得开着)。
|
|
825
|
+
if (isTerminalTask(rec))
|
|
826
|
+
this.#streams.markDone(rec.taskId);
|
|
827
|
+
// 释放同键认领:**只有终态才释放**(挂起仍在等人,同键提交不该另起一个任务)。
|
|
828
|
+
// 判据用 taskId 比对而非 `claimed`:HITL 的恢复段是**另一次** #execute 调用
|
|
829
|
+
// (approve / 超时兜底 / 崩溃恢复都会重派,且异步 store 交出的常是新副本对象),
|
|
830
|
+
// 那次 `claimed` 必为 false —— 只看 `claimed` 会让挂起过的键**永不释放**(认领表泄漏,
|
|
831
|
+
// 同键从此永远命中那条老记录)。
|
|
832
|
+
if (key !== undefined &&
|
|
833
|
+
rec.status !== 'suspended' &&
|
|
834
|
+
this.#claims.get(key)?.taskId === rec.taskId) {
|
|
835
|
+
this.#claims.delete(key);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
576
838
|
// 任务已达终态并落库 → 唤醒 awaitTask 的等待者(放在递减之后,语义与 drain 一致)。
|
|
577
|
-
// 挂起也唤醒:等待者看一眼状态继续等(
|
|
839
|
+
// 挂起也唤醒:等待者看一眼状态继续等(suspended 不是终态),无副作用。
|
|
578
840
|
this.#taskWaiters.notify(rec.taskId);
|
|
579
|
-
// 任务流收口(挂起不算终态 —— 见 isTerminalTask 的注释:批了会接着跑,流得开着)。
|
|
580
|
-
if (isTerminalTask(rec))
|
|
581
|
-
this.#streams.markDone(rec.taskId);
|
|
582
|
-
// 释放同键认领:**只有终态才释放**(挂起仍在等人,同键提交不该另起一个任务)。
|
|
583
|
-
// 判据用 taskId 比对而非 `claimed`:HITL 的恢复段是**另一次** #execute 调用
|
|
584
|
-
// (approve / 超时兜底 / 崩溃恢复都会重派,且异步 store 交出的常是新副本对象),
|
|
585
|
-
// 那次 `claimed` 必为 false —— 只看 `claimed` 会让挂起过的键**永不释放**(认领表泄漏,
|
|
586
|
-
// 同键从此永远命中那条老记录)。
|
|
587
|
-
if (key !== undefined &&
|
|
588
|
-
rec.status !== 'awaiting_approval' &&
|
|
589
|
-
this.#claims.get(key)?.taskId === rec.taskId) {
|
|
590
|
-
this.#claims.delete(key);
|
|
591
|
-
}
|
|
592
841
|
this.#notifyDrained();
|
|
842
|
+
// 「本进程已受理」的终点(与 #execute 同寿)。删在最后:此前若 cancel 挤进来,
|
|
843
|
+
// 它看到的仍是「本进程在推进这条」,文案不会突然改口。
|
|
844
|
+
this.#executions.delete(rec.taskId);
|
|
593
845
|
}
|
|
594
846
|
}
|
|
595
847
|
}
|
|
@@ -631,11 +883,53 @@ export class AsyncRunner {
|
|
|
631
883
|
return;
|
|
632
884
|
}
|
|
633
885
|
}
|
|
886
|
+
// 「出排队段」必须与「占槽」落在**同一次同步执行**里:`await acquire()` 的连续体要等
|
|
887
|
+
// 一次微任务,而 `acquire` 内部的 `running++` 是同步的 —— 同一条任务在那一瞬被算两遍
|
|
888
|
+
// ⇒ 排队深度虚高一格(`concurrency: 1` + `maxQueued: 1` 下第 2 条合法提交被误拒)。
|
|
889
|
+
// 所以:**有空位就趁 await 之前出集合**(判据是快照 —— 判断与 acquire 之间没有 await,
|
|
890
|
+
// 池子状态不会变);排队等到的那些,则在 acquire 兑现后出集合(同一个 delete,幂等)。
|
|
891
|
+
// ⚠️ 刻意**不**把 acquire 换成同步快路径:那会顺带少掉一次微任务,把「置 running」
|
|
892
|
+
// 相对引擎推进的时刻提前,踩到既有用例里「等到 running 就当工具已挂在飞」的隐含假设
|
|
893
|
+
// (`tests/transport/cancel.test.ts` 的前置断言当场红)。记账改原子,时序一个字不改。
|
|
894
|
+
if (this.#slots.inUse < this.concurrency)
|
|
895
|
+
this.#waiting.delete(rec.taskId);
|
|
634
896
|
await this.#slots.acquire();
|
|
897
|
+
this.#waiting.delete(rec.taskId); // 拿到槽位 = 离开排队段(此后归 concurrency 管)
|
|
635
898
|
try {
|
|
899
|
+
// 认领前**重读一遍**再判(异步 store 交出的是副本:拿 submit 时那个对象判不出
|
|
900
|
+
// 「排队期间被取消了没有」)——与 `#wakeDueInner` 的「进闸后重读」同因。
|
|
901
|
+
// 不判的后果:cancel 只把状态改了,任务**照跑**(记录说 cancelled、副作用真发生 ——
|
|
902
|
+
// 最坏的一种谎)。
|
|
903
|
+
// ⚠️ 判据**只挡终态**,别写成 `!== 'queued'`:恢复那几条路(approve / 到期唤醒 /
|
|
904
|
+
// 崩溃重投)都**先**把状态置成 `running` 再派发 —— 那样写会把它们全挡死,
|
|
905
|
+
// 症状是「恢复了但任务没跑」(本实现第一版就是这么错的,transport 套件当场挂住)。
|
|
906
|
+
// ⚠️ 读失败**不改变既有行为**:这道闸只负责「明知已终态就别跑」,不负责把
|
|
907
|
+
// store 读取故障升级成「任务起不来」(停机窗口里 store 已 close 是既有场景,
|
|
908
|
+
// 那条路径的行为由 scheduler 的用例钉着)。
|
|
909
|
+
let claimed;
|
|
910
|
+
try {
|
|
911
|
+
claimed = (await this.store.get(rec.taskId)) ?? rec;
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
claimed = undefined;
|
|
915
|
+
}
|
|
916
|
+
if (claimed && isTerminalTask(claimed)) {
|
|
917
|
+
// 与 store 对齐再返回:外层 finally 会拿 `rec` 落一次 outcome,
|
|
918
|
+
// 不同步的话会用**陈旧副本**把取消覆盖回 queued(任务从此既不起跑也不终态)。
|
|
919
|
+
// 记账(sinks / 事件流 / 认领释放 / 等待者)交给**还活着**的那次 #execute 的 finally
|
|
920
|
+
// ——它正是此刻在等槽位的这一条。
|
|
921
|
+
Object.assign(rec, claimed);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
// 在飞句柄**先**登记(在任何 await 与状态写之前):cancel 的中断窗口从这里闭合
|
|
925
|
+
this.#runAborts.set(rec.taskId, new AbortController());
|
|
636
926
|
rec.status = 'running';
|
|
637
927
|
rec.startedAt = Date.now();
|
|
638
928
|
await this.store.save(rec);
|
|
929
|
+
// 取消意图在收尾判定里要用两次:正常出口(`#cancels.has`)与**异常出口**
|
|
930
|
+
// (拒绝式认 signal 的宿主 —— abort 后 reject,包 fetch 类客户端的常见写法)。
|
|
931
|
+
// ⚠️ 必须声明在 try **之外**:`let` 是块级作用域,写在 try 里 catch 看不到它。
|
|
932
|
+
let cancelIntent = false;
|
|
639
933
|
try {
|
|
640
934
|
// 会话注入(C4 的异步通道):sessionId 是可序列化的引用,store 实例由
|
|
641
935
|
// runner 持有 —— 执行前在这里换成 RunAppOptions.session。resumed 记录
|
|
@@ -644,12 +938,19 @@ export class AsyncRunner {
|
|
|
644
938
|
if (rec.spec.options?.sessionId !== undefined && this.sessionStore === undefined) {
|
|
645
939
|
throw new Error(`任务 ${rec.taskId} 带了 options.sessionId,但本 runner 未配 sessionStore`);
|
|
646
940
|
}
|
|
647
|
-
// HITL
|
|
648
|
-
|
|
941
|
+
// **续跑段**的判定(不只是 HITL:时间挂起醒来也是续跑)—— 决定会话注入、
|
|
942
|
+
// 链路关联与会话回写三条口径的走向(见下)。判据**不能**只看 `approvals`:
|
|
943
|
+
// timer 挂起醒来时没有任何决定(approvals 是空的),但 `rec.spec.messages` 同样是
|
|
944
|
+
// 挂起段落库的**扩展历史**(末尾是含未决 tool_use 的 assistant)—— 那三条口径
|
|
945
|
+
// 一字不差地适用(`suspendedSince` 是「这条记录挂起过」的痕迹:挂起时写、终态才清)。
|
|
946
|
+
const isResume = rec.approvals !== undefined || rec.suspendedSince !== undefined;
|
|
649
947
|
// runTimeoutMs 到点即 abort(对尊重 signal 的客户端是真中止);与调用方
|
|
650
948
|
// 可能传入的 signal 合成,任一触发都中止本次 run。
|
|
651
|
-
|
|
652
|
-
|
|
949
|
+
// 每任务**一个** controller,同时服务超时与取消(机制是同一条 signal,差别在意图):
|
|
950
|
+
// 它在认领处就登记进了 #runAborts,所以 cancel 找得到它
|
|
951
|
+
const runAc = this.#runAborts.get(rec.taskId) ?? new AbortController();
|
|
952
|
+
this.#runAborts.set(rec.taskId, runAc);
|
|
953
|
+
const combined = combineSignals(rec.spec.options?.signal, runAc.signal);
|
|
653
954
|
// 记账事件 → 本任务的流(`GET /tasks/:id/stream` 的数据源)。
|
|
654
955
|
// 与任务 spec 里可能自带的那个**叠加**而不是覆盖(composeTraceEvents 的同一份理由):
|
|
655
956
|
// 两边都要收到 —— 覆盖会让其中一条静默失聪。
|
|
@@ -677,10 +978,16 @@ export class AsyncRunner {
|
|
|
677
978
|
: {}),
|
|
678
979
|
// HITL 恢复段:审批决定随任务落库,重跑时原样进引擎(进程重启不丢)
|
|
679
980
|
...(rec.approvals !== undefined ? { approvals: rec.approvals } : {}),
|
|
981
|
+
// 事件投入口(2026-09-28 ⑥):挂起期间投递的事件随记录进引擎,由它在
|
|
982
|
+
// 未决 tool_use 解决之后注入消息流(为什么不能由宿主直接追加进
|
|
983
|
+
// spec.messages 的末尾:见 signalTask 与 engine/loop.ts 的注入点纪律)
|
|
984
|
+
...(rec.pendingEvents !== undefined && rec.pendingEvents.length > 0
|
|
985
|
+
? { events: rec.pendingEvents }
|
|
986
|
+
: {}),
|
|
680
987
|
// 恢复段的 trace 是一棵**新树**,经 traceContext link 挂到上一段 runId
|
|
681
988
|
// (spec §9.2 的入站关联机制)——「挂起段 → 恢复段 → …」在观测后端连成一条链。
|
|
682
|
-
//
|
|
683
|
-
...(
|
|
989
|
+
// 判定依据:本段是续跑段(isResume)且已有上一段 runId;首次执行两者皆无。
|
|
990
|
+
...(isResume && rec.runId !== undefined
|
|
684
991
|
? { traceContext: { traceId: rec.runId } }
|
|
685
992
|
: {}),
|
|
686
993
|
rethrow: false, // 硬失败也以 failed 记录落库
|
|
@@ -689,16 +996,16 @@ export class AsyncRunner {
|
|
|
689
996
|
...(bridgeTraceEvents !== undefined ? { onTraceEvent: bridgeTraceEvents } : {}),
|
|
690
997
|
};
|
|
691
998
|
try {
|
|
692
|
-
const out = await this.#raceTimeout(this.app.run(rec.spec.messages, callOpts), rec.taskId, () =>
|
|
999
|
+
const out = await this.#raceTimeout(this.app.run(rec.spec.messages, callOpts), rec.taskId, () => runAc.abort());
|
|
693
1000
|
rec.runId = out.run.runId;
|
|
694
1001
|
rec.result = out.result;
|
|
695
1002
|
rec.error = out.result.error;
|
|
696
|
-
if (out.result.stopReason === '
|
|
1003
|
+
if (out.result.stopReason === 'suspended' && out.result.suspendedMessages) {
|
|
697
1004
|
// HITL 挂起:扩展后的消息历史(末尾是含未决 tool_use 的 assistant 消息)
|
|
698
1005
|
// 与待决清单、挂起时刻一起落库 —— approve / 惰性超时 / 重启后都靠它们。
|
|
699
1006
|
// 槽位照常释放(finally)、onFinished 不触发(#execute 的出口判断)、
|
|
700
1007
|
// finishedAt 不置(下面 finally 里按状态跳过):它不是终态。
|
|
701
|
-
rec.status = '
|
|
1008
|
+
rec.status = 'suspended';
|
|
702
1009
|
// 「本轮用户输入」快照必须在 overwrite **之前**取 —— 此刻 rec.spec.messages
|
|
703
1010
|
// 还是原始输入;恢复段成功后由 #appendResumedSession 拿它 + finalText 补写会话。
|
|
704
1011
|
// 只在首个挂起段快照(!isResume):恢复段再挂起时 spec.messages 已是
|
|
@@ -710,13 +1017,51 @@ export class AsyncRunner {
|
|
|
710
1017
|
}
|
|
711
1018
|
rec.spec = { ...rec.spec, messages: out.result.suspendedMessages };
|
|
712
1019
|
rec.pendingApprovals = out.result.pendingApprovals;
|
|
713
|
-
rec.
|
|
1020
|
+
rec.suspendedSince = Date.now();
|
|
1021
|
+
// 挂起原因(2026-09-28 ①):从结果形状里取,**不写死** ——
|
|
1022
|
+
// 写死成 'approval' 会让将来新增的挂起原因在落库这一步被悄悄改写成审批。
|
|
1023
|
+
rec.suspendedReason = out.result.suspendedReason;
|
|
1024
|
+
// 目标时刻(时间挂起才有)—— 同上,从结果形状里取,不按原因反推
|
|
1025
|
+
rec.wakeAt = out.result.wakeAt;
|
|
1026
|
+
// 挂起读数(纪律①):与落库**同一个分支**登记,不另起一处判断
|
|
1027
|
+
this.#approvals.markSuspended(rec);
|
|
1028
|
+
// 事件簿记(2026-09-28 ⑥ 复审收口):**注入过**就清 —— 事件已在挂起历史里
|
|
1029
|
+
// (suspendedMessages 含注入的 user 消息),簿记留着会让下次续跑**重复注入**
|
|
1030
|
+
// (真探针实证过:模型看到同一事件两次)。没注入(续跑入口的再挂起出口在
|
|
1031
|
+
// 注入点之前返回)则必须留住 —— 清掉就是丢事件。判据是结果上的
|
|
1032
|
+
// `eventsDelivered` 位,不是「是不是挂起」这个粗粒度。
|
|
1033
|
+
if (out.result.eventsDelivered)
|
|
1034
|
+
rec.pendingEvents = undefined;
|
|
714
1035
|
}
|
|
715
1036
|
else {
|
|
716
|
-
|
|
1037
|
+
// 取消:机制与 `runTimeoutMs` 一字不差(同一条 abort signal),**意图**不同 ——
|
|
1038
|
+
// 状态按意图落(超时 = failed、人取消 = cancelled)。意图从 `#cancels` 取,
|
|
1039
|
+
// 不从 signal 反推:同一条 signal 有三个来源(调用方 / 超时 / 取消)。
|
|
1040
|
+
const cancelled = this.#cancels.has(rec.taskId);
|
|
1041
|
+
rec.status = cancelled ? 'cancelled' : out.run.status;
|
|
1042
|
+
// 取消必须带**结构化原因**(「取消不是失败,但原因要可查」):宿主自定义的
|
|
1043
|
+
// AppCallable 若不认 signal,它照旧返回一个正常结果 ⇒ `result.error` 是空的,
|
|
1044
|
+
// 那时记录里也得说清「这条是被取消的」(与超时那条的账一类)。
|
|
1045
|
+
if (cancelled && rec.error === undefined)
|
|
1046
|
+
rec.error = abortedError();
|
|
717
1047
|
// 终态后清掉挂起痕迹(决定保留:审批记录是审计的一部分,随任务走)
|
|
718
1048
|
rec.pendingApprovals = undefined;
|
|
719
|
-
rec.
|
|
1049
|
+
rec.suspendedSince = undefined;
|
|
1050
|
+
rec.suspendedReason = undefined;
|
|
1051
|
+
rec.wakeAt = undefined;
|
|
1052
|
+
// 事件簿记(2026-09-28 ⑥):**终态没有第二次机会** —— 这条记录不会再被续跑,
|
|
1053
|
+
// 事件留着也没有注入点,清掉。
|
|
1054
|
+
// ⚠️ 判据与挂起分支**刻意不同**(复审第二轮把这里的注释改成真话,原先写的是
|
|
1055
|
+
// 「跑通到非挂起出口 ⇒ 引擎已把它们注入消息流」—— 那句在注入点**之前**就被
|
|
1056
|
+
// 中止/失败的出口上不成立):挂起分支按 `eventsDelivered` 判(注入过才清、
|
|
1057
|
+
// 没注入要留 —— 下次续跑补上),这里无条件清。
|
|
1058
|
+
// 于是「恢复段在注入之前就 abort(取消 / runTimeoutMs)或整段失败」的那些终态,
|
|
1059
|
+
// 其事件**不会**进历史 —— 既成事实,不假装「已注入」;发件方从记录的终态与
|
|
1060
|
+
// error 看得到这条 run 的下场(要重试就重新提交一次:事件不跨终态重放)。
|
|
1061
|
+
rec.pendingEvents = undefined;
|
|
1062
|
+
// 离开挂起态(读数纪律②):两处恢复路径负责「还没跑到终态」的那一半,
|
|
1063
|
+
// 这一支负责「跑到了终态」的那一半(兜底:恢复失败也会落到这里)
|
|
1064
|
+
this.#approvals.unmarkSuspended(rec.taskId);
|
|
720
1065
|
// HITL 恢复段 + 会话:本段没把 session 交给 run 层(见上面 callOpts 注释),
|
|
721
1066
|
// 会话回写由 runner 自己补 —— 口径与 run.ts 的 appendSession 一致。
|
|
722
1067
|
if (isResume && out.run.status === 'succeeded') {
|
|
@@ -728,16 +1073,29 @@ export class AsyncRunner {
|
|
|
728
1073
|
// 正常收尾(没有源中止)时主动摘除挂在各源上的监听器 —— 宿主级共享
|
|
729
1074
|
// signal 是长寿的,不摘会按任务数累积(MaxListenersExceededWarning)
|
|
730
1075
|
releaseCombinedSignal(combined);
|
|
1076
|
+
// 在飞句柄与取消意图一起摘掉:意图只在**这次 run** 的收尾判定里有用(见下面那处)。
|
|
1077
|
+
// ⚠️ 意图的取值要留给 catch(`Set.delete` 返回布尔):拒绝式认 signal 的宿主
|
|
1078
|
+
// 走的就是 catch —— 在这里丢掉意图,取消就会被记成 failed(正是要治的病)。
|
|
1079
|
+
this.#runAborts.delete(rec.taskId);
|
|
1080
|
+
cancelIntent = this.#cancels.delete(rec.taskId);
|
|
731
1081
|
}
|
|
732
1082
|
}
|
|
733
1083
|
catch (e) {
|
|
734
|
-
|
|
735
|
-
|
|
1084
|
+
// 意图先到 ⇒ cancelled(与「结果说成功、取消先到」同一条「意图赢」规则)——
|
|
1085
|
+
// 覆盖「abort 后 reject」的宿主路径;无意图的异常照旧是 failed。
|
|
1086
|
+
if (cancelIntent) {
|
|
1087
|
+
rec.status = 'cancelled';
|
|
1088
|
+
rec.error = abortedError();
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
rec.error = classifyError(e);
|
|
1092
|
+
rec.status = 'failed';
|
|
1093
|
+
}
|
|
736
1094
|
}
|
|
737
1095
|
}
|
|
738
1096
|
finally {
|
|
739
1097
|
// HITL:挂起不是「完成」—— finishedAt 不置(等待中的任务没有结束时刻)
|
|
740
|
-
if (rec.status !== '
|
|
1098
|
+
if (rec.status !== 'suspended')
|
|
741
1099
|
rec.finishedAt = Date.now();
|
|
742
1100
|
// 落库失败不遮罩、槽位必须释放:释放放在内层 finally,即便落库实现抛错也必达
|
|
743
1101
|
try {
|