@migor/agentia 0.9.3 → 0.9.5
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 +779 -1
- package/README.md +1 -1
- package/dist/AGENTS.md +320 -37
- package/dist/container/container.js +15 -0
- package/dist/container/container.js.map +1 -1
- package/dist/core/limits.d.ts +121 -17
- package/dist/core/limits.js +133 -16
- 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 +39 -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/defer.d.ts +49 -0
- package/dist/engine/defer.js +72 -0
- package/dist/engine/defer.js.map +1 -0
- 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 +151 -17
- 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/engine/usage.d.ts +22 -3
- package/dist/engine/usage.js +47 -6
- package/dist/engine/usage.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 +112 -15
- 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 +3 -1
- package/dist/store/redisStore.js +21 -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/async.d.ts +197 -18
- package/dist/transport/async.js +871 -85
- 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 +110 -467
- 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/scheduler.js +23 -24
- package/dist/transport/scheduler.js.map +1 -1
- 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/sse.js +8 -0
- package/dist/transport/sse.js.map +1 -1
- package/dist/transport/task-events.d.ts +34 -3
- package/dist/transport/task-events.js +86 -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 +6 -4
package/dist/transport/async.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
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
13
|
import { approvalExpired, approvalsComplete, fillTimeoutDenials } from './approval-policy.js';
|
|
14
|
+
import { summarizeSuspended, timerDue } from './wake-policy.js';
|
|
11
15
|
import { DrainGate } from './drain-gate.js';
|
|
12
16
|
import { resumeSkipReason } from './resume-policy.js';
|
|
17
|
+
import { formatOwnerId } from './owner-id.js';
|
|
18
|
+
import { ownerAlive } from './owner-liveness.js';
|
|
13
19
|
import { TaskWaiters } from './task-waiters.js';
|
|
14
20
|
import { TaskEventStreams } from './task-events.js';
|
|
15
21
|
/**
|
|
@@ -27,19 +33,23 @@ export class TaskStreamError extends Error {
|
|
|
27
33
|
/**
|
|
28
34
|
* 任务是否**已到终态**(流可以收口了)。
|
|
29
35
|
*
|
|
30
|
-
* ⚠️ 与 `resume-policy.ts` 那个「可续跑」的判定**刻意不同**:那里 `
|
|
36
|
+
* ⚠️ 与 `resume-policy.ts` 那个「可续跑」的判定**刻意不同**:那里 `suspended`
|
|
31
37
|
* 算「不可续跑」(重启扫描不该去动它),这里是**非终态** —— 挂起在等人,人批了它会接着跑,
|
|
32
38
|
* 流必须**继续开着**(关掉的话「等审批结果的前端」正好在最需要的时候断线)。
|
|
33
39
|
*/
|
|
34
40
|
function isTerminalTask(rec) {
|
|
35
|
-
|
|
41
|
+
// 判定外移到 core/run.ts 的 `isTerminalStatus`(单一真源,2026-09-28 外部深评 S1):
|
|
42
|
+
// 这里原本是负向枚举(`status !== 'queued' && …`),而 Redis store 那边压根没判 ——
|
|
43
|
+
// 同一事实两份读数,写法还不一样。那两处现在都调同一个函数。
|
|
44
|
+
return isTerminalStatus(rec.status);
|
|
36
45
|
}
|
|
37
46
|
/**
|
|
38
47
|
* `approve` 的失败:带 HTTP 语义的状态码(404 = 任务不存在,409 = 任务当前不在
|
|
39
|
-
* `
|
|
48
|
+
* `suspended` 状态),HTTP 宿主据此回对应响应。
|
|
40
49
|
* module 级 export —— 不进公共导出面(纯宿主内部实现细节)。
|
|
41
50
|
*/
|
|
42
51
|
export class TaskApproveError extends Error {
|
|
52
|
+
/** 400 = 入参语义不合法(decisions 里有本任务待决之外的 id,2026-09-28 深评 P1-2) */
|
|
43
53
|
status;
|
|
44
54
|
constructor(status, message) {
|
|
45
55
|
super(message);
|
|
@@ -47,14 +57,144 @@ export class TaskApproveError extends Error {
|
|
|
47
57
|
this.status = status;
|
|
48
58
|
}
|
|
49
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* `cancel` 的失败:带 HTTP 语义的状态码(404 = 任务不存在,409 = 已终态 / 在飞 run 不在本进程)。
|
|
62
|
+
* 形状与 `TaskApproveError` 同款(module 级 export,不进公共导出面)。
|
|
63
|
+
*/
|
|
64
|
+
export class TaskCancelError extends Error {
|
|
65
|
+
status;
|
|
66
|
+
constructor(status, message) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = 'TaskCancelError';
|
|
69
|
+
this.status = status;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
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
|
+
/**
|
|
86
|
+
* `submit` 在**排队段已满**时的失败(`AsyncRunnerOptions.maxQueued`,2026-09-28 外部深评 T5)。
|
|
87
|
+
*
|
|
88
|
+
* 为什么单独一个类型、而不是复用 `TaskInputError`:两者在 HTTP 上的**含义不同** ——
|
|
89
|
+
* 入参不合法是 **400**(调用方改请求就能过),排队满**不是调用方的错**,是 **503
|
|
90
|
+
* + `Retry-After`**(同一次请求稍后重发就该过)。宿主若把它们混成一支,运维看到的就是
|
|
91
|
+
* 「你的请求有问题」,而实际是「服务该扩容 / 该退避」—— 与 `maxConcurrentRuns` 的 503
|
|
92
|
+
* 那支刻意分开的理由同款。
|
|
93
|
+
*
|
|
94
|
+
* `status` 是自陈字段(本仓 `TaskApproveError` 起的惯例):宿主按它选状态码,不靠
|
|
95
|
+
* `instanceof` 猜;本类目前只有 503 一种。
|
|
96
|
+
*
|
|
97
|
+
* module 级 export —— 不进公共导出面(与 TaskApproveError / TaskCancelError / TaskEventError 同档)。
|
|
98
|
+
*/
|
|
99
|
+
export class TaskQueueFullError extends Error {
|
|
100
|
+
status = 503;
|
|
101
|
+
constructor(message) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.name = 'TaskQueueFullError';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
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
|
+
/**
|
|
131
|
+
* 取消的**宽限**:取消请求发出后,留给那次 run 自己收尾的时间。
|
|
132
|
+
*
|
|
133
|
+
* 尊重 signal 的 client 是毫秒级收尾(SDK 一收到 abort 就 reject,run 随即以 `stopReason:
|
|
134
|
+
* 'aborted'` 收尾 —— 那条路连 `result` 与 trace 都保得住,本常量对它只是余量:一次网络往返 +
|
|
135
|
+
* trace 落盘)。不认 signal 的宿主**永远**不会自己收尾 —— 等多久都是白等,所以到点就撤回意图、
|
|
136
|
+
* 如实抛 409(见 `#awaitCancelled`),不假装取消成功。
|
|
137
|
+
*/
|
|
138
|
+
const CANCEL_GRACE_MS = 2_000;
|
|
50
139
|
export class AsyncRunner {
|
|
51
140
|
app;
|
|
52
141
|
store;
|
|
53
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* 本进程标识:写进认领的 `TaskRecord.ownerId`,供 resumePending 区分他我。
|
|
144
|
+
*
|
|
145
|
+
* 形状 `p<pid>@<host>-<8位十六进制>`(真源见 `owner-id.ts`)。为什么带主机名与 pid:
|
|
146
|
+
* 光有时间判不出「主人还在不在」(`staleAfterMs` 是**新鲜度**不是**租约**),
|
|
147
|
+
* 而「同主机 + pid 还在」是能直接问操作系统的**直接证据**(见 `owner-liveness.ts`)。
|
|
148
|
+
*/
|
|
54
149
|
ownerId;
|
|
150
|
+
/**
|
|
151
|
+
* 本机主机名(构造期取一次)。**同一个值**既写进 `ownerId`、也用于「同主机吗」的判定 ——
|
|
152
|
+
* 两处各自取一次就可能不一致(hostname 在进程存活期间是可能被改的),判定会因此静默失效。
|
|
153
|
+
*/
|
|
154
|
+
#host = hostname();
|
|
55
155
|
client;
|
|
56
156
|
sessionStore;
|
|
57
157
|
concurrency;
|
|
158
|
+
maxQueued;
|
|
159
|
+
/**
|
|
160
|
+
* 当前**在等槽位**的任务 id 集合(已受理、尚未拿到槽位)—— `maxQueued` 的判据,
|
|
161
|
+
* 也是 `queued` 读数。
|
|
162
|
+
*
|
|
163
|
+
* 为什么是**集合**而不是计数器:入口只有一处(`#execute` 的**同步前段**、第一个 await
|
|
164
|
+
* 之前 `add`),出口有两处(`#executeInner` 里**拿到槽位**那一刻 `delete`,`#execute`
|
|
165
|
+
* 的外层 finally 再 `delete` 一次兜底 —— 覆盖「幂等去重早退 / 中途抛错」)。`add` /
|
|
166
|
+
* `delete` 的语义**天然幂等**,所以多重释放无害、漏放不可能(兜底那道闸在 finally 里)。
|
|
167
|
+
* 用整数计数器要做到同一件事,得自己再造一个「只减一次」的闸 —— 那正是最容易写漏的东西
|
|
168
|
+
* (第一版就是这么写的,而释放点根本不在同一个函数作用域里,闭包跨不过去)。
|
|
169
|
+
*
|
|
170
|
+
* ⚠️ 同一 taskId 的两次 `#execute` 并发不在设计内(在飞闸挡着);万一发生,先退出那一趟
|
|
171
|
+
* 的 finally 会把仍在等槽位的那趟从集合里删掉 ⇒ 读数**偏小**(闸更宽松)。偏小是安全的
|
|
172
|
+
* 方向:它不会把服务永久锁在 503 上,而「多收一条」只是回到加本旋钮之前的行为。
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ **它本身不是排队深度**:队列长度要减掉「马上就能拿到空槽位的那些」,见 `#queueDepth`
|
|
175
|
+
* —— 一个 `submit` 循环里同步灌进来的每一条都会先落进本集合,而此时池子还空着,
|
|
176
|
+
* 直接拿 `size` 当深度会把「立刻就能跑的」也算成排队的(`concurrency: 1` + `maxQueued: 1`
|
|
177
|
+
* 下连灌两条就会把第 2 条误拒 —— 第一版就是这么写的,被用例当场抓出来)。
|
|
178
|
+
*/
|
|
179
|
+
#waiting = new Set();
|
|
180
|
+
/**
|
|
181
|
+
* **真正在排队的深度**(`maxQueued` 的判据,也是 `queued` 读数)。
|
|
182
|
+
*
|
|
183
|
+
* 公式:`max(0, 已受理未持槽的任务数 + adding − 当前空槽位数)`。
|
|
184
|
+
*
|
|
185
|
+
* 为什么不能只用等待队列长度、也不能只用 `#waiting.size`:
|
|
186
|
+
* - `#waiting` 里混了「刚 submit、还没走到 acquire」的任务 —— 池子空着时它们**不会排队**,
|
|
187
|
+
* 只是还没轮到执行那一刻。`adding` 就是给「正在提交的这一条」留的位置。
|
|
188
|
+
* - 空槽位数 = `concurrency − #slots.inUse`。于是「同步灌 N 条」时:先到的 N 条一边进
|
|
189
|
+
* `#waiting`、一边还算着同样的空槽位,两个量相抵 ⇒ 判据正好落在
|
|
190
|
+
* 「最多 `concurrency + maxQueued` 条在推进」这条对外口径上(见 `AsyncRunnerOptions.maxQueued`)。
|
|
191
|
+
* - `concurrency` 为 `Infinity`(缺省)时空槽位数是 `Infinity` ⇒ 深度恒为 0 ⇒ 闸天然不起作用,
|
|
192
|
+
* 与「缺省行为与加本旋钮之前逐字一致」对齐。
|
|
193
|
+
*/
|
|
194
|
+
#queueDepth(adding = 0) {
|
|
195
|
+
const freeSlots = Math.max(0, this.concurrency - this.#slots.inUse);
|
|
196
|
+
return Math.max(0, this.#waiting.size + adding - freeSlots);
|
|
197
|
+
}
|
|
58
198
|
runTimeoutMs;
|
|
59
199
|
approvalTimeoutMs;
|
|
60
200
|
/** 并发槽位池(见 slot-pool.ts):本类不再自管 running/waitQueue */
|
|
@@ -65,6 +205,21 @@ export class AsyncRunner {
|
|
|
65
205
|
#drain = new DrainGate();
|
|
66
206
|
/** 任务终态等待表(见 task-waiters.ts):仅覆盖本进程写终态,他进程写靠兜底轮询 */
|
|
67
207
|
#taskWaiters = new TaskWaiters();
|
|
208
|
+
/**
|
|
209
|
+
* 本进程**已受理并正在推进**的任务(= `#execute` 的存活区间)。
|
|
210
|
+
*
|
|
211
|
+
* 只为 `cancel` 的文案存在:命中「`status === 'running'` 但在飞句柄表里没有」时,
|
|
212
|
+
* 区分「**本进程排队未起跑**」与「**真不在本进程**」——
|
|
213
|
+
* - 四条恢复路径(approve / 到期唤醒 / 事件投递 / 崩溃重投)都**先**把状态置 `running`
|
|
214
|
+
* 再 `#dispatch`,而 `#runAborts` 要到 `#slots.acquire()` 之后才登记 ⇒ 中间整个窗口
|
|
215
|
+
* 里 `status` 已是 running、句柄却还没有。原文案一律说「不在本进程」,
|
|
216
|
+
* 对同进程排队的那条是**说反话**(2026-09-28 外部复核抓到)。
|
|
217
|
+
* - 本集合与 `#runAborts` 的**差集**恰好就是「已受理、未起跑」(见 `#cancel`)。
|
|
218
|
+
*
|
|
219
|
+
* ⚠️ **纯诊断**:不参与任何正确性判定,也不影响 409 闸本身(那道闸不放开 ——
|
|
220
|
+
* 放开会让等槽位那趟 `#execute` 的 finally 双发 `onFinished`,正是 spec §10 的变异 M23)。
|
|
221
|
+
*/
|
|
222
|
+
#executions = new Set();
|
|
68
223
|
/**
|
|
69
224
|
* 每任务的记账事件流(见 task-events.ts):`GET /tasks/:id/stream` 的重放 + 实时推送。
|
|
70
225
|
* 纯记账件,独立于 store / 槽位 —— 所以在 855 行的类里单独抽一个文件。
|
|
@@ -102,13 +257,25 @@ export class AsyncRunner {
|
|
|
102
257
|
this.sessionStore = opts.sessionStore;
|
|
103
258
|
this.taskSinks = opts.taskSinks ?? [];
|
|
104
259
|
this.onPersistError = opts.onPersistError;
|
|
105
|
-
this.#streams = new TaskEventStreams(
|
|
260
|
+
this.#streams = new TaskEventStreams({
|
|
261
|
+
...(opts.streamBufferEvents === undefined ? {} : { maxEvents: opts.streamBufferEvents }),
|
|
262
|
+
...(opts.streamNonTerminalBuffers === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: { nonTerminalBuffers: opts.streamNonTerminalBuffers }),
|
|
265
|
+
});
|
|
106
266
|
this.concurrency = opts.concurrency ?? Number.POSITIVE_INFINITY;
|
|
107
267
|
if (!(this.concurrency > 0)) {
|
|
108
|
-
throw new Error(`concurrency
|
|
268
|
+
throw new Error(`concurrency 必须为正数(${zeroClauseOf('AsyncRunner.concurrency')}),收到 ${opts.concurrency}`);
|
|
109
269
|
}
|
|
110
270
|
// 放在校验之后:字段初始化式在构造函数体之前求值,那时 concurrency 还是 undefined
|
|
111
271
|
this.#slots = new SlotPool(this.concurrency);
|
|
272
|
+
this.maxQueued = opts.maxQueued ?? 0;
|
|
273
|
+
if (!Number.isInteger(this.maxQueued) || this.maxQueued < 0) {
|
|
274
|
+
// 与 concurrency 同族:`0` 本身**合法**(= 不限),坏的是「负数 / 小数 / NaN / Infinity」。
|
|
275
|
+
// 负数的读法无解(「最多排 -1 个」是什么),小数会让比较变成「有时收有时不收」——
|
|
276
|
+
// 都不是「配置宽一点」而是「配置说了另一件事」,所以构造期响亮失败。
|
|
277
|
+
throw new Error(`maxQueued 必须为 ≥ 0 的整数(${zeroClauseOf('AsyncRunner.maxQueued')}),收到 ${opts.maxQueued}`);
|
|
278
|
+
}
|
|
112
279
|
this.runTimeoutMs = opts.runTimeoutMs ?? 0;
|
|
113
280
|
if (!Number.isFinite(this.runTimeoutMs) || this.runTimeoutMs < 0) {
|
|
114
281
|
// NaN/Infinity 都不能放给 setTimeout:两者都会被钳到 1ms,每个任务立即「超时」失败
|
|
@@ -116,12 +283,17 @@ export class AsyncRunner {
|
|
|
116
283
|
// 文案里的「0 = 不限」取自 core/limits.ts 的表 —— 口径与实现同一处,不可能各说各话。
|
|
117
284
|
throw new Error(`runTimeoutMs 必须为 ≥ 0 的有限数(${zeroClauseOf('AsyncRunner.runTimeoutMs')}),收到 ${opts.runTimeoutMs}`);
|
|
118
285
|
}
|
|
286
|
+
// 上界(2026-09-28):「≥0 且有限」还不够 —— 超过 2^31-1ms(约 24.86 天)的延迟
|
|
287
|
+
// Node **不会遵守**(stderr 一行 TimeoutOverflowWarning + 钳到 1ms),于是「配 30 天
|
|
288
|
+
// 超时」变成「每个任务立即超时失败」,静默且与配置相反。上限单源与五处站点共用:
|
|
289
|
+
// 这一句就是 scheduler.ts 注释里承诺过的「async.ts 同款防线」(此前那句话与实现不符)。
|
|
290
|
+
assertTimerDelay(this.runTimeoutMs, 'AsyncRunner 的 runTimeoutMs');
|
|
119
291
|
this.approvalTimeoutMs = opts.approvalTimeoutMs ?? 0;
|
|
120
292
|
if (!Number.isFinite(this.approvalTimeoutMs) || this.approvalTimeoutMs < 0) {
|
|
121
293
|
// 同 runTimeoutMs:非有限数会让「已挂起多久」的比较静默失效或立即超时
|
|
122
294
|
throw new Error(`approvalTimeoutMs 必须为 ≥ 0 的有限数(${zeroClauseOf('AsyncRunner.approvalTimeoutMs')}),收到 ${opts.approvalTimeoutMs}`);
|
|
123
295
|
}
|
|
124
|
-
this.ownerId =
|
|
296
|
+
this.ownerId = formatOwnerId(process.pid, this.#host, randomUUID().slice(0, 8));
|
|
125
297
|
}
|
|
126
298
|
/**
|
|
127
299
|
* 提交一次异步任务。入参可为 string / messages / {prompt|text|messages}。
|
|
@@ -156,10 +328,28 @@ export class AsyncRunner {
|
|
|
156
328
|
// 处理,拒绝即视为无记录,交 #execute 的去重兜底。
|
|
157
329
|
existing.catch(() => undefined);
|
|
158
330
|
}
|
|
159
|
-
else if (existing && existing.status !== 'failed') {
|
|
160
|
-
|
|
331
|
+
else if (existing && existing.status !== 'failed' && existing.status !== 'cancelled') {
|
|
332
|
+
// at-least-once 去重:不重复执行。`failed` 被刻意排除(失败后同键重提要真跑);
|
|
333
|
+
// `cancelled` 同理 —— 排队被取消的任务**一次都没跑过**,同键再提交若直接认回
|
|
334
|
+
// 那条 cancelled 记录,这次提交就被静默吞掉(且与 #executeInner 只采纳
|
|
335
|
+
// `succeeded` 的异步路径不一致:同一件事两种 store 两种结局)。
|
|
336
|
+
return { ...existing };
|
|
161
337
|
}
|
|
162
338
|
}
|
|
339
|
+
// 排队段闸(2026-09-28 外部深评 T5):放在**去重之后** —— 幂等键命中既有记录时
|
|
340
|
+
// 本次提交既不产生新活也不排队,不该吃 503(否则「重试同一个键」会变成看运气)。
|
|
341
|
+
// 读 `#waiting` 是**同步准确**的:别的 submit 在它自己的 `#execute` 同步前段就入过集合
|
|
342
|
+
// (`#dispatch` 是 submit 的最后一行,`void this.#execute(rec)` 的 body 到第一个 await
|
|
343
|
+
// 之前全同步)⇒ 「一个循环里同步灌 N 条」不可能绕过这道闸,不靠人记得 await。
|
|
344
|
+
// 判据是**真正在排队的深度**(`#queueDepth`),不是「已受理未持槽」的集合大小:
|
|
345
|
+
// 后者把「池子还空着、马上就能跑」的那些也算成排队的,会把第 2 条合法提交误拒。
|
|
346
|
+
// `(1)` = 把正在提交的这一条算进去 —— 闸要拦的是「这条交了以后会溢出」。
|
|
347
|
+
const queueDepth = this.#queueDepth(1);
|
|
348
|
+
if (this.maxQueued > 0 && queueDepth > this.maxQueued) {
|
|
349
|
+
throw new TaskQueueFullError(`排队已满:这条提交会让 ${queueDepth} 条任务排在槽位后` +
|
|
350
|
+
`(上限 maxQueued=${this.maxQueued},concurrency=${this.concurrency})` +
|
|
351
|
+
'—— 请退避重试,或调大 concurrency / maxQueued');
|
|
352
|
+
}
|
|
163
353
|
const rec = {
|
|
164
354
|
taskId: nextTaskId(),
|
|
165
355
|
status: 'queued',
|
|
@@ -182,17 +372,25 @@ export class AsyncRunner {
|
|
|
182
372
|
void this.#safeSave(rec, 'initial');
|
|
183
373
|
});
|
|
184
374
|
}
|
|
185
|
-
|
|
375
|
+
// 派发走唯一入口(闸在 #dispatch 里)—— ⚠️ 这里是**结构一致性**,不是行为依赖:
|
|
376
|
+
// 本方法开头那道 `isDraining` 检查(拒绝语义:对调用方的承诺是抛错 / 503,不是静默排期)
|
|
377
|
+
// 与这一行之间**没有 await** —— `submit` 是**全同步**的(异步 store 的 `save` 也只是挂个
|
|
378
|
+
// `.catch`,不 await)⇒ JS 不可能在中间插入 drain 的置位 ⇒ `#dispatch` 那道闸在**本方法内
|
|
379
|
+
// 永远不触发**。(2026-09-28 PR #164 复核 §3:此前的注释与提交信息把它说成「挡进闸与派发
|
|
380
|
+
// 之间刚开始停机的窄窗口」,那是个不存在的窗口。)
|
|
381
|
+
// 仍然走它:**「派发一律走唯一入口」这条纪律不该有例外** —— 少一个例外,就少一处将来
|
|
382
|
+
// 新增恢复路径时会漏的枚举。
|
|
383
|
+
this.#dispatch(rec);
|
|
186
384
|
// 返回浅拷贝:记录会被后台状态机原地推进,调用方拿到的是提交时刻的快照
|
|
187
385
|
return { ...rec };
|
|
188
386
|
}
|
|
189
387
|
/** 查任务当前记录(异步 store 下返回 Promise,调用方 await) */
|
|
190
388
|
poll(taskId) {
|
|
191
389
|
const rec = this.store.get(taskId);
|
|
192
|
-
//
|
|
390
|
+
// 惰性挂起闸(HITL 超时 + 到期唤醒):读到挂起记录时顺手判定,到点就恢复并重派
|
|
193
391
|
if (isThenable(rec))
|
|
194
|
-
return rec.then((r) => this.#
|
|
195
|
-
return this.#
|
|
392
|
+
return rec.then((r) => this.#lazyGates(r));
|
|
393
|
+
return this.#lazyGates(rec);
|
|
196
394
|
}
|
|
197
395
|
/**
|
|
198
396
|
* 订阅某任务的**记账事件流**(`GET /tasks/:id/stream` 的引擎侧)。
|
|
@@ -201,7 +399,7 @@ export class AsyncRunner {
|
|
|
201
399
|
* 1. **先重放、后实时**:`opts.from`(= SSE 的 `Last-Event-ID` / `?from=`)之后的事件先补发,
|
|
202
400
|
* 再挂实时订阅 —— 「连上时已经跑了一半」的客户端因此也能拿到完整前缀。
|
|
203
401
|
* 2. **终态收口**:任务已终态 ⇒ 补发完缓冲 + 一帧 `end`,**不留**一条永远不会再产出事件的流。
|
|
204
|
-
* 挂起(`
|
|
402
|
+
* 挂起(`suspended`)**不是**终态:批了(或睡到点了)会接着跑,流必须开着(见 isTerminalTask)。
|
|
205
403
|
* 3. **跨进程不假装**:本进程没有这条流时(别的进程在跑 / 任务早于本进程),发一帧
|
|
206
404
|
* `unavailable` 后**立即收口**:终态补 `end`,非终态补 `closed`(流级收尾,不是
|
|
207
405
|
* 伪造终态)—— 非终态只发 `unavailable` 就返回的话,这条 SSE 只剩心跳永远挂着。
|
|
@@ -260,13 +458,141 @@ export class AsyncRunner {
|
|
|
260
458
|
return this.store.list();
|
|
261
459
|
}
|
|
262
460
|
/**
|
|
263
|
-
*
|
|
461
|
+
* 取消一个任务(durable 配套 5 的另一半;设计稿 `docs/plans/2026-09-28-cancel-api.md`)。
|
|
462
|
+
*
|
|
463
|
+
* 三种「在服」状态各自的语义 —— 都落在这一处,不分散:
|
|
464
|
+
* - **running**:中止在飞 signal ⇒ 引擎以 `stopReason: 'aborted'` 收尾(尊重 signal 的
|
|
465
|
+
* client 是**真中断**,token 不再烧;不尊重者等价「放弃等待」—— 与 `runTimeoutMs`
|
|
466
|
+
* 同一份契约)。等它真落库再返回:不等的话调用方拿到的是 `status` 仍为 running 的快照,
|
|
467
|
+
* 「取消了但状态还在跑」比不返回更误导。
|
|
468
|
+
* - **queued**(还没起跑):落终态;`#executeInner` 拿到槽位后会**重读**再判,
|
|
469
|
+
* 所以被取消的排队任务**绝不起跑**(这条是本次实现顺带补上的洞)。记账由本方法
|
|
470
|
+
* 自己做;那一趟还活着的 `#execute`(在等槽位)靠 `#cancelSettled` 在 finally 里
|
|
471
|
+
* 跳过它那一份(否则 onFinished 两次)。
|
|
472
|
+
* - **suspended**(两种原因都算):落终态 —— 两条唤醒闸都 gate 在 `status === 'suspended'`
|
|
473
|
+
* 上,「不唤醒」因此是翻转状态的**推论**,不需要第二处标志。它没有活着的 `#execute`
|
|
474
|
+
* (挂起时那趟就走完了),所以记账(sinks / 事件流 / 认领释放 / 等待者 / 会话清理)
|
|
475
|
+
* 由本方法自己做 —— 也因此它**不进** `#cancelSettled`(没有消费者,加了就是泄漏)。
|
|
476
|
+
*
|
|
477
|
+
* 状态按**意图**落,不按机制落:超时的收尾语义**不变**(仍是 `failed` + `error.type` 为
|
|
478
|
+
* `'timeout'`),取消带 `error = abortedError()`(取消不是失败,但原因要可查)。
|
|
479
|
+
*
|
|
480
|
+
* 不做静默 no-op:已终态 ⇒ `TaskCancelError(409)`(状态不对要说出来),不存在 ⇒ 404。
|
|
481
|
+
* 边界(如实写在这里):`running` 的 run **不在本进程**(多进程部署 / 上一世留下的 running)
|
|
482
|
+
* 时抛 409 —— 本进程没有句柄可中断,改状态假装取消只会在那条 run 跑完时被覆盖回去。
|
|
483
|
+
*
|
|
484
|
+
* ⚠️ 同一个 409 判据下还有**第二种**情形(2026-09-28 外部复核):任务已在本进程受理、
|
|
485
|
+
* 但还没走到 `#runAborts` 登记(在等并发槽位,或恢复路径刚写完 `running` 尚未派发完)。
|
|
486
|
+
* 它**不是**「不在本进程」—— 原文案一律那么说,对这一类是说反话。两种情形分开措辞
|
|
487
|
+
* (见 `#cancel` 的判据),但**闸不放开**:放开会让等槽位那趟 `#execute` 的 finally
|
|
488
|
+
* 双发 `onFinished`(spec §10 的变异 M23)。
|
|
489
|
+
*/
|
|
490
|
+
cancel(taskId) {
|
|
491
|
+
const found = this.store.get(taskId);
|
|
492
|
+
if (isThenable(found))
|
|
493
|
+
return found.then((rec) => this.#cancel(rec, taskId));
|
|
494
|
+
return this.#cancel(found, taskId);
|
|
495
|
+
}
|
|
496
|
+
#cancel(rec, taskId) {
|
|
497
|
+
if (!rec)
|
|
498
|
+
throw new TaskCancelError(404, `task 不存在: ${taskId}`);
|
|
499
|
+
if (isTerminalTask(rec)) {
|
|
500
|
+
throw new TaskCancelError(409, `task ${taskId} 已终态(${rec.status}),不能取消`);
|
|
501
|
+
}
|
|
502
|
+
const inflight = this.#runAborts.get(taskId);
|
|
503
|
+
if (rec.status === 'running' && !inflight) {
|
|
504
|
+
// 「假装取消」是这里最坏的选项:那条 run 还活着,跑完会把状态覆盖回去
|
|
505
|
+
// (调用方以为成功了,实际什么都没发生)。要说出来。
|
|
506
|
+
// ⚠️ 但这两类情形**必须分开说**(2026-09-28 外部复核):`#executions` 是
|
|
507
|
+
// 「本进程已受理并正在推进」的集合,与 `#runAborts`(已登记在飞句柄)的差集
|
|
508
|
+
// 就是「**已受理、未起跑**」。四条恢复路径先置 `running` 再 `#dispatch`、
|
|
509
|
+
// 而句柄要到拿到槽位才登记 ⇒ 中间整个窗口都会落进这个差集 —— 对它说
|
|
510
|
+
// 「不在本进程」是反话。调用方据此区分「稍后重试」与「得去找那个进程」。
|
|
511
|
+
const acceptedHere = this.#executions.has(taskId);
|
|
512
|
+
throw new TaskCancelError(409, acceptedHere
|
|
513
|
+
? `task ${taskId} 已在本进程受理,但尚未起跑(正在等并发槽位 / 走恢复前置)—— ` +
|
|
514
|
+
`此刻取消无法与那条在等槽位的执行路径安全收口(会重复记账),暂不支持;` +
|
|
515
|
+
`请待它起跑后再取消,或先降低并发压力`
|
|
516
|
+
: `task ${taskId} 正在运行但不在本进程,本进程无法中断它(多进程部署见 spec §10 2026-09-28 ④)`);
|
|
517
|
+
}
|
|
518
|
+
// 意图先记下:在飞那条路要靠它把 aborted 的收尾落成 cancelled(否则落成 failed)
|
|
519
|
+
this.#cancels.add(taskId);
|
|
520
|
+
if (inflight) {
|
|
521
|
+
inflight.abort();
|
|
522
|
+
return this.#awaitCancelled(taskId);
|
|
523
|
+
}
|
|
524
|
+
// queued / suspended:没有在飞 run(或它还没起跑),直接落终态 —— 记账由本方法做完,
|
|
525
|
+
// 等槽位那趟 #execute 的 finally 靠 `#cancelSettled` 跳过它那一份(否则 onFinished 两次)。
|
|
526
|
+
// ⚠️ `#cancelSettled` 只为 **queued** 而存(它有活着的、在等槽位的 #execute 来消费);
|
|
527
|
+
// suspended 的那趟 #execute 早在挂起时就走完了,条目加进去永远没人摘(泄漏)。
|
|
528
|
+
// 既知边界(多进程):他进程取消本进程**排队中**的任务时,本进程的 execute finally
|
|
529
|
+
// 在自己的 `#cancelSettled` 里查不到 ⇒ onFinished 会双发(cancel 进程一次 + owner 一次)。
|
|
530
|
+
const wasQueued = rec.status === 'queued';
|
|
531
|
+
rec.status = 'cancelled';
|
|
532
|
+
rec.error = abortedError();
|
|
533
|
+
rec.finishedAt = Date.now();
|
|
534
|
+
// 挂起痕迹清掉(`pendingApprovals` 与 `approvals` 的区别与既有终态口径一致:
|
|
535
|
+
// 未决清单清掉、**已做出的决定**保留 —— 那是审计的一部分,随任务走)
|
|
536
|
+
rec.pendingApprovals = undefined;
|
|
537
|
+
rec.suspendedSince = undefined;
|
|
538
|
+
rec.suspendedReason = undefined;
|
|
539
|
+
rec.wakeAt = undefined;
|
|
540
|
+
this.#unmarkSuspended(taskId);
|
|
541
|
+
const saved = this.#safeSave(rec, 'outcome');
|
|
542
|
+
this.#cancels.delete(taskId);
|
|
543
|
+
if (wasQueued)
|
|
544
|
+
this.#cancelSettled.add(taskId);
|
|
545
|
+
const finish = () => {
|
|
546
|
+
this.#taskWaiters.notify(taskId);
|
|
547
|
+
this.#streams.markDone(taskId);
|
|
548
|
+
this.sessionInputs.delete(taskId);
|
|
549
|
+
const key = rec.idempotencyKey;
|
|
550
|
+
if (key !== undefined && this.#claims.get(key)?.taskId === taskId)
|
|
551
|
+
this.#claims.delete(key);
|
|
552
|
+
this.#notifyDrained();
|
|
553
|
+
return { ...rec };
|
|
554
|
+
};
|
|
555
|
+
return isThenable(saved)
|
|
556
|
+
? Promise.resolve(saved)
|
|
557
|
+
.then(() => this.#notifySinks(rec))
|
|
558
|
+
.then(finish)
|
|
559
|
+
: this.#notifySinks(rec).then(finish);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* 等一条**已请求取消**的在飞任务真收尾(宽限见 `CANCEL_GRACE_MS`)。
|
|
563
|
+
*
|
|
564
|
+
* 终态判据用 `isTerminalTask`(单一真源)—— 手写两值会让 `cancelled` 在这里被漏掉,
|
|
565
|
+
* 等待就变成「等到超时」,症状是「取消没生效」。
|
|
566
|
+
*
|
|
567
|
+
* ⚠️ 宽限内没收尾 ⇒ **撤回意图 + 抛 409**,不假装:敢说「取消了」而那条 run 还活着的话,
|
|
568
|
+
* 它跑完会把状态覆盖回去(调用方以为成功、实际什么都没发生)。不认 `signal` 的宿主正是
|
|
569
|
+
* 这一类;`runTimeoutMs` 那条老路对同样情形是「放弃等待」(记录落 failed、槽位释放、
|
|
570
|
+
* 底层执行照样跑完)—— 两者**刻意不同**:超时是宿主自己定的预算,取消是人手按的动作,
|
|
571
|
+
* 骗人的代价不一样。
|
|
572
|
+
*/
|
|
573
|
+
async #awaitCancelled(taskId) {
|
|
574
|
+
const deadline = Date.now() + CANCEL_GRACE_MS;
|
|
575
|
+
for (;;) {
|
|
576
|
+
const rec = await this.store.get(taskId);
|
|
577
|
+
if (rec && isTerminalTask(rec))
|
|
578
|
+
return rec;
|
|
579
|
+
const left = deadline - Date.now();
|
|
580
|
+
if (left <= 0) {
|
|
581
|
+
// 撤回意图(不删 `#runAborts`:那条 run 还在本进程里活着,句柄留给它自己的 finally 摘)
|
|
582
|
+
this.#cancels.delete(taskId);
|
|
583
|
+
throw new TaskCancelError(409, `task ${taskId} 的中断没有在 ${CANCEL_GRACE_MS}ms 内生效(宿主不认 signal?)—— 取消未发生`);
|
|
584
|
+
}
|
|
585
|
+
await this.#taskWaiters.wait(taskId, Math.min(250, left));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* 审批一个处于 `suspended` 的任务(HITL)。
|
|
264
590
|
*
|
|
265
591
|
* - **逐 tool_use_id 幂等**:已存在的决定不覆盖(第一次决定赢)—— 重复提交 /
|
|
266
592
|
* 并发点击不会推翻已有决定,也不会让恢复段重复执行;
|
|
267
593
|
* - **并发重入共享在飞那次**:同一任务的并发 approve(双击「批准」/两个审批人
|
|
268
594
|
* 同时批)返回同一个 Promise —— 否则两个调用都在对方落库前读到
|
|
269
|
-
* `
|
|
595
|
+
* `suspended`、各自判「决定齐了」、**各派发一次**(同一任务重复执行,
|
|
270
596
|
* 与 resumePending 的闸门同一 bug 类)。被共享的那次覆盖不到的决定不丢:
|
|
271
597
|
* 调用方从返回的记录看到任务仍在等待,重试即并入;
|
|
272
598
|
* - 决定齐了就恢复:`status` 回 `running`、**先落库再派发**(与 `#redispatch`
|
|
@@ -294,10 +620,29 @@ export class AsyncRunner {
|
|
|
294
620
|
const rec = await this.store.get(taskId);
|
|
295
621
|
if (!rec)
|
|
296
622
|
throw new TaskApproveError(404, `task 不存在: ${taskId}`);
|
|
297
|
-
if (rec.status !== '
|
|
298
|
-
throw new TaskApproveError(409,
|
|
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},只有挂起在等审批的任务才能审批`);
|
|
299
627
|
}
|
|
300
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
|
+
}
|
|
301
646
|
rec.approvals ??= {};
|
|
302
647
|
for (const [id, d] of Object.entries(decisions)) {
|
|
303
648
|
if (rec.approvals[id])
|
|
@@ -307,9 +652,7 @@ export class AsyncRunner {
|
|
|
307
652
|
...(d.reason !== undefined ? { reason: d.reason } : {}),
|
|
308
653
|
...(opts.decidedBy !== undefined ? { decidedBy: opts.decidedBy } : {}),
|
|
309
654
|
decidedAt: now,
|
|
310
|
-
...(rec.
|
|
311
|
-
? { requestedAt: rec.approvalPendingSince }
|
|
312
|
-
: {}),
|
|
655
|
+
...(rec.suspendedSince !== undefined ? { requestedAt: rec.suspendedSince } : {}),
|
|
313
656
|
};
|
|
314
657
|
}
|
|
315
658
|
// 惰性审批超时:人的决定先并入(先到先赢),仍空着的待决项由超时兜底成 deny
|
|
@@ -324,14 +667,135 @@ export class AsyncRunner {
|
|
|
324
667
|
// 绝不恢复执行(落不了库的决定不算决定:进程崩在窗口里会丢决定,重启后把
|
|
325
668
|
// 同一件事再判一次、可能改判)。调用方拿到 reject,重试即可。
|
|
326
669
|
await this.store.save(rec);
|
|
327
|
-
if (complete)
|
|
328
|
-
|
|
670
|
+
if (complete) {
|
|
671
|
+
// 离开挂起态(读数纪律②):决定齐了这就是「醒来」那一刻
|
|
672
|
+
this.#unmarkSuspended(taskId);
|
|
673
|
+
// 派发走**唯一入口**(闸在 #dispatch 里判一次 —— 2026-09-28 外部深评 P1-1 的结构性修法):
|
|
674
|
+
// 决定已经在记录里,停机窗口里放行的是「重新排期」(下次启动认领),不是「吞掉」。
|
|
675
|
+
this.#dispatch(rec);
|
|
676
|
+
}
|
|
677
|
+
return { ...rec };
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
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 同理由:挂起的任务只有外部输入能推进)。
|
|
699
|
+
*
|
|
700
|
+
* 并发闸与 approve **不同款**:approve 共享在飞那次(决定是幂等合并),事件**串行成链**
|
|
701
|
+
* (两条并发事件是两条不同的输入,共享 = 静默丢一条)。与其它恢复路径
|
|
702
|
+
* (approve / 到期唤醒,各有自己的闸)的竞速:进闸后重读再判挡住绝大多数;
|
|
703
|
+
* 剩下的窄窗口与既有「approve × wakeDue 分闸」同类(那两条按挂起原因天然互斥,
|
|
704
|
+
* 事件两种原因都适用,是这个闸新盖的缝 —— 已记在 spec §10 2026-09-28 ⑥)。
|
|
705
|
+
*/
|
|
706
|
+
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);
|
|
329
762
|
return { ...rec };
|
|
330
763
|
}
|
|
331
764
|
/** 已受理但未达终态的任务数(queued + running)—— 健康检查与 drain 共用同一口径 */
|
|
332
765
|
get inFlight() {
|
|
333
766
|
return this.active;
|
|
334
767
|
}
|
|
768
|
+
/**
|
|
769
|
+
* **本进程可见的挂起读数**(`/healthz` 的 `suspended` 段,配套 6)。
|
|
770
|
+
*
|
|
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
|
+
* 拿它当健康检查的代价比它回答的问题大得多。
|
|
783
|
+
*/
|
|
784
|
+
#suspended = new Map();
|
|
785
|
+
/** 挂起读数:按原因分组的条数 + 最早的目标时刻(无时间挂起时为 `null`) */
|
|
786
|
+
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);
|
|
798
|
+
}
|
|
335
799
|
/** 是否已进入优雅停机(drain 之后为 true)—— HTTP 宿主据此对新单回 503 */
|
|
336
800
|
get isDraining() {
|
|
337
801
|
return this.#drain.isDraining;
|
|
@@ -343,6 +807,17 @@ export class AsyncRunner {
|
|
|
343
807
|
* `resumePending` 续跑(所以 drain 不是「丢弃」,是「不再往前推」)。
|
|
344
808
|
* - `timeoutMs` 缺省 0 = 一直等。
|
|
345
809
|
* - 等待的是**所有已受理**的任务(queued 的也在内),不只是正在占槽位的那些。
|
|
810
|
+
*
|
|
811
|
+
* ⚠️ **这是一道单向闩:置位之后本进程不再推进任何任务,且没有复位路径**(`DrainGate` 只有
|
|
812
|
+
* 置位,没有复位)。所以 `false`(排空超时)的含义不只是「还有在飞的」——它同时给宿主
|
|
813
|
+
* 加了一条义务:**必须让进程退出**。理由(2026-09-28 PR #164 复核 §1,有探针实证):
|
|
814
|
+
* 停机窗口里被 `#dispatch` 拒掉的记录**已经落库**(`running`/`queued` + **本进程的 ownerId**),
|
|
815
|
+
* 而本进程的 `resumePending` 会按 `own-process` 跳过它们(见 resume-policy.ts),`submit` 又
|
|
816
|
+
* 已整体关闭 ⇒ 本进程内**没有任何自愈路径**。宿主若在 `false` 之后继续服务,这些记录会永远
|
|
817
|
+
* 停在 `running`:`awaitTask` 不返回,`GET /tasks/:id/stream` 还会告诉客户端
|
|
818
|
+
* `not-in-this-process`(说不对话:记录里的 ownerId 正是本进程)。只有**下一次进程启动**
|
|
819
|
+
* 才会有人认领它们(ownerId 含 pid)。参考宿主(`examples/` 各示例的 `src/main.ts`)都是
|
|
820
|
+
* `await drain(...)` 之后 `process.exit(...)`,与本条契约一致。
|
|
346
821
|
*/
|
|
347
822
|
async drain(opts = {}) {
|
|
348
823
|
// 实现已外移到 drain-gate.ts(写前保留的契约注释仍在本方法上)
|
|
@@ -380,8 +855,7 @@ export class AsyncRunner {
|
|
|
380
855
|
// 凭陈旧快照放行会把同一任务再派发一次。
|
|
381
856
|
const fresh = await this.store.get(rec.taskId);
|
|
382
857
|
const target = fresh ?? rec;
|
|
383
|
-
if (target.status !== '
|
|
384
|
-
!approvalExpired(target, now, this.approvalTimeoutMs)) {
|
|
858
|
+
if (target.status !== 'suspended' || !approvalExpired(target, now, this.approvalTimeoutMs)) {
|
|
385
859
|
return target;
|
|
386
860
|
}
|
|
387
861
|
fillTimeoutDenials(target, now);
|
|
@@ -403,24 +877,164 @@ export class AsyncRunner {
|
|
|
403
877
|
return target;
|
|
404
878
|
}
|
|
405
879
|
}
|
|
406
|
-
|
|
880
|
+
// 离开挂起态(读数纪律②)—— 落在**落库成功之后**:落不了库就不算恢复(没派发,读数也不动)
|
|
881
|
+
this.#unmarkSuspended(target.taskId);
|
|
882
|
+
// 派发走唯一入口(闸在 #dispatch 里)。⚠️ **这一条正是外部深评抓到的第四条路径**:
|
|
883
|
+
// `#expireAndResume`(审批超时自动全拒并恢复)与 `approve` 不同触发源、同一形状,
|
|
884
|
+
// 由 `poll()` 的惰性闸驱动 —— 而「停机中照常可轮询」是 HTTP 宿主的明确承诺
|
|
885
|
+
// (LB / K8s preStop / 前端轮询)⇒ 停机后照样能起新 run。
|
|
886
|
+
this.#dispatch(target);
|
|
407
887
|
return target;
|
|
408
888
|
}
|
|
409
|
-
/**
|
|
410
|
-
|
|
889
|
+
/**
|
|
890
|
+
* poll 的读路径钩子:两条**惰性**的挂起闸各判一次(都不起定时器 —— 没人读的挂起不会自己动,
|
|
891
|
+
* 与审批超时同一条纪律;崩溃/重启也不依赖任何在飞回调)。
|
|
892
|
+
*
|
|
893
|
+
* 写成 `else if` 而不是两个独立的 `if`:两条闸的原因判据本来就互斥(`approval` / `timer`),
|
|
894
|
+
* 排他在这里是**写出来的**而不是「两个函数各自恰好正确」—— C 之前正是「一个状态兼职两件事」
|
|
895
|
+
* 让审批那条闸去叫醒了等时刻的 run。
|
|
896
|
+
*/
|
|
897
|
+
#lazyGates(rec) {
|
|
411
898
|
if (!rec)
|
|
412
899
|
return rec;
|
|
413
900
|
const now = Date.now();
|
|
414
901
|
if (approvalExpired(rec, now, this.approvalTimeoutMs))
|
|
415
902
|
this.#expireAndResume(rec, now);
|
|
903
|
+
else if (timerDue(rec, now))
|
|
904
|
+
this.#wakeDue(rec, now);
|
|
416
905
|
return rec;
|
|
417
906
|
}
|
|
907
|
+
/** 在飞的到期唤醒(同一任务的重入闸,理由与 inflightApprovals 同款:两条路径都「落库 + 派发」)。 */
|
|
908
|
+
inflightWakes = new Map();
|
|
909
|
+
/**
|
|
910
|
+
* 取消的**意图**(2026-09-28 ④)。落库状态按意图判、不从 signal 反推 ——
|
|
911
|
+
* 同一条 signal 有三个来源(调用方传入 / `runTimeoutMs` / 取消),反推不出「谁按的」。
|
|
912
|
+
* 只在一次 run 的收尾判定里活着:登记在认领处,摘除在同一次 run 的 finally。
|
|
913
|
+
*/
|
|
914
|
+
#cancels = new Set();
|
|
915
|
+
/** 在飞 run 的中止句柄(**每任务一个**,同时服务超时与取消 —— 机制相同、意图不同)。 */
|
|
916
|
+
#runAborts = new Map();
|
|
917
|
+
/**
|
|
918
|
+
* 已由 `cancel` **自己**收尾过的任务。那趟等槽位的 `#execute` 还活着,它的 finally 会再记一遍账
|
|
919
|
+
* (onFinished 触发两次 / 流被关两次)—— 用这个集合把「谁负责记账」说定:cancel 收尾了,
|
|
920
|
+
* finally 就只做它自己那份(在飞递减、通知等待者、排空通知)。
|
|
921
|
+
*/
|
|
922
|
+
#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
|
+
/**
|
|
983
|
+
* **唯一的派发口**(2026-09-28 外部深评 P1-1 / P2-1 的结构性修法)。
|
|
984
|
+
*
|
|
985
|
+
* 为什么要有它:所有「先把状态落库成 `running`/`queued`、再派发 `#execute`」的路径
|
|
986
|
+
* (`submit` / `approve` / `signalTask` / 到期唤醒 / 审批超时兜底 / `resumePending` 的认领)
|
|
987
|
+
* **形状相同**,而停机闸原先散在各支里 —— 于是「这道闸覆盖几条路径」变成一份**靠人记**的清单:
|
|
988
|
+
* 第二批修了三支、第四支(审批超时兜底,走 `poll()`)与第五支(认领循环)漏了,两处都在
|
|
989
|
+
* `drain()` 返回 `true`(「排空干净」)之后**又起了新 run**(实证:`drain-race.test.ts`)。
|
|
990
|
+
*
|
|
991
|
+
* 修法:闸**只在这里判一次**,各支只管「先落库」的顺序(那条纪律不变)。
|
|
992
|
+
* 源码级穷尽守卫在 `tests/transport/dispatch-guard.test.ts`:`this.#execute(` 与
|
|
993
|
+
* `this.#executeInner(` 都只许出现在自己的那一个家里 —— 第五次新增恢复路径时构建就红,
|
|
994
|
+
* 不靠记性。(钉两个而不是一个:`#executeInner` 是同一个旁路的另一半,绕过它还会连带
|
|
995
|
+
* 绕过 `active++` 与 `#streams.open`,见那份用例的注释。)
|
|
996
|
+
*
|
|
997
|
+
* 语义:停机窗口里**不派发**。记录已落库(`running`/`queued` + 本进程 ownerId),
|
|
998
|
+
* 留给下次启动的 `resumePending` 认领(ownerId 含 pid ⇒ 新进程必认领)—— at-least-once 兜底,
|
|
999
|
+
* 与「先落库再派发」的崩窗同形;决定 / 事件都在记录里,不丢。
|
|
1000
|
+
* ⚠️ 「留给下次启动」成立的前提是**宿主真的退出**(见 `drain()` 的契约:单向闩 + 本进程内
|
|
1001
|
+
* 无自愈路径)。所以这里的拒绝**不静默**:首次拒绝打一条 `console.warn` 说清「谁、什么状态、
|
|
1002
|
+
* 谁来认领、宿主该做什么」—— 否则运维只看到一个停在 `running` 的任务,无从解释。
|
|
1003
|
+
* ⚠️ 对调用方**不抛错**(`submit` 的「停机中不接单 ⇒ 503」在那之前的早返回里,不在这一层)。
|
|
1004
|
+
*/
|
|
1005
|
+
#dispatch(rec) {
|
|
1006
|
+
if (this.#drain.isDraining) {
|
|
1007
|
+
// **不静默**(2026-09-28 PR #164 复核 §1):这里的拒绝等于「本进程此后不再推进任何任务」,
|
|
1008
|
+
// 而它此前是一条裸 `return` —— 运维只会看到一个停在 `running` 的任务,无从解释它为什么不跑
|
|
1009
|
+
// (`drain()` 的契约见上:宿主此刻必须退出,记录留给下次启动认领)。
|
|
1010
|
+
// 只报一次:停机窗口里每条被拒记录各报一条会把日志刷满,而它们的原因完全相同。
|
|
1011
|
+
if (!this.#dispatchRefusedWarned) {
|
|
1012
|
+
this.#dispatchRefusedWarned = true;
|
|
1013
|
+
console.warn(`[agentia] 停机中:已拒绝派发 task ${rec.taskId}(记录已落库成 ${rec.status})—— ` +
|
|
1014
|
+
'本进程此后不再推进任何任务,它留给下次启动的 resumePending 认领;' +
|
|
1015
|
+
'宿主必须在 drain() 之后退出,否则这条记录会一直停在 running(见 usage-guide §7)');
|
|
1016
|
+
}
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
void this.#execute(rec);
|
|
1020
|
+
}
|
|
1021
|
+
/** 「停机窗口里拒绝派发」的告警是否已出过(每次停机只报一条,见 `#dispatch`) */
|
|
1022
|
+
#dispatchRefusedWarned = false;
|
|
1023
|
+
/**
|
|
1024
|
+
* 当前**真正在排队**的任务数(`maxQueued` 的实时读数,诊断用)—— 与闸用的是**同一个**
|
|
1025
|
+
* `#queueDepth`,所以「看到的数」就是「判据的数」,不会各说各话。
|
|
1026
|
+
* 与 `inFlight` 的分工:`inFlight` 是「本进程已受理、未达终态」的全量(含在跑、含挂起),
|
|
1027
|
+
* 本读数是其中「排不上、在等槽位」的那一段。
|
|
1028
|
+
*/
|
|
1029
|
+
get queued() {
|
|
1030
|
+
return this.#queueDepth();
|
|
1031
|
+
}
|
|
418
1032
|
/** 任务终态唤醒与等待:实现见 task-waiters.ts(`notify` / `wait`) */
|
|
419
1033
|
/** 排空通知:只在确无在飞任务时唤醒等待者(drain 的唯一出口)—— 判定在 drain-gate.ts */
|
|
420
1034
|
#notifyDrained() {
|
|
421
1035
|
this.#drain.signalIdle(() => this.active === 0);
|
|
422
1036
|
}
|
|
423
|
-
/** 等到任务终态;超时抛错。`
|
|
1037
|
+
/** 等到任务终态;超时抛错。`suspended` 不是终态 —— 继续等(人在路上 / 时刻未到)。 */
|
|
424
1038
|
async awaitTask(taskId, opts = {}) {
|
|
425
1039
|
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
426
1040
|
// intervalMs 现在只是**兜底轮询**间隔,不是主路径:本进程把任务写到终态会主动唤醒
|
|
@@ -434,7 +1048,9 @@ export class AsyncRunner {
|
|
|
434
1048
|
const rec = await this.poll(taskId);
|
|
435
1049
|
if (!rec)
|
|
436
1050
|
throw new Error(`task 不存在: ${taskId}`);
|
|
437
|
-
|
|
1051
|
+
// ⚠️ 终态集合走 `isTerminalTask` 这个**单一真源**:手写枚举会让新加的终态(`cancelled`)
|
|
1052
|
+
// 在这里被漏掉 —— 表现为「取消了但 awaitTask 一直等到超时」,看起来像取消没生效
|
|
1053
|
+
if (isTerminalTask(rec))
|
|
438
1054
|
return rec;
|
|
439
1055
|
const left = deadline - Date.now();
|
|
440
1056
|
if (left <= 0)
|
|
@@ -447,17 +1063,21 @@ export class AsyncRunner {
|
|
|
447
1063
|
* (running 视为进程中断)。返回重派数量(异步 store 下返回 Promise<number>)。
|
|
448
1064
|
* 幂等键去重照常生效。
|
|
449
1065
|
*
|
|
450
|
-
* `
|
|
451
|
-
*
|
|
452
|
-
*
|
|
1066
|
+
* `suspended`(HITL)**不捡**:它在等人、不是孤儿(进程没死也可能挂着)。
|
|
1067
|
+
* 但读到它会做**惰性判定**,两条闸各管各的原因:
|
|
1068
|
+
* - `approval`:配了 `approvalTimeoutMs` 且已超时的挂起任务自动全拒
|
|
1069
|
+
* (`denied, reason: '审批超时'`)并重派;
|
|
1070
|
+
* - `timer`:`wakeAt` 已到的睡着的任务**唤醒并重派**(醒来重跑那一批)。
|
|
1071
|
+
* ⚠️ `drain()` 之后不唤醒(配套 5):停机窗口里天级的 run 被叫起来接着跑 = 部署卡死。
|
|
453
1072
|
*
|
|
454
1073
|
* **认领先落库、再派发**(见 `#redispatch`);异步 store 的认领落库失败会让本方法
|
|
455
1074
|
* reject —— 宁可让调用方看见「续跑没做」,也不要静默放出一批会被重复执行的任务。
|
|
456
1075
|
*
|
|
457
1076
|
* 多进程共用一个 store 时靠 `ownerId` 区分他我:
|
|
458
1077
|
* - 本进程的记录一律跳过(它还在本进程内存里跑,重派 = 跑两遍);
|
|
459
|
-
* - `staleAfterMs > 0`
|
|
460
|
-
*
|
|
1078
|
+
* - `staleAfterMs > 0` 时启动他进程判定:**同主机就问 pid 还在不在**(在的不抢、
|
|
1079
|
+
* 不在的立刻抢),问不到(异主机 / 旧格式 ownerId)才退回保鲜期启发式。
|
|
1080
|
+
* 缺省 0 = 不判断、一律重派(单进程旧语义)。判定分级与边界见 `resume-policy.ts`。
|
|
461
1081
|
*/
|
|
462
1082
|
resumePending(opts = {}) {
|
|
463
1083
|
// 重入闸:**并发**调用共享同一次扫描的结果,而不是各自再扫一遍。
|
|
@@ -473,16 +1093,21 @@ export class AsyncRunner {
|
|
|
473
1093
|
return this.inflightResume;
|
|
474
1094
|
const listed = this.store.list();
|
|
475
1095
|
const staleAfterMs = opts.staleAfterMs ?? 0;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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))
|
|
479
1104
|
.finally(() => {
|
|
480
1105
|
this.inflightResume = null;
|
|
481
1106
|
});
|
|
482
1107
|
this.inflightResume = run;
|
|
483
1108
|
return run;
|
|
484
1109
|
}
|
|
485
|
-
const dispatched = this.#redispatch(listed, staleAfterMs);
|
|
1110
|
+
const dispatched = this.#redispatch(listed, staleAfterMs, dueListed);
|
|
486
1111
|
if (isThenable(dispatched)) {
|
|
487
1112
|
const run = Promise.resolve(dispatched).finally(() => {
|
|
488
1113
|
this.inflightResume = null;
|
|
@@ -510,21 +1135,47 @@ export class AsyncRunner {
|
|
|
510
1135
|
* 落地,返回数字(`submit`/`list` 那套「同步 store 保持同步门面」的约定不变);只有
|
|
511
1136
|
* 真出现 thenable 才升级成 Promise,等所有认领落库后再统一派发。
|
|
512
1137
|
*/
|
|
513
|
-
#redispatch(recs, staleAfterMs) {
|
|
1138
|
+
#redispatch(recs, staleAfterMs, due) {
|
|
514
1139
|
const now = Date.now();
|
|
515
|
-
//
|
|
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 **不捡走续跑**(它在等人,不是
|
|
516
1151
|
// 孤儿 —— 崩溃续跑语义不适用于「等审批」),但读到它时顺手判超时:
|
|
517
1152
|
// 到点自动全拒并重派(框架补的 deny 决定先进 store,再进引擎)。
|
|
518
1153
|
let expired = 0;
|
|
519
1154
|
for (const rec of recs) {
|
|
520
|
-
if (rec.status !== '
|
|
1155
|
+
if (rec.status !== 'suspended' || !approvalExpired(rec, now, this.approvalTimeoutMs))
|
|
521
1156
|
continue;
|
|
522
1157
|
expired++;
|
|
523
1158
|
this.#expireAndResume(rec, now);
|
|
524
1159
|
}
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
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);
|
|
528
1179
|
const claims = [];
|
|
529
1180
|
for (const rec of pending) {
|
|
530
1181
|
rec.status = 'queued'; // 重新入队,由 #execute 统一推进
|
|
@@ -532,15 +1183,25 @@ export class AsyncRunner {
|
|
|
532
1183
|
const saved = this.store.save(rec);
|
|
533
1184
|
if (isThenable(saved)) {
|
|
534
1185
|
// 落库失败则**不派发**:认领没落地,派发等于把上面那个重复执行的窗口重新打开
|
|
535
|
-
claims.push(Promise.resolve(saved).then(() =>
|
|
1186
|
+
claims.push(Promise.resolve(saved).then(() => this.#dispatch(rec)));
|
|
536
1187
|
}
|
|
537
1188
|
else {
|
|
538
|
-
|
|
1189
|
+
// 派发走唯一入口(闸在 #dispatch 里)—— 外部深评 P2-1:认领循环以前绕过了那道闸。
|
|
1190
|
+
this.#dispatch(rec);
|
|
539
1191
|
}
|
|
540
1192
|
}
|
|
541
1193
|
if (claims.length === 0)
|
|
542
|
-
return pending.length + expired;
|
|
543
|
-
return Promise.all(claims).then(() => pending.length + expired);
|
|
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);
|
|
544
1205
|
}
|
|
545
1206
|
/**
|
|
546
1207
|
* 在飞计数包裹层:#executeInner 是状态机主体,这里只负责 active 计数与排空通知。
|
|
@@ -549,47 +1210,66 @@ export class AsyncRunner {
|
|
|
549
1210
|
*/
|
|
550
1211
|
async #execute(rec) {
|
|
551
1212
|
// 同步认领(在任何 await 之前):同键并发提交的第二个 submit 立刻能看见它。
|
|
552
|
-
// 挂起(
|
|
1213
|
+
// 挂起(suspended)**不释放** —— 那是「等人工」,不是终态;放了会让同键再起一个新任务。
|
|
553
1214
|
const key = rec.idempotencyKey;
|
|
554
1215
|
if (key !== undefined && !this.#claims.has(key))
|
|
555
1216
|
this.#claims.set(key, rec);
|
|
556
1217
|
// 同步开流(在任何 await 之前):`GET /tasks/:id/stream` 从这一刻起可以订阅。
|
|
557
1218
|
// 复用语义见 task-events.ts —— HITL 恢复 / 崩溃重投是**同一个任务**,序号接着走。
|
|
558
1219
|
this.#streams.open(rec.taskId);
|
|
1220
|
+
// 「本进程已受理」的起点(同步段,与 #streams.open 同处):cancel 的 409 文案据此
|
|
1221
|
+
// 区分「排队未起跑」与「真不在本进程」。终点是整个 #execute 退出的 finally。
|
|
1222
|
+
this.#executions.add(rec.taskId);
|
|
559
1223
|
this.active++;
|
|
1224
|
+
// **在等槽位**的入口(同步段,与 `active++` 同处)—— 只看 id,出集合在「拿到槽位」
|
|
1225
|
+
// 那一刻(`#executeInner`)与本节 finally 两处,`add`/`delete` 幂等所以两处都安全。
|
|
1226
|
+
// 同步段入集合是这道闸**挡得住同步突发**的前提:`#dispatch` 是 submit 的最后一行,
|
|
1227
|
+
// `void this.#execute(rec)` 的 body 到第一个 await 之前全同步 ⇒ 一个循环里连续 submit
|
|
1228
|
+
// 时,每次读到的都是已含前几条的**准确**读数。
|
|
1229
|
+
this.#waiting.add(rec.taskId);
|
|
560
1230
|
try {
|
|
561
1231
|
await this.#executeInner(rec);
|
|
562
1232
|
}
|
|
563
1233
|
finally {
|
|
1234
|
+
this.#waiting.delete(rec.taskId);
|
|
564
1235
|
// 通知在飞递减**之前**:drain() 返回时保证「任务已终态 + 回调已发完」。
|
|
565
1236
|
// 内层 finally 保证回调万一抛错(理论上被吞掉)也不泄漏在飞计数。
|
|
566
1237
|
try {
|
|
567
|
-
// HITL:挂起不是终态 —— onFinished
|
|
568
|
-
|
|
1238
|
+
// HITL:挂起不是终态 —— onFinished 的承诺是「任务达终态」,对它不开火。
|
|
1239
|
+
// 已由 `cancel` 自己收尾过的那一条也不开火(`#cancelSettled`):两处都记会触发两次
|
|
1240
|
+
if (rec.status !== 'suspended' && !this.#cancelSettled.has(rec.taskId)) {
|
|
569
1241
|
await this.#notifySinks(rec);
|
|
1242
|
+
}
|
|
570
1243
|
}
|
|
571
1244
|
finally {
|
|
572
1245
|
this.active--;
|
|
573
|
-
//
|
|
574
|
-
|
|
575
|
-
|
|
1246
|
+
// 记账的归属:cancel 收尾过的那一份已经做过(流收口 / 会话清理 / 认领释放),
|
|
1247
|
+
// 这里只消费掉标记,不重复 —— 在飞递减、等待者、排空通知照旧必达
|
|
1248
|
+
if (!this.#cancelSettled.delete(rec.taskId)) {
|
|
1249
|
+
// 终态即清理 HITL 会话回写快照(挂起则保留 —— 恢复段成功后还要用它)
|
|
1250
|
+
if (rec.status !== 'suspended')
|
|
1251
|
+
this.sessionInputs.delete(rec.taskId);
|
|
1252
|
+
// 任务流收口(挂起不算终态 —— 见 isTerminalTask 的注释:批了会接着跑,流得开着)。
|
|
1253
|
+
if (isTerminalTask(rec))
|
|
1254
|
+
this.#streams.markDone(rec.taskId);
|
|
1255
|
+
// 释放同键认领:**只有终态才释放**(挂起仍在等人,同键提交不该另起一个任务)。
|
|
1256
|
+
// 判据用 taskId 比对而非 `claimed`:HITL 的恢复段是**另一次** #execute 调用
|
|
1257
|
+
// (approve / 超时兜底 / 崩溃恢复都会重派,且异步 store 交出的常是新副本对象),
|
|
1258
|
+
// 那次 `claimed` 必为 false —— 只看 `claimed` 会让挂起过的键**永不释放**(认领表泄漏,
|
|
1259
|
+
// 同键从此永远命中那条老记录)。
|
|
1260
|
+
if (key !== undefined &&
|
|
1261
|
+
rec.status !== 'suspended' &&
|
|
1262
|
+
this.#claims.get(key)?.taskId === rec.taskId) {
|
|
1263
|
+
this.#claims.delete(key);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
576
1266
|
// 任务已达终态并落库 → 唤醒 awaitTask 的等待者(放在递减之后,语义与 drain 一致)。
|
|
577
|
-
// 挂起也唤醒:等待者看一眼状态继续等(
|
|
1267
|
+
// 挂起也唤醒:等待者看一眼状态继续等(suspended 不是终态),无副作用。
|
|
578
1268
|
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
1269
|
this.#notifyDrained();
|
|
1270
|
+
// 「本进程已受理」的终点(与 #execute 同寿)。删在最后:此前若 cancel 挤进来,
|
|
1271
|
+
// 它看到的仍是「本进程在推进这条」,文案不会突然改口。
|
|
1272
|
+
this.#executions.delete(rec.taskId);
|
|
593
1273
|
}
|
|
594
1274
|
}
|
|
595
1275
|
}
|
|
@@ -631,11 +1311,53 @@ export class AsyncRunner {
|
|
|
631
1311
|
return;
|
|
632
1312
|
}
|
|
633
1313
|
}
|
|
1314
|
+
// 「出排队段」必须与「占槽」落在**同一次同步执行**里:`await acquire()` 的连续体要等
|
|
1315
|
+
// 一次微任务,而 `acquire` 内部的 `running++` 是同步的 —— 同一条任务在那一瞬被算两遍
|
|
1316
|
+
// ⇒ 排队深度虚高一格(`concurrency: 1` + `maxQueued: 1` 下第 2 条合法提交被误拒)。
|
|
1317
|
+
// 所以:**有空位就趁 await 之前出集合**(判据是快照 —— 判断与 acquire 之间没有 await,
|
|
1318
|
+
// 池子状态不会变);排队等到的那些,则在 acquire 兑现后出集合(同一个 delete,幂等)。
|
|
1319
|
+
// ⚠️ 刻意**不**把 acquire 换成同步快路径:那会顺带少掉一次微任务,把「置 running」
|
|
1320
|
+
// 相对引擎推进的时刻提前,踩到既有用例里「等到 running 就当工具已挂在飞」的隐含假设
|
|
1321
|
+
// (`tests/transport/cancel.test.ts` 的前置断言当场红)。记账改原子,时序一个字不改。
|
|
1322
|
+
if (this.#slots.inUse < this.concurrency)
|
|
1323
|
+
this.#waiting.delete(rec.taskId);
|
|
634
1324
|
await this.#slots.acquire();
|
|
1325
|
+
this.#waiting.delete(rec.taskId); // 拿到槽位 = 离开排队段(此后归 concurrency 管)
|
|
635
1326
|
try {
|
|
1327
|
+
// 认领前**重读一遍**再判(异步 store 交出的是副本:拿 submit 时那个对象判不出
|
|
1328
|
+
// 「排队期间被取消了没有」)——与 `#wakeDueInner` 的「进闸后重读」同因。
|
|
1329
|
+
// 不判的后果:cancel 只把状态改了,任务**照跑**(记录说 cancelled、副作用真发生 ——
|
|
1330
|
+
// 最坏的一种谎)。
|
|
1331
|
+
// ⚠️ 判据**只挡终态**,别写成 `!== 'queued'`:恢复那几条路(approve / 到期唤醒 /
|
|
1332
|
+
// 崩溃重投)都**先**把状态置成 `running` 再派发 —— 那样写会把它们全挡死,
|
|
1333
|
+
// 症状是「恢复了但任务没跑」(本实现第一版就是这么错的,transport 套件当场挂住)。
|
|
1334
|
+
// ⚠️ 读失败**不改变既有行为**:这道闸只负责「明知已终态就别跑」,不负责把
|
|
1335
|
+
// store 读取故障升级成「任务起不来」(停机窗口里 store 已 close 是既有场景,
|
|
1336
|
+
// 那条路径的行为由 scheduler 的用例钉着)。
|
|
1337
|
+
let claimed;
|
|
1338
|
+
try {
|
|
1339
|
+
claimed = (await this.store.get(rec.taskId)) ?? rec;
|
|
1340
|
+
}
|
|
1341
|
+
catch {
|
|
1342
|
+
claimed = undefined;
|
|
1343
|
+
}
|
|
1344
|
+
if (claimed && isTerminalTask(claimed)) {
|
|
1345
|
+
// 与 store 对齐再返回:外层 finally 会拿 `rec` 落一次 outcome,
|
|
1346
|
+
// 不同步的话会用**陈旧副本**把取消覆盖回 queued(任务从此既不起跑也不终态)。
|
|
1347
|
+
// 记账(sinks / 事件流 / 认领释放 / 等待者)交给**还活着**的那次 #execute 的 finally
|
|
1348
|
+
// ——它正是此刻在等槽位的这一条。
|
|
1349
|
+
Object.assign(rec, claimed);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
// 在飞句柄**先**登记(在任何 await 与状态写之前):cancel 的中断窗口从这里闭合
|
|
1353
|
+
this.#runAborts.set(rec.taskId, new AbortController());
|
|
636
1354
|
rec.status = 'running';
|
|
637
1355
|
rec.startedAt = Date.now();
|
|
638
1356
|
await this.store.save(rec);
|
|
1357
|
+
// 取消意图在收尾判定里要用两次:正常出口(`#cancels.has`)与**异常出口**
|
|
1358
|
+
// (拒绝式认 signal 的宿主 —— abort 后 reject,包 fetch 类客户端的常见写法)。
|
|
1359
|
+
// ⚠️ 必须声明在 try **之外**:`let` 是块级作用域,写在 try 里 catch 看不到它。
|
|
1360
|
+
let cancelIntent = false;
|
|
639
1361
|
try {
|
|
640
1362
|
// 会话注入(C4 的异步通道):sessionId 是可序列化的引用,store 实例由
|
|
641
1363
|
// runner 持有 —— 执行前在这里换成 RunAppOptions.session。resumed 记录
|
|
@@ -644,12 +1366,19 @@ export class AsyncRunner {
|
|
|
644
1366
|
if (rec.spec.options?.sessionId !== undefined && this.sessionStore === undefined) {
|
|
645
1367
|
throw new Error(`任务 ${rec.taskId} 带了 options.sessionId,但本 runner 未配 sessionStore`);
|
|
646
1368
|
}
|
|
647
|
-
// HITL
|
|
648
|
-
|
|
1369
|
+
// **续跑段**的判定(不只是 HITL:时间挂起醒来也是续跑)—— 决定会话注入、
|
|
1370
|
+
// 链路关联与会话回写三条口径的走向(见下)。判据**不能**只看 `approvals`:
|
|
1371
|
+
// timer 挂起醒来时没有任何决定(approvals 是空的),但 `rec.spec.messages` 同样是
|
|
1372
|
+
// 挂起段落库的**扩展历史**(末尾是含未决 tool_use 的 assistant)—— 那三条口径
|
|
1373
|
+
// 一字不差地适用(`suspendedSince` 是「这条记录挂起过」的痕迹:挂起时写、终态才清)。
|
|
1374
|
+
const isResume = rec.approvals !== undefined || rec.suspendedSince !== undefined;
|
|
649
1375
|
// runTimeoutMs 到点即 abort(对尊重 signal 的客户端是真中止);与调用方
|
|
650
1376
|
// 可能传入的 signal 合成,任一触发都中止本次 run。
|
|
651
|
-
|
|
652
|
-
|
|
1377
|
+
// 每任务**一个** controller,同时服务超时与取消(机制是同一条 signal,差别在意图):
|
|
1378
|
+
// 它在认领处就登记进了 #runAborts,所以 cancel 找得到它
|
|
1379
|
+
const runAc = this.#runAborts.get(rec.taskId) ?? new AbortController();
|
|
1380
|
+
this.#runAborts.set(rec.taskId, runAc);
|
|
1381
|
+
const combined = combineSignals(rec.spec.options?.signal, runAc.signal);
|
|
653
1382
|
// 记账事件 → 本任务的流(`GET /tasks/:id/stream` 的数据源)。
|
|
654
1383
|
// 与任务 spec 里可能自带的那个**叠加**而不是覆盖(composeTraceEvents 的同一份理由):
|
|
655
1384
|
// 两边都要收到 —— 覆盖会让其中一条静默失聪。
|
|
@@ -677,10 +1406,16 @@ export class AsyncRunner {
|
|
|
677
1406
|
: {}),
|
|
678
1407
|
// HITL 恢复段:审批决定随任务落库,重跑时原样进引擎(进程重启不丢)
|
|
679
1408
|
...(rec.approvals !== undefined ? { approvals: rec.approvals } : {}),
|
|
1409
|
+
// 事件投入口(2026-09-28 ⑥):挂起期间投递的事件随记录进引擎,由它在
|
|
1410
|
+
// 未决 tool_use 解决之后注入消息流(为什么不能由宿主直接追加进
|
|
1411
|
+
// spec.messages 的末尾:见 signalTask 与 engine/loop.ts 的注入点纪律)
|
|
1412
|
+
...(rec.pendingEvents !== undefined && rec.pendingEvents.length > 0
|
|
1413
|
+
? { events: rec.pendingEvents }
|
|
1414
|
+
: {}),
|
|
680
1415
|
// 恢复段的 trace 是一棵**新树**,经 traceContext link 挂到上一段 runId
|
|
681
1416
|
// (spec §9.2 的入站关联机制)——「挂起段 → 恢复段 → …」在观测后端连成一条链。
|
|
682
|
-
//
|
|
683
|
-
...(
|
|
1417
|
+
// 判定依据:本段是续跑段(isResume)且已有上一段 runId;首次执行两者皆无。
|
|
1418
|
+
...(isResume && rec.runId !== undefined
|
|
684
1419
|
? { traceContext: { traceId: rec.runId } }
|
|
685
1420
|
: {}),
|
|
686
1421
|
rethrow: false, // 硬失败也以 failed 记录落库
|
|
@@ -689,16 +1424,16 @@ export class AsyncRunner {
|
|
|
689
1424
|
...(bridgeTraceEvents !== undefined ? { onTraceEvent: bridgeTraceEvents } : {}),
|
|
690
1425
|
};
|
|
691
1426
|
try {
|
|
692
|
-
const out = await this.#raceTimeout(this.app.run(rec.spec.messages, callOpts), rec.taskId, () =>
|
|
1427
|
+
const out = await this.#raceTimeout(this.app.run(rec.spec.messages, callOpts), rec.taskId, () => runAc.abort());
|
|
693
1428
|
rec.runId = out.run.runId;
|
|
694
1429
|
rec.result = out.result;
|
|
695
1430
|
rec.error = out.result.error;
|
|
696
|
-
if (out.result.stopReason === '
|
|
1431
|
+
if (out.result.stopReason === 'suspended' && out.result.suspendedMessages) {
|
|
697
1432
|
// HITL 挂起:扩展后的消息历史(末尾是含未决 tool_use 的 assistant 消息)
|
|
698
1433
|
// 与待决清单、挂起时刻一起落库 —— approve / 惰性超时 / 重启后都靠它们。
|
|
699
1434
|
// 槽位照常释放(finally)、onFinished 不触发(#execute 的出口判断)、
|
|
700
1435
|
// finishedAt 不置(下面 finally 里按状态跳过):它不是终态。
|
|
701
|
-
rec.status = '
|
|
1436
|
+
rec.status = 'suspended';
|
|
702
1437
|
// 「本轮用户输入」快照必须在 overwrite **之前**取 —— 此刻 rec.spec.messages
|
|
703
1438
|
// 还是原始输入;恢复段成功后由 #appendResumedSession 拿它 + finalText 补写会话。
|
|
704
1439
|
// 只在首个挂起段快照(!isResume):恢复段再挂起时 spec.messages 已是
|
|
@@ -710,13 +1445,51 @@ export class AsyncRunner {
|
|
|
710
1445
|
}
|
|
711
1446
|
rec.spec = { ...rec.spec, messages: out.result.suspendedMessages };
|
|
712
1447
|
rec.pendingApprovals = out.result.pendingApprovals;
|
|
713
|
-
rec.
|
|
1448
|
+
rec.suspendedSince = Date.now();
|
|
1449
|
+
// 挂起原因(2026-09-28 ①):从结果形状里取,**不写死** ——
|
|
1450
|
+
// 写死成 'approval' 会让将来新增的挂起原因在落库这一步被悄悄改写成审批。
|
|
1451
|
+
rec.suspendedReason = out.result.suspendedReason;
|
|
1452
|
+
// 目标时刻(时间挂起才有)—— 同上,从结果形状里取,不按原因反推
|
|
1453
|
+
rec.wakeAt = out.result.wakeAt;
|
|
1454
|
+
// 挂起读数(纪律①):与落库**同一个分支**登记,不另起一处判断
|
|
1455
|
+
this.#markSuspended(rec);
|
|
1456
|
+
// 事件簿记(2026-09-28 ⑥ 复审收口):**注入过**就清 —— 事件已在挂起历史里
|
|
1457
|
+
// (suspendedMessages 含注入的 user 消息),簿记留着会让下次续跑**重复注入**
|
|
1458
|
+
// (真探针实证过:模型看到同一事件两次)。没注入(续跑入口的再挂起出口在
|
|
1459
|
+
// 注入点之前返回)则必须留住 —— 清掉就是丢事件。判据是结果上的
|
|
1460
|
+
// `eventsDelivered` 位,不是「是不是挂起」这个粗粒度。
|
|
1461
|
+
if (out.result.eventsDelivered)
|
|
1462
|
+
rec.pendingEvents = undefined;
|
|
714
1463
|
}
|
|
715
1464
|
else {
|
|
716
|
-
|
|
1465
|
+
// 取消:机制与 `runTimeoutMs` 一字不差(同一条 abort signal),**意图**不同 ——
|
|
1466
|
+
// 状态按意图落(超时 = failed、人取消 = cancelled)。意图从 `#cancels` 取,
|
|
1467
|
+
// 不从 signal 反推:同一条 signal 有三个来源(调用方 / 超时 / 取消)。
|
|
1468
|
+
const cancelled = this.#cancels.has(rec.taskId);
|
|
1469
|
+
rec.status = cancelled ? 'cancelled' : out.run.status;
|
|
1470
|
+
// 取消必须带**结构化原因**(「取消不是失败,但原因要可查」):宿主自定义的
|
|
1471
|
+
// AppCallable 若不认 signal,它照旧返回一个正常结果 ⇒ `result.error` 是空的,
|
|
1472
|
+
// 那时记录里也得说清「这条是被取消的」(与超时那条的账一类)。
|
|
1473
|
+
if (cancelled && rec.error === undefined)
|
|
1474
|
+
rec.error = abortedError();
|
|
717
1475
|
// 终态后清掉挂起痕迹(决定保留:审批记录是审计的一部分,随任务走)
|
|
718
1476
|
rec.pendingApprovals = undefined;
|
|
719
|
-
rec.
|
|
1477
|
+
rec.suspendedSince = undefined;
|
|
1478
|
+
rec.suspendedReason = undefined;
|
|
1479
|
+
rec.wakeAt = undefined;
|
|
1480
|
+
// 事件簿记(2026-09-28 ⑥):**终态没有第二次机会** —— 这条记录不会再被续跑,
|
|
1481
|
+
// 事件留着也没有注入点,清掉。
|
|
1482
|
+
// ⚠️ 判据与挂起分支**刻意不同**(复审第二轮把这里的注释改成真话,原先写的是
|
|
1483
|
+
// 「跑通到非挂起出口 ⇒ 引擎已把它们注入消息流」—— 那句在注入点**之前**就被
|
|
1484
|
+
// 中止/失败的出口上不成立):挂起分支按 `eventsDelivered` 判(注入过才清、
|
|
1485
|
+
// 没注入要留 —— 下次续跑补上),这里无条件清。
|
|
1486
|
+
// 于是「恢复段在注入之前就 abort(取消 / runTimeoutMs)或整段失败」的那些终态,
|
|
1487
|
+
// 其事件**不会**进历史 —— 既成事实,不假装「已注入」;发件方从记录的终态与
|
|
1488
|
+
// error 看得到这条 run 的下场(要重试就重新提交一次:事件不跨终态重放)。
|
|
1489
|
+
rec.pendingEvents = undefined;
|
|
1490
|
+
// 离开挂起态(读数纪律②):两处恢复路径负责「还没跑到终态」的那一半,
|
|
1491
|
+
// 这一支负责「跑到了终态」的那一半(兜底:恢复失败也会落到这里)
|
|
1492
|
+
this.#unmarkSuspended(rec.taskId);
|
|
720
1493
|
// HITL 恢复段 + 会话:本段没把 session 交给 run 层(见上面 callOpts 注释),
|
|
721
1494
|
// 会话回写由 runner 自己补 —— 口径与 run.ts 的 appendSession 一致。
|
|
722
1495
|
if (isResume && out.run.status === 'succeeded') {
|
|
@@ -728,16 +1501,29 @@ export class AsyncRunner {
|
|
|
728
1501
|
// 正常收尾(没有源中止)时主动摘除挂在各源上的监听器 —— 宿主级共享
|
|
729
1502
|
// signal 是长寿的,不摘会按任务数累积(MaxListenersExceededWarning)
|
|
730
1503
|
releaseCombinedSignal(combined);
|
|
1504
|
+
// 在飞句柄与取消意图一起摘掉:意图只在**这次 run** 的收尾判定里有用(见下面那处)。
|
|
1505
|
+
// ⚠️ 意图的取值要留给 catch(`Set.delete` 返回布尔):拒绝式认 signal 的宿主
|
|
1506
|
+
// 走的就是 catch —— 在这里丢掉意图,取消就会被记成 failed(正是要治的病)。
|
|
1507
|
+
this.#runAborts.delete(rec.taskId);
|
|
1508
|
+
cancelIntent = this.#cancels.delete(rec.taskId);
|
|
731
1509
|
}
|
|
732
1510
|
}
|
|
733
1511
|
catch (e) {
|
|
734
|
-
|
|
735
|
-
|
|
1512
|
+
// 意图先到 ⇒ cancelled(与「结果说成功、取消先到」同一条「意图赢」规则)——
|
|
1513
|
+
// 覆盖「abort 后 reject」的宿主路径;无意图的异常照旧是 failed。
|
|
1514
|
+
if (cancelIntent) {
|
|
1515
|
+
rec.status = 'cancelled';
|
|
1516
|
+
rec.error = abortedError();
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
rec.error = classifyError(e);
|
|
1520
|
+
rec.status = 'failed';
|
|
1521
|
+
}
|
|
736
1522
|
}
|
|
737
1523
|
}
|
|
738
1524
|
finally {
|
|
739
1525
|
// HITL:挂起不是「完成」—— finishedAt 不置(等待中的任务没有结束时刻)
|
|
740
|
-
if (rec.status !== '
|
|
1526
|
+
if (rec.status !== 'suspended')
|
|
741
1527
|
rec.finishedAt = Date.now();
|
|
742
1528
|
// 落库失败不遮罩、槽位必须释放:释放放在内层 finally,即便落库实现抛错也必达
|
|
743
1529
|
try {
|