@feng0531/dsh-execution-bus 0.11.3 → 0.12.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/README.md +10 -3
- package/THIRD_PARTY_NOTICES.md +2 -0
- package/dist/bus.d.ts +29 -2
- package/dist/bus.js +239 -21
- package/dist/diagnostics.d.ts +2 -1
- package/dist/error-codes.d.ts +2 -0
- package/dist/error-codes.js +2 -0
- package/dist/execution.d.ts +1 -0
- package/dist/handlers.d.ts +5 -1
- package/dist/handlers.js +21 -1
- package/dist/product.d.ts +6 -0
- package/dist/product.js +11 -0
- package/dist/protocol.d.ts +11 -0
- package/docs/INTEGRATION.md +27 -0
- package/docs/PRODUCT.md +2 -0
- package/docs/protocol.md +3 -1
- package/docs/releases/0.12.0.md +20 -0
- package/docs/validation.md +4 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
“萧何”是产品与模式显示名,执行总线是其技术功能;不新增人物 Agent,不进行角色扮演。仓库 dsh-execution-bus、npm 包名、execution_bus 工具名及 execution-bus-test 预设 ID 保持不变。历史版本发布说明保留当时名称。
|
|
6
6
|
|
|
7
|
-
DSH 独立插件:`@feng0531/dsh-execution-bus` · 当前版本 **0.
|
|
7
|
+
DSH 独立插件:`@feng0531/dsh-execution-bus` · 当前版本 **0.12.0**
|
|
8
8
|
|
|
9
9
|
> 萧何模式从宿主已声明的 PTC 组合派生,保留日常工具,再叠加可选执行总线。已验证隔离的真实 Node PTC → 原 read + 总线调用,以及脚本模型内存会话;真实模型、桌面界面与实际沙箱仍未验收。**不支持重启后恢复任务。**
|
|
10
10
|
|
|
@@ -130,7 +130,7 @@ flowchart TD
|
|
|
130
130
|
2. 在你选定的隔离 DSH 配置依赖目录安装固定版本,不要在 DSH 程序安装目录执行:
|
|
131
131
|
|
|
132
132
|
```powershell
|
|
133
|
-
npm install --ignore-scripts @feng0531/dsh-execution-bus@0.
|
|
133
|
+
npm install --ignore-scripts @feng0531/dsh-execution-bus@0.12.0
|
|
134
134
|
```
|
|
135
135
|
|
|
136
136
|
这是 npm 目录示例。若目标配置使用 pnpm,应沿用其包管理器,不混用锁文件。遇到宿主 peer 版本冲突就停止,不用强制安装绕过。
|
|
@@ -205,6 +205,13 @@ PTC 是 DSH 的程序化工具调用入口。在 `run_code` 内使用 `await too
|
|
|
205
205
|
|
|
206
206
|
**跨步骤取回的正确姿势(0.11.1 补充):** 同一 `run_code` 程序内,未完成的 request 会阻塞后发工具调用的结果提交(宿主按提交顺序结算);不要在同一个程序里“启动后立刻 status 轮询”。启动步骤只发请求并尽早结束程序,下一步再用同一 `requestId` 的 `status`/`result`(或重发同一 request)附着取回——pilot 实测单次执行、返回 `reused:true`。
|
|
207
207
|
|
|
208
|
+
### 调度与互斥(0.12.0)
|
|
209
|
+
|
|
210
|
+
- **队列类别**:内置 Handler 分为 `fs`(read/glob/grep/edit/write)、`command`(pwsh)、`diagnostic`(test)。同会话内按类别优先级 + 等待老化选择:长命令不会长期压住文件/搜索类任务,等待超过阈值会逐步削平类别差距。
|
|
211
|
+
- **舱壁**:Handler 可声明 `maxConcurrency`,同一 Handler 运行数受限;`execution_bus_state` 的 `waitReason` 会显示 `concurrency`/`dispatch`/`resource`,运行超过 30 秒的任务标记 `longRunning`。
|
|
212
|
+
- **资源互斥**:`host.edit`/`host.write` 按 `file_path` 资源键在**同一总线实例内**串行,避免同文件并发写;失败/取消/关闭都会释放。它是插件内互斥,不是 OS 文件锁或跨进程保证。
|
|
213
|
+
- **唯一任务**:`host.read`/`host.glob`/`host.grep` 声明 payload 去重;同会话同输入且已有成功结果时,新 requestId 直接复用并返回 `reused:true`(失败结果不缓存,不会把失败当成功复用)。
|
|
214
|
+
|
|
208
215
|
**0.9.0 起,预算到期不再连带取消已受理执行。** `run_code` 因时间预算到期或程序结束时,宿主会中断其中的工具子调用;总线把这种中断视为**只停止等待**:已受理执行与宿主 Job 继续,程序返回预算错误后,可在后续 `run_code` 中用同一 `requestId` 的 `status`/`result` 取回结果,或用 `cancel` 取消。用户主动中断、会话取消、沙箱模式变化等其他原因仍会请求取消整个执行。该识别依赖 DSH 0.2.1-rc.2 的中断原因字符串(见[宿主兼容性](<docs/host-compatibility.md>)),不是宿主公开稳定契约;进程退出仍会丢失全部内存状态,保留期一小时。
|
|
209
216
|
|
|
210
217
|
## 如何理解任务状态?
|
|
@@ -280,7 +287,7 @@ npm run test:install # 临时目录安装,不操作日常 DSH
|
|
|
280
287
|
|
|
281
288
|
- [协议和投递语义](<docs/protocol.md>):字段、状态、重复处理与取消规则。
|
|
282
289
|
- [宿主兼容性与源码调用链](<docs/host-compatibility.md>):为什么可以等待,为什么不能跨重启恢复。
|
|
283
|
-
- [验证记录](<docs/validation.md>)、[0.
|
|
290
|
+
- [验证记录](<docs/validation.md>)、[0.12.0 发布说明](<docs/releases/0.12.0.md>):已验证与未验证范围。
|
|
284
291
|
- [同类项目源码整合](<docs/INTEGRATION.md>):固定来源、采用范围、许可与验证边界。
|
|
285
292
|
- [维护者发布流程](<docs/RELEASING.md>):标签 CI、安装包校验与正式发布门禁。
|
|
286
293
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -5,3 +5,5 @@
|
|
|
5
5
|
CI 与发布组织方式参考 dsh-vcs,并按本项目执行能力重新实现;本包不含 Monaco、React 或 SVN 实现。
|
|
6
6
|
|
|
7
7
|
0.5.0 定向参考 dsh-llm-rate-limit(MIT,hanruochong)、dsh-nightshift(MIT,contributors)和 dsh-input-traffic(MIT,contributors)的准入、生命周期和会话视图原则,按本项目契约自行实现,没有复制其源码或捆绑依赖。AsyncGate 与 cc-multi-process-mcp 仅用于边界比较,所查快照未发现 LICENSE,不复制代码。固定来源与阅读范围见 [整合说明](<docs/INTEGRATION.md>)。
|
|
8
|
+
|
|
9
|
+
0.12.0 定向阅读 Temporal(文档;SDK MIT)、Restate(文档;BUSL-1.1,未复制代码)、DBOS(文档;Transact MIT)、Oban(Apache-2.0)、BullMQ(MIT)与 Resilience4j(Apache-2.0)的公开文档/README,按本插件边界重新实现调度隔离与资源互斥,没有复制第三方源码或新增运行依赖。fast-check(MIT,dubzzz)仅作为开发依赖用于属性测试,不进入本项目 tgz。
|
package/dist/bus.d.ts
CHANGED
|
@@ -6,6 +6,14 @@ export interface AdmissionPolicy {
|
|
|
6
6
|
sessionQueueLimit?: number;
|
|
7
7
|
maxQueueWaitMs?: number;
|
|
8
8
|
}
|
|
9
|
+
export interface SchedulerPolicy {
|
|
10
|
+
/** Handler.queue → 类别优先级(数值越小越先出队)。 */
|
|
11
|
+
queueClassOrder?: Readonly<Record<string, number>>;
|
|
12
|
+
/** 等待多久提升一级(最多提升两级)。 */
|
|
13
|
+
queueAgingMs?: number;
|
|
14
|
+
/** 运行超过该阈值时在状态快照标记 longRunning。 */
|
|
15
|
+
longRunningMs?: number;
|
|
16
|
+
}
|
|
9
17
|
/** Volatile single-process bus; no acceptance durability or automatic execution retry. */
|
|
10
18
|
export declare class ExecutionBus {
|
|
11
19
|
readonly options: {
|
|
@@ -16,11 +24,17 @@ export declare class ExecutionBus {
|
|
|
16
24
|
retentionMs: number;
|
|
17
25
|
};
|
|
18
26
|
readonly admission: AdmissionPolicy;
|
|
27
|
+
readonly scheduler: Readonly<Required<SchedulerPolicy>>;
|
|
19
28
|
private entries;
|
|
20
29
|
private handlers;
|
|
21
30
|
private queue;
|
|
22
31
|
private readyOwners;
|
|
23
32
|
private running;
|
|
33
|
+
private runningByHandler;
|
|
34
|
+
/** 资源键 → 占用它的运行中 Entry;有副作用 Handler 据此互斥。 */
|
|
35
|
+
private resourceLocks;
|
|
36
|
+
/** payload 指纹 → 已成功或运行中的 Entry,用于唯一任务复用。 */
|
|
37
|
+
private dedupeIndex;
|
|
24
38
|
private retainedResultBytes;
|
|
25
39
|
private closed;
|
|
26
40
|
private telemetry;
|
|
@@ -40,6 +54,7 @@ export declare class ExecutionBus {
|
|
|
40
54
|
job_released?: number | undefined;
|
|
41
55
|
job_error?: number | undefined;
|
|
42
56
|
reused?: number | undefined;
|
|
57
|
+
unique_reused?: number | undefined;
|
|
43
58
|
};
|
|
44
59
|
traces: {
|
|
45
60
|
operation: number;
|
|
@@ -55,7 +70,7 @@ export declare class ExecutionBus {
|
|
|
55
70
|
recordLimit: number;
|
|
56
71
|
resultBytes: number;
|
|
57
72
|
retentionMs: number;
|
|
58
|
-
}, admission?: AdmissionPolicy);
|
|
73
|
+
}, admission?: AdmissionPolicy, scheduler?: Readonly<Required<SchedulerPolicy>>);
|
|
59
74
|
register(name: string, handler: Handler): () => void;
|
|
60
75
|
private key;
|
|
61
76
|
private emit;
|
|
@@ -81,6 +96,17 @@ export declare class ExecutionBus {
|
|
|
81
96
|
private admitInvocation;
|
|
82
97
|
/** 若启用了排队期限,登记到期结算为 QUEUE_WAIT_TIMEOUT 的计时器。 */
|
|
83
98
|
private scheduleQueueDeadline;
|
|
99
|
+
/** 计算并校验 Handler 的资源键;失败则请求不进入队列。 */
|
|
100
|
+
private resourcesFor;
|
|
101
|
+
/** 唯一任务命中已成功终态时,构造一个不再执行 Handler 的只读结果记录。 */
|
|
102
|
+
private syntheticUnique;
|
|
103
|
+
private releaseResources;
|
|
104
|
+
/** 是否有空闲槽位供此 Entry 开始执行(Handler 舱壁 + 资源互斥)。 */
|
|
105
|
+
private eligible;
|
|
106
|
+
/** 类别优先级 + 等待老化:分数越低越先出队,长时间等待会逐步压过类别差异。 */
|
|
107
|
+
private effectivePriority;
|
|
108
|
+
/** 对已到期的排队任务结算 QUEUE_WAIT_TIMEOUT,再从队列移除。 */
|
|
109
|
+
private expireQueued;
|
|
84
110
|
private attachment;
|
|
85
111
|
command(owner: Owner, raw: unknown): RecordView;
|
|
86
112
|
private removeQueued;
|
|
@@ -104,6 +130,7 @@ export declare class ExecutionBus {
|
|
|
104
130
|
error: string | null;
|
|
105
131
|
progress: number | null;
|
|
106
132
|
waitReason: string | null;
|
|
133
|
+
longRunning: boolean;
|
|
107
134
|
phase: string;
|
|
108
135
|
queueMs: number;
|
|
109
136
|
runMs: number;
|
|
@@ -113,7 +140,7 @@ export declare class ExecutionBus {
|
|
|
113
140
|
};
|
|
114
141
|
private cancel;
|
|
115
142
|
private finish;
|
|
116
|
-
/**
|
|
143
|
+
/** 按会话轮转选择下一个可执行项:会话内按类别优先级 + 老化,跨会话公平。 */
|
|
117
144
|
private nextQueuedIndex;
|
|
118
145
|
private pump;
|
|
119
146
|
private run;
|
package/dist/bus.js
CHANGED
|
@@ -4,7 +4,7 @@ import { Diagnostics } from "./diagnostics.js";
|
|
|
4
4
|
import { ERRORS } from "./error-codes.js";
|
|
5
5
|
import { assertJson } from "./json.js";
|
|
6
6
|
import { LIMITS } from "./limits.js";
|
|
7
|
-
import { CORE_DEFAULTS } from "./product.js";
|
|
7
|
+
import { CORE_DEFAULTS, SCHEDULER_POLICY } from "./product.js";
|
|
8
8
|
import { canonical, freezeJson, object, parse } from "./protocol.js";
|
|
9
9
|
export { object, parse };
|
|
10
10
|
const isTerminal = (s) => !["queued", "running"].includes(s);
|
|
@@ -16,11 +16,17 @@ function hashPayload(payload) {
|
|
|
16
16
|
export class ExecutionBus {
|
|
17
17
|
options;
|
|
18
18
|
admission;
|
|
19
|
+
scheduler;
|
|
19
20
|
entries = new Map();
|
|
20
21
|
handlers = new Map();
|
|
21
22
|
queue = [];
|
|
22
23
|
readyOwners = [];
|
|
23
24
|
running = 0;
|
|
25
|
+
runningByHandler = new Map();
|
|
26
|
+
/** 资源键 → 占用它的运行中 Entry;有副作用 Handler 据此互斥。 */
|
|
27
|
+
resourceLocks = new Map();
|
|
28
|
+
/** payload 指纹 → 已成功或运行中的 Entry,用于唯一任务复用。 */
|
|
29
|
+
dedupeIndex = new Map();
|
|
24
30
|
retainedResultBytes = 0;
|
|
25
31
|
closed = false;
|
|
26
32
|
telemetry = new Diagnostics();
|
|
@@ -36,9 +42,36 @@ export class ExecutionBus {
|
|
|
36
42
|
}
|
|
37
43
|
closing;
|
|
38
44
|
shutdownPhase = "open";
|
|
39
|
-
constructor(options = CORE_DEFAULTS, admission = {}) {
|
|
45
|
+
constructor(options = CORE_DEFAULTS, admission = {}, scheduler = SCHEDULER_POLICY) {
|
|
40
46
|
this.options = options;
|
|
41
47
|
this.admission = admission;
|
|
48
|
+
this.scheduler = scheduler;
|
|
49
|
+
const schedulerKeys = ["queueClassOrder", "queueAgingMs", "longRunningMs"];
|
|
50
|
+
if (!scheduler ||
|
|
51
|
+
typeof scheduler !== "object" ||
|
|
52
|
+
Array.isArray(scheduler) ||
|
|
53
|
+
Reflect.ownKeys(scheduler).some((key) => typeof key !== "string" || !schedulerKeys.includes(key)))
|
|
54
|
+
throw Error(ERRORS.INVALID_OPTIONS);
|
|
55
|
+
if (!Number.isSafeInteger(scheduler.queueAgingMs) ||
|
|
56
|
+
scheduler.queueAgingMs < 1 ||
|
|
57
|
+
!Number.isSafeInteger(scheduler.longRunningMs) ||
|
|
58
|
+
scheduler.longRunningMs < 1)
|
|
59
|
+
throw Error(ERRORS.INVALID_OPTIONS);
|
|
60
|
+
const order = scheduler.queueClassOrder;
|
|
61
|
+
if (!order || typeof order !== "object" || Array.isArray(order))
|
|
62
|
+
throw Error(ERRORS.INVALID_OPTIONS);
|
|
63
|
+
for (const [className, rank] of Object.entries(order))
|
|
64
|
+
if (typeof className !== "string" ||
|
|
65
|
+
className.length === 0 ||
|
|
66
|
+
className.length > 64 ||
|
|
67
|
+
!Number.isSafeInteger(rank) ||
|
|
68
|
+
rank < 0)
|
|
69
|
+
throw Error(ERRORS.INVALID_OPTIONS);
|
|
70
|
+
this.scheduler = Object.freeze({
|
|
71
|
+
queueClassOrder: Object.freeze({ ...order }),
|
|
72
|
+
queueAgingMs: scheduler.queueAgingMs,
|
|
73
|
+
longRunningMs: scheduler.longRunningMs,
|
|
74
|
+
});
|
|
42
75
|
const allowed = ["sessionQueueLimit", "maxQueueWaitMs"];
|
|
43
76
|
if (!admission ||
|
|
44
77
|
typeof admission !== "object" ||
|
|
@@ -79,6 +112,19 @@ export class ExecutionBus {
|
|
|
79
112
|
register(name, handler) {
|
|
80
113
|
if (this.handlers.has(name))
|
|
81
114
|
throw Error(ERRORS.DUPLICATE_HANDLER);
|
|
115
|
+
if (!handler ||
|
|
116
|
+
typeof handler !== "object" ||
|
|
117
|
+
(handler.queue !== undefined &&
|
|
118
|
+
(typeof handler.queue !== "string" ||
|
|
119
|
+
handler.queue.length === 0 ||
|
|
120
|
+
handler.queue.length > 64)) ||
|
|
121
|
+
(handler.maxConcurrency !== undefined &&
|
|
122
|
+
(!Number.isSafeInteger(handler.maxConcurrency) ||
|
|
123
|
+
handler.maxConcurrency < 1)) ||
|
|
124
|
+
(handler.dedupe !== undefined && handler.dedupe !== "payload") ||
|
|
125
|
+
(handler.dedupe === "payload" && handler.idempotent !== true) ||
|
|
126
|
+
(handler.resources !== undefined && typeof handler.resources !== "function"))
|
|
127
|
+
throw Error(ERRORS.INVALID_HANDLER_OPTIONS);
|
|
82
128
|
const registration = { handler, token: Symbol(name) };
|
|
83
129
|
this.handlers.set(name, registration);
|
|
84
130
|
let disposed = false;
|
|
@@ -145,6 +191,10 @@ export class ExecutionBus {
|
|
|
145
191
|
this.retainedResultBytes -= e.resultBytes;
|
|
146
192
|
this.entries.delete(k);
|
|
147
193
|
}
|
|
194
|
+
const live = new Set(this.entries.values());
|
|
195
|
+
for (const [key, e] of this.dedupeIndex)
|
|
196
|
+
if (!live.has(e))
|
|
197
|
+
this.dedupeIndex.delete(key);
|
|
148
198
|
}
|
|
149
199
|
submit(owner, raw, onEvent, invoke) {
|
|
150
200
|
if (this.closed)
|
|
@@ -199,6 +249,26 @@ export class ExecutionBus {
|
|
|
199
249
|
if (!handler)
|
|
200
250
|
throw Error(ERRORS.UNKNOWN_HANDLER);
|
|
201
251
|
handler.validate(parts.input);
|
|
252
|
+
const dedupeKey = handler.dedupe === "payload"
|
|
253
|
+
? JSON.stringify([owner.sessionId, parts.handler, fingerprint])
|
|
254
|
+
: undefined;
|
|
255
|
+
if (dedupeKey !== undefined) {
|
|
256
|
+
const target = this.dedupeIndex.get(dedupeKey);
|
|
257
|
+
if (target !== undefined &&
|
|
258
|
+
target.view.state === "succeeded" &&
|
|
259
|
+
isTerminal(target.view.state)) {
|
|
260
|
+
try {
|
|
261
|
+
const unique = this.syntheticUnique(owner, m, parts, key, fingerprint, handler, target, dedupeKey);
|
|
262
|
+
return this.attachment(unique, onEvent, m.messageId, true);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (error instanceof Error && error.message === ERRORS.BACKPRESSURE)
|
|
266
|
+
throw error;
|
|
267
|
+
// 结果不可克隆时退回正常执行,避免把唯一缓存错误变成请求失败。
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const resources = this.resourcesFor(handler, parts.input);
|
|
202
272
|
if (this.entries.size >= this.options.recordLimit ||
|
|
203
273
|
this.queue.length >= this.options.queueLimit)
|
|
204
274
|
throw Error(ERRORS.BACKPRESSURE);
|
|
@@ -216,6 +286,10 @@ export class ExecutionBus {
|
|
|
216
286
|
owner: owner.sessionId,
|
|
217
287
|
fingerprint,
|
|
218
288
|
handler,
|
|
289
|
+
handlerName: parts.handler,
|
|
290
|
+
queueClass: handler.queue ?? "default",
|
|
291
|
+
resources,
|
|
292
|
+
dedupeKey,
|
|
219
293
|
input: structuredClone(parts.input),
|
|
220
294
|
view: {
|
|
221
295
|
requestId: m.requestId,
|
|
@@ -235,6 +309,8 @@ export class ExecutionBus {
|
|
|
235
309
|
messages: new Map([[m.messageId, canonical(m)]]),
|
|
236
310
|
};
|
|
237
311
|
this.entries.set(key, e);
|
|
312
|
+
if (dedupeKey !== undefined)
|
|
313
|
+
this.dedupeIndex.set(dedupeKey, e);
|
|
238
314
|
this.telemetry.record(e.trace, "accepted");
|
|
239
315
|
const attached = this.attachment(e, onEvent, m.messageId);
|
|
240
316
|
this.queue.push(e);
|
|
@@ -262,6 +338,112 @@ export class ExecutionBus {
|
|
|
262
338
|
};
|
|
263
339
|
e.queueTimer = setTimeout(expire, this.admission.maxQueueWaitMs);
|
|
264
340
|
}
|
|
341
|
+
/** 计算并校验 Handler 的资源键;失败则请求不进入队列。 */
|
|
342
|
+
resourcesFor(handler, input) {
|
|
343
|
+
if (handler.resources === undefined)
|
|
344
|
+
return [];
|
|
345
|
+
let raw;
|
|
346
|
+
try {
|
|
347
|
+
raw = handler.resources(input);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
throw Error(ERRORS.INVALID_RESOURCES);
|
|
351
|
+
}
|
|
352
|
+
if (!Array.isArray(raw) || raw.length > 8)
|
|
353
|
+
throw Error(ERRORS.INVALID_RESOURCES);
|
|
354
|
+
const resources = [];
|
|
355
|
+
for (const resource of raw) {
|
|
356
|
+
if (typeof resource !== "string" ||
|
|
357
|
+
resource.length === 0 ||
|
|
358
|
+
resource.length > 256)
|
|
359
|
+
throw Error(ERRORS.INVALID_RESOURCES);
|
|
360
|
+
if (!resources.includes(resource))
|
|
361
|
+
resources.push(resource);
|
|
362
|
+
}
|
|
363
|
+
resources.sort();
|
|
364
|
+
return resources;
|
|
365
|
+
}
|
|
366
|
+
/** 唯一任务命中已成功终态时,构造一个不再执行 Handler 的只读结果记录。 */
|
|
367
|
+
syntheticUnique(owner, m, parts, key, fingerprint, handler, target, dedupeKey) {
|
|
368
|
+
if (this.entries.size >= this.options.recordLimit)
|
|
369
|
+
throw Error(ERRORS.BACKPRESSURE);
|
|
370
|
+
const snapshot = freezeJson(structuredClone({
|
|
371
|
+
requestId: m.requestId,
|
|
372
|
+
state: "succeeded",
|
|
373
|
+
sequence: 1,
|
|
374
|
+
cancelRequested: false,
|
|
375
|
+
delivery: "pending",
|
|
376
|
+
result: target.view.result,
|
|
377
|
+
}));
|
|
378
|
+
let resolve;
|
|
379
|
+
const done = new Promise((r) => (resolve = r));
|
|
380
|
+
const e = {
|
|
381
|
+
trace: this.telemetry.begin(),
|
|
382
|
+
resultBytes: 0,
|
|
383
|
+
invoke: undefined,
|
|
384
|
+
correlationId: m.messageId,
|
|
385
|
+
owner: owner.sessionId,
|
|
386
|
+
fingerprint,
|
|
387
|
+
handler,
|
|
388
|
+
handlerName: parts.handler,
|
|
389
|
+
queueClass: handler.queue ?? "default",
|
|
390
|
+
resources: [],
|
|
391
|
+
dedupeKey,
|
|
392
|
+
input: structuredClone(parts.input),
|
|
393
|
+
view: { ...snapshot, sequence: 1 },
|
|
394
|
+
controller: new AbortController(),
|
|
395
|
+
done,
|
|
396
|
+
resolve,
|
|
397
|
+
listeners: new Set(),
|
|
398
|
+
notifications: [],
|
|
399
|
+
dispatching: false,
|
|
400
|
+
sourceSequence: 0,
|
|
401
|
+
created: Date.now(),
|
|
402
|
+
messages: new Map([[m.messageId, canonical(m)]]),
|
|
403
|
+
};
|
|
404
|
+
this.entries.set(key, e);
|
|
405
|
+
this.telemetry.record(e.trace, "accepted");
|
|
406
|
+
this.telemetry.record(e.trace, "unique_reused");
|
|
407
|
+
e.resolve(snapshot);
|
|
408
|
+
return e;
|
|
409
|
+
}
|
|
410
|
+
releaseResources(e) {
|
|
411
|
+
for (const resource of e.resources)
|
|
412
|
+
if (this.resourceLocks.get(resource) === e)
|
|
413
|
+
this.resourceLocks.delete(resource);
|
|
414
|
+
}
|
|
415
|
+
/** 是否有空闲槽位供此 Entry 开始执行(Handler 舱壁 + 资源互斥)。 */
|
|
416
|
+
eligible(e, now) {
|
|
417
|
+
void now;
|
|
418
|
+
const limit = e.handler.maxConcurrency;
|
|
419
|
+
if (limit !== undefined &&
|
|
420
|
+
(this.runningByHandler.get(e.handler) ?? 0) >= limit)
|
|
421
|
+
return false;
|
|
422
|
+
for (const resource of e.resources)
|
|
423
|
+
if (this.resourceLocks.has(resource))
|
|
424
|
+
return false;
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
/** 类别优先级 + 等待老化:分数越低越先出队,长时间等待会逐步压过类别差异。 */
|
|
428
|
+
effectivePriority(e, now) {
|
|
429
|
+
const rank = this.scheduler.queueClassOrder[e.queueClass] ?? 3;
|
|
430
|
+
const boost = Math.min(2, Math.floor(Math.max(0, now - e.trace.at) / this.scheduler.queueAgingMs));
|
|
431
|
+
// 老化只削平类别差距,不把优先级推到负值;同分时按队列先后(FIFO)决出。
|
|
432
|
+
return Math.max(0, rank - boost);
|
|
433
|
+
}
|
|
434
|
+
/** 对已到期的排队任务结算 QUEUE_WAIT_TIMEOUT,再从队列移除。 */
|
|
435
|
+
expireQueued(now) {
|
|
436
|
+
for (const e of [...this.queue]) {
|
|
437
|
+
if (e.view.state !== "queued") {
|
|
438
|
+
this.removeQueued(e);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (e.deadline !== undefined && now >= e.deadline) {
|
|
442
|
+
this.removeQueued(e);
|
|
443
|
+
this.finish(e, "failed", undefined, "QUEUE_WAIT_TIMEOUT");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
265
447
|
attachment(e, onEvent, id, reused = false) {
|
|
266
448
|
// 每次附着独立占配额,即使调用方复用同一个回调。
|
|
267
449
|
const listener = onEvent ? (event) => onEvent(event) : undefined;
|
|
@@ -349,8 +531,14 @@ export class ExecutionBus {
|
|
|
349
531
|
waitReason: e.view.state === "queued"
|
|
350
532
|
? this.running >= this.options.concurrency
|
|
351
533
|
? "concurrency"
|
|
352
|
-
:
|
|
534
|
+
: this.eligible(e, now)
|
|
535
|
+
? "dispatch"
|
|
536
|
+
: "resource"
|
|
353
537
|
: null,
|
|
538
|
+
longRunning: e.view.state === "running" &&
|
|
539
|
+
e.startedAt !== undefined &&
|
|
540
|
+
(e.finishedAt ?? now) - e.startedAt >=
|
|
541
|
+
this.scheduler.longRunningMs,
|
|
354
542
|
phase: isTerminal(e.view.state)
|
|
355
543
|
? e.view.delivery === "returned"
|
|
356
544
|
? "returned"
|
|
@@ -393,6 +581,11 @@ export class ExecutionBus {
|
|
|
393
581
|
finish(e, state, result, error, resultBytes = 0) {
|
|
394
582
|
if (isTerminal(e.view.state))
|
|
395
583
|
return;
|
|
584
|
+
this.releaseResources(e);
|
|
585
|
+
if (e.dedupeKey !== undefined &&
|
|
586
|
+
this.dedupeIndex.get(e.dedupeKey) === e &&
|
|
587
|
+
state !== "succeeded")
|
|
588
|
+
this.dedupeIndex.delete(e.dedupeKey);
|
|
396
589
|
if (e.queueTimer !== undefined) {
|
|
397
590
|
clearTimeout(e.queueTimer);
|
|
398
591
|
e.queueTimer = undefined;
|
|
@@ -435,44 +628,66 @@ export class ExecutionBus {
|
|
|
435
628
|
if (!e.dispatching)
|
|
436
629
|
e.listeners.clear();
|
|
437
630
|
}
|
|
438
|
-
/**
|
|
439
|
-
nextQueuedIndex() {
|
|
440
|
-
|
|
441
|
-
|
|
631
|
+
/** 按会话轮转选择下一个可执行项:会话内按类别优先级 + 老化,跨会话公平。 */
|
|
632
|
+
nextQueuedIndex(now) {
|
|
633
|
+
const owners = this.readyOwners.length;
|
|
634
|
+
for (let n = 0; n < owners; n++) {
|
|
442
635
|
const owner = this.readyOwners.shift();
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (
|
|
636
|
+
let chosen = -1;
|
|
637
|
+
let chosenScore = Infinity;
|
|
638
|
+
for (let i = 0; i < this.queue.length; i++) {
|
|
639
|
+
const e = this.queue[i];
|
|
640
|
+
if (e.owner !== owner || !this.eligible(e, now))
|
|
641
|
+
continue;
|
|
642
|
+
const score = this.effectivePriority(e, now);
|
|
643
|
+
if (score < chosenScore) {
|
|
644
|
+
chosen = i;
|
|
645
|
+
chosenScore = score;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (chosen < 0) {
|
|
649
|
+
// 该会话暂时全部被舱壁/资源阻塞;保留轮次,等释放后重新争取。
|
|
650
|
+
if (this.queue.some((entry) => entry.owner === owner))
|
|
448
651
|
this.readyOwners.push(owner);
|
|
652
|
+
continue;
|
|
449
653
|
}
|
|
654
|
+
if (this.queue.some((entry, i) => i !== chosen && entry.owner === owner))
|
|
655
|
+
this.readyOwners.push(owner);
|
|
656
|
+
return chosen;
|
|
450
657
|
}
|
|
451
|
-
return
|
|
658
|
+
return -1;
|
|
452
659
|
}
|
|
453
660
|
pump() {
|
|
454
|
-
while (!this.closed
|
|
455
|
-
|
|
456
|
-
this.
|
|
457
|
-
|
|
661
|
+
while (!this.closed) {
|
|
662
|
+
const now = performance.now();
|
|
663
|
+
this.expireQueued(now);
|
|
664
|
+
if (this.running >= this.options.concurrency || this.queue.length === 0)
|
|
665
|
+
return;
|
|
666
|
+
const index = this.nextQueuedIndex(now);
|
|
667
|
+
if (index < 0)
|
|
668
|
+
return;
|
|
458
669
|
const e = this.queue.splice(index, 1)[0];
|
|
459
670
|
if (e.view.state !== "queued")
|
|
460
671
|
continue;
|
|
461
|
-
if (e.deadline !== undefined && performance.now() >= e.deadline) {
|
|
462
|
-
this.finish(e, "failed", undefined, "QUEUE_WAIT_TIMEOUT");
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
672
|
if (e.queueTimer !== undefined) {
|
|
466
673
|
clearTimeout(e.queueTimer);
|
|
467
674
|
e.queueTimer = undefined;
|
|
468
675
|
}
|
|
469
676
|
e.startedAt = performance.now();
|
|
470
677
|
this.running++;
|
|
678
|
+
this.runningByHandler.set(e.handler, (this.runningByHandler.get(e.handler) ?? 0) + 1);
|
|
679
|
+
for (const resource of e.resources)
|
|
680
|
+
this.resourceLocks.set(resource, e);
|
|
471
681
|
e.view.state = "running";
|
|
472
682
|
this.telemetry.record(e.trace, "started");
|
|
473
683
|
this.emit(e, "started", {});
|
|
474
684
|
void this.run(e).finally(() => {
|
|
475
685
|
this.running--;
|
|
686
|
+
const remaining = (this.runningByHandler.get(e.handler) ?? 1) - 1;
|
|
687
|
+
if (remaining > 0)
|
|
688
|
+
this.runningByHandler.set(e.handler, remaining);
|
|
689
|
+
else
|
|
690
|
+
this.runningByHandler.delete(e.handler);
|
|
476
691
|
this.pump();
|
|
477
692
|
});
|
|
478
693
|
}
|
|
@@ -545,6 +760,9 @@ export class ExecutionBus {
|
|
|
545
760
|
await Promise.all(entries.map((e) => e.done));
|
|
546
761
|
this.handlers.clear();
|
|
547
762
|
this.entries.clear();
|
|
763
|
+
this.dedupeIndex.clear();
|
|
764
|
+
this.resourceLocks.clear();
|
|
765
|
+
this.runningByHandler.clear();
|
|
548
766
|
this.retainedResultBytes = 0;
|
|
549
767
|
this.queue = [];
|
|
550
768
|
this.readyOwners = [];
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type TraceStage = "accepted" | "started" | "cancel_requested" | "terminal" | "returned" | "job_created" | "job_released" | "job_error" | "reused";
|
|
1
|
+
export type TraceStage = "accepted" | "started" | "cancel_requested" | "terminal" | "returned" | "job_created" | "job_released" | "job_error" | "reused" | "unique_reused";
|
|
2
2
|
export interface Trace {
|
|
3
3
|
operation: number;
|
|
4
4
|
stage: TraceStage;
|
|
@@ -30,6 +30,7 @@ export declare class Diagnostics {
|
|
|
30
30
|
job_released?: number | undefined;
|
|
31
31
|
job_error?: number | undefined;
|
|
32
32
|
reused?: number | undefined;
|
|
33
|
+
unique_reused?: number | undefined;
|
|
33
34
|
};
|
|
34
35
|
traces: {
|
|
35
36
|
operation: number;
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export declare const ERRORS: Readonly<{
|
|
|
38
38
|
INVALID_OPTIONS: "INVALID_OPTIONS";
|
|
39
39
|
DUPLICATE_HANDLER: "DUPLICATE_HANDLER";
|
|
40
40
|
MUTATING_REUSE_DENIED: "MUTATING_REUSE_DENIED";
|
|
41
|
+
INVALID_HANDLER_OPTIONS: "INVALID_HANDLER_OPTIONS";
|
|
42
|
+
INVALID_RESOURCES: "INVALID_RESOURCES";
|
|
41
43
|
DUPLICATE_EXECUTION_HANDLE: "DUPLICATE_EXECUTION_HANDLE";
|
|
42
44
|
INVALID_STATE_QUERY: "INVALID_STATE_QUERY";
|
|
43
45
|
HOST_READ_FAILED: "HOST_READ_FAILED";
|
package/dist/error-codes.js
CHANGED
|
@@ -41,6 +41,8 @@ export const ERRORS = Object.freeze({
|
|
|
41
41
|
INVALID_OPTIONS: "INVALID_OPTIONS",
|
|
42
42
|
DUPLICATE_HANDLER: "DUPLICATE_HANDLER",
|
|
43
43
|
MUTATING_REUSE_DENIED: "MUTATING_REUSE_DENIED",
|
|
44
|
+
INVALID_HANDLER_OPTIONS: "INVALID_HANDLER_OPTIONS",
|
|
45
|
+
INVALID_RESOURCES: "INVALID_RESOURCES",
|
|
44
46
|
// —— 宿主投影(execution / plugin) ——
|
|
45
47
|
DUPLICATE_EXECUTION_HANDLE: "DUPLICATE_EXECUTION_HANDLE",
|
|
46
48
|
INVALID_STATE_QUERY: "INVALID_STATE_QUERY",
|
package/dist/execution.d.ts
CHANGED
package/dist/handlers.d.ts
CHANGED
|
@@ -5,9 +5,13 @@ export declare function hostToolHandler(toolName: string, opts: {
|
|
|
5
5
|
idempotent: boolean;
|
|
6
6
|
validate(input: Record<string, unknown>): void;
|
|
7
7
|
prepare?(input: Record<string, unknown>): Record<string, unknown>;
|
|
8
|
+
queue?: string;
|
|
9
|
+
maxConcurrency?: number;
|
|
10
|
+
resources?(input: Record<string, unknown>): readonly string[];
|
|
11
|
+
dedupe?: "payload";
|
|
8
12
|
}): Handler;
|
|
9
13
|
export declare const testHandler: Handler;
|
|
10
|
-
/**
|
|
14
|
+
/** 读取:无副作用,幂等,终态结果可复用;可按 payload 指纹做唯一任务。 */
|
|
11
15
|
export declare const readHandler: Handler;
|
|
12
16
|
/** 文件名搜索:只读,幂等,终态结果可复用;搜索基数由宿主工具限制。 */
|
|
13
17
|
export declare const globHandler: Handler;
|
package/dist/handlers.js
CHANGED
|
@@ -8,6 +8,10 @@ export function hostToolHandler(toolName, opts) {
|
|
|
8
8
|
void toolName;
|
|
9
9
|
return {
|
|
10
10
|
idempotent: opts.idempotent,
|
|
11
|
+
queue: opts.queue,
|
|
12
|
+
maxConcurrency: opts.maxConcurrency,
|
|
13
|
+
resources: opts.resources,
|
|
14
|
+
dedupe: opts.dedupe,
|
|
11
15
|
validate: opts.validate,
|
|
12
16
|
async run(input, ctx) {
|
|
13
17
|
if (!ctx.invoke)
|
|
@@ -18,6 +22,7 @@ export function hostToolHandler(toolName, opts) {
|
|
|
18
22
|
}
|
|
19
23
|
export const testHandler = {
|
|
20
24
|
idempotent: true,
|
|
25
|
+
queue: "diagnostic",
|
|
21
26
|
validate(input) {
|
|
22
27
|
if (Object.keys(input).some((k) => !["delayMs", "fail"].includes(k)) ||
|
|
23
28
|
!Number.isSafeInteger(input.delayMs) ||
|
|
@@ -61,9 +66,15 @@ function validateEdit(input) {
|
|
|
61
66
|
(input.replace_all !== undefined && typeof input.replace_all !== "boolean"))
|
|
62
67
|
throw Error(ERRORS.INVALID_EDIT_INPUT);
|
|
63
68
|
}
|
|
64
|
-
/**
|
|
69
|
+
/** 同一路径的文件写入/编辑互斥;路径按调用原文作为资源键(不做跨平台归一化)。 */
|
|
70
|
+
function fileResource(input) {
|
|
71
|
+
return ["file:" + String(input.file_path)];
|
|
72
|
+
}
|
|
73
|
+
/** 读取:无副作用,幂等,终态结果可复用;可按 payload 指纹做唯一任务。 */
|
|
65
74
|
export const readHandler = hostToolHandler("read", {
|
|
66
75
|
idempotent: true,
|
|
76
|
+
queue: "fs",
|
|
77
|
+
dedupe: "payload",
|
|
67
78
|
validate: validateRead,
|
|
68
79
|
prepare: (input) => ({
|
|
69
80
|
...input,
|
|
@@ -81,6 +92,8 @@ function validateGlob(input) {
|
|
|
81
92
|
/** 文件名搜索:只读,幂等,终态结果可复用;搜索基数由宿主工具限制。 */
|
|
82
93
|
export const globHandler = hostToolHandler("glob", {
|
|
83
94
|
idempotent: true,
|
|
95
|
+
queue: "fs",
|
|
96
|
+
dedupe: "payload",
|
|
84
97
|
validate: validateGlob,
|
|
85
98
|
});
|
|
86
99
|
function validateGrep(input) {
|
|
@@ -111,6 +124,8 @@ function validateGrep(input) {
|
|
|
111
124
|
/** 内容搜索:只读,幂等,终态结果可复用;匹配上限由宿主工具限制。 */
|
|
112
125
|
export const grepHandler = hostToolHandler("grep", {
|
|
113
126
|
idempotent: true,
|
|
127
|
+
queue: "fs",
|
|
128
|
+
dedupe: "payload",
|
|
114
129
|
validate: validateGrep,
|
|
115
130
|
});
|
|
116
131
|
function validateWrite(input) {
|
|
@@ -124,11 +139,15 @@ function validateWrite(input) {
|
|
|
124
139
|
/** 写入:有副作用,非幂等,终态结果不可复用。 */
|
|
125
140
|
export const writeHandler = hostToolHandler("write", {
|
|
126
141
|
idempotent: false,
|
|
142
|
+
queue: "fs",
|
|
143
|
+
resources: fileResource,
|
|
127
144
|
validate: validateWrite,
|
|
128
145
|
});
|
|
129
146
|
/** 编辑:有副作用,非幂等,终态结果不可复用。 */
|
|
130
147
|
export const editHandler = hostToolHandler("edit", {
|
|
131
148
|
idempotent: false,
|
|
149
|
+
queue: "fs",
|
|
150
|
+
resources: fileResource,
|
|
132
151
|
validate: validateEdit,
|
|
133
152
|
});
|
|
134
153
|
/** 与宿主 pwsh 工具一致的提权目标;沙箱组合未提供提权面时由宿主拒绝。 */
|
|
@@ -170,5 +189,6 @@ function validatePwsh(input) {
|
|
|
170
189
|
/** 命令执行:有副作用,非幂等,终态结果不可复用;后台模式返回 Job ID 不等于完成。 */
|
|
171
190
|
export const pwshHandler = hostToolHandler("pwsh", {
|
|
172
191
|
idempotent: false,
|
|
192
|
+
queue: "command",
|
|
173
193
|
validate: validatePwsh,
|
|
174
194
|
});
|
package/dist/product.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export declare const HANDLER_LIMITS: Readonly<{
|
|
|
23
23
|
readPathChars: 2048;
|
|
24
24
|
diagnosticDelayMs: 30000;
|
|
25
25
|
}>;
|
|
26
|
+
/** 调度策略:类别优先级 + 等待老化 + 长任务诊断阈值。 */
|
|
27
|
+
export declare const SCHEDULER_POLICY: Readonly<{
|
|
28
|
+
longRunningMs: 30000;
|
|
29
|
+
queueAgingMs: 30000;
|
|
30
|
+
queueClassOrder: Readonly<Record<string, number>>;
|
|
31
|
+
}>;
|
|
26
32
|
export declare const REQUEST_PARAMETERS: {
|
|
27
33
|
readonly oneOf: readonly [...{
|
|
28
34
|
type: "object";
|
package/dist/product.js
CHANGED
|
@@ -23,6 +23,17 @@ export const HANDLER_LIMITS = Object.freeze({
|
|
|
23
23
|
readPathChars: 2048,
|
|
24
24
|
diagnosticDelayMs: 30000,
|
|
25
25
|
});
|
|
26
|
+
/** 调度策略:类别优先级 + 等待老化 + 长任务诊断阈值。 */
|
|
27
|
+
export const SCHEDULER_POLICY = Object.freeze({
|
|
28
|
+
longRunningMs: 30000,
|
|
29
|
+
queueAgingMs: 30000,
|
|
30
|
+
queueClassOrder: Object.freeze({
|
|
31
|
+
fs: 0,
|
|
32
|
+
diagnostic: 1,
|
|
33
|
+
command: 2,
|
|
34
|
+
default: 3,
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
26
37
|
/** 每个 Handler 的模型可见输入声明。只用于工具 schema/SDK 生成;运行时仍由 Handler.validate 严格校验。 */
|
|
27
38
|
const INPUT_SCHEMAS = {
|
|
28
39
|
test: {
|
package/dist/protocol.d.ts
CHANGED
|
@@ -24,6 +24,17 @@ export interface Handler {
|
|
|
24
24
|
/** 是否可安全复用终态结果。省略默认 true(可复用,向后兼容);
|
|
25
25
|
* 显式 false = 有副作用,终态结果复用将被拒绝(运行中等待仍放行)。 */
|
|
26
26
|
readonly idempotent?: boolean;
|
|
27
|
+
/** 调度队列类别;默认按 "default"。不同类别之间按类别优先级 + 等待老化排序,
|
|
28
|
+
* 避免长任务类独占出队顺序。 */
|
|
29
|
+
readonly queue?: string;
|
|
30
|
+
/** 同一 Handler 同时在跑的上限;省略表示只受全局并发限制(舱壁)。 */
|
|
31
|
+
readonly maxConcurrency?: number;
|
|
32
|
+
/** 可选资源键(如文件路径):同键请求在总线内互斥,避免并发写同一资源。
|
|
33
|
+
* 由 Handler 在执行前从输入推导;返回空数组表示不占用资源。 */
|
|
34
|
+
resources?(input: Record<string, unknown>): readonly string[];
|
|
35
|
+
/** 唯一任务策略:"payload" 表示幂等只读 Handler 可按 handler+input 指纹复用
|
|
36
|
+
* 已成功的终态结果(不同 requestId);省略表示只按 requestId 去重。 */
|
|
37
|
+
readonly dedupe?: "payload";
|
|
27
38
|
validate(input: Record<string, unknown>): void;
|
|
28
39
|
run(input: Record<string, unknown>, ctx: {
|
|
29
40
|
signal: AbortSignal;
|
package/docs/INTEGRATION.md
CHANGED
|
@@ -21,6 +21,33 @@
|
|
|
21
21
|
| [cc-multi-process-mcp / 234823f1](https://github.com/L-x-C/cc-multi-process-mcp/tree/234823f15616d7e9c759072e940396d939f823c2) | main.py 的进程检测、信号回收、同步/异步执行、结果文件和清理路径 | 不引入自建进程池或轮询。源码的 skip_permissions 默认和 /tmp 存储不适合本插件宿主边界;未发现 LICENSE 文件,不复制代码 |
|
|
22
22
|
|
|
23
23
|
这是相关路径的定向阅读,不是五仓全量逐文件安全审核。参考快照在开发目录,明确忽略,不进入源码提交或 npm 包。
|
|
24
|
+
## 0.12.0 追加整合:调度隔离、资源互斥、唯一任务与属性测试
|
|
25
|
+
|
|
26
|
+
本轮以 0.11.3 为基线,读取以下公开文档/README(2026-10-18,文档站点与仓库链接;未复制源码,未新增运行依赖),只采用与插件边界相符的机制:
|
|
27
|
+
|
|
28
|
+
| 来源 | 实际阅读内容 | 采用与不采用 |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| [Temporal Workflow Id / Reuse Policy](https://docs.temporal.io/workflow-execution/workflowid-runid) | Workflow Id 唯一性、Run Id 区分、关闭后复用策略 | 参考“稳定逻辑编号 + 复用策略”分层;不引入持久化运行历史 |
|
|
31
|
+
| [Restate 文档](https://docs.restate.dev/)(BUSL-1.1,仅文档) | exactly-once、按 scope 的并发/流量控制、virtual object 串行化 | 参考“按类别/资源隔离并发”;不复制代码,不引入其运行时 |
|
|
32
|
+
| [DBOS 文档](https://docs.dbos.dev/) | durable queues、exactly-once、事务入队 | 仅对照边界;无持久化宿主契约,不实现 |
|
|
33
|
+
| [Oban 文档](https://oban.hexdocs.pm/Oban.html)(Apache-2.0) | 队列独立并发上限、唯一任务、10 级优先级 | 实现 Handler.queue 类别 + 优先级 + 等待老化;不引入数据库/自动重试 |
|
|
34
|
+
| [BullMQ README](https://github.com/taskforcesh/bullmq)(MIT) | priorities、concurrency、rate limiter、stalled jobs | 参考优先级/舱壁;不引入 Redis/停滞重排 |
|
|
35
|
+
| [Resilience4j README](https://github.com/resilience4j/resilience4j)(Apache-2.0) | Bulkhead、TimeLimiter | 实现 Handler.maxConcurrency 舱壁;超时暂只做 longRunning 诊断 |
|
|
36
|
+
| [fast-check](https://github.com/dubzzz/fast-check)(MIT,devDependency) | 属性测试 API | 新增随机序列不变量回归;仅开发依赖,不进入 tgz |
|
|
37
|
+
|
|
38
|
+
本轮实现(0.12.0):
|
|
39
|
+
|
|
40
|
+
1. Handler 调度声明:queue、maxConcurrency、resources(input)、dedupe:"payload";register 时校验。
|
|
41
|
+
2. 调度:同会话内按类别优先级(fs < diagnostic < command < default)+ 等待老化(每 30 秒削平一级,最多两级)选择;跨会话仍按会话轮转;Handler.maxConcurrency 作为舱壁。
|
|
42
|
+
3. 资源互斥:Handler.resources 返回稳定字符串键(host.edit/host.write 使用 file:<file_path>);同一总线实例内同键串行,失败/取消/关闭释放;排队期限仍然有效。
|
|
43
|
+
4. 唯一任务:幂等 Handler 声明 dedupe:"payload"(host.read/glob/grep);同会话同 handler+payload 已有 succeeded 终态时,新 requestId 直接复用快照并带 reused:true;失败结果不缓存。
|
|
44
|
+
5. 诊断:execution_bus_state 任务增加 longRunning(30 秒阈值);waitReason 增加 resource。
|
|
45
|
+
6. 测试:新增 8 项调度/唯一/资源回归、2 项 fast-check 属性回归与 2 项插件级 Handler 回归。
|
|
46
|
+
|
|
47
|
+
不采用:Oban/BullMQ 的数据库/Redis 持久化、自动重试、停滞重排;Temporal/DBOS 的持久化执行与跨重启恢复(缺宿主契约);Restate 源码(BUSL-1.1)。资源互斥仅在单个总线实例内有效,不是 OS 文件锁或跨进程保证。
|
|
48
|
+
|
|
49
|
+
许可:本轮未复制第三方源码;fast-check 作为 devDependency 使用(MIT),不进入 tgz;Restate 文档仅作机制参考(BUSL-1.1)。
|
|
50
|
+
|
|
24
51
|
|
|
25
52
|
## 许可与实现归属
|
|
26
53
|
|
package/docs/PRODUCT.md
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
0.11.0 根据 pilot 收紧为收益导向触发:重复提交/重试去重、预算结束后继续/跨步骤取回、多等待者同一执行才用总线,时长本身不是理由;新增简化调用(requestId+handler+input,messageId 自动生成)与控制(requestId+type),完整 v1 消息仍兼容;快照在复用时带 reused:true,并新增只读的会话采用率统计脚本。
|
|
14
14
|
|
|
15
|
+
0.12.0 补充调度隔离:Handler 可声明调度类别、舱壁、资源键与 payload 唯一任务;内置写 Handler 按文件路径互斥,只读 Handler 的相同输入可在不同 requestId 间复用成功结果;状态快照增加 longRunning 与 resource 等待原因。参考来源与许可见 [整合说明](<INTEGRATION.md>),未引入第三方运行依赖;fast-check 仅用于开发属性测试。
|
|
16
|
+
|
|
15
17
|
## 入口及配置层次
|
|
16
18
|
|
|
17
19
|
| 入口 | 谁使用 | 责任 |
|
package/docs/protocol.md
CHANGED
|
@@ -110,7 +110,7 @@ core 不传配置时用默认值;显式配置必须完整包含 concurrency、
|
|
|
110
110
|
|
|
111
111
|
期限使用单调时钟,计时器及出队前均检查,超期只将 queued 结算 failed / QUEUE_WAIT_TIMEOUT,不调用 Handler、不设置 cancelRequested、不重试,沿原 done/Job 返回。开始运行、取消或关闭时清除计时器。事件循环繁忙会令通知晚于期限,但不会让超期任务出队执行。已运行任务继续遵守原执行/取消规则。
|
|
112
112
|
|
|
113
|
-
execution_bus_state 工具仅接受 {},从宿主 Agent 取 sessionId,拒绝匿名或自报身份,关闭后拒绝查询。它读取 core inspect 的不可变快照,不创建 Job、不改变消息额度;查询会做正常终态 TTL 清理。不包含输入/输出,不访问其他会话。最多 50 条、活跃优先,其后按受理次序倒序列出终态;总数未截断,truncated 提示列表截断。元数据含 requestId、state、delivery、cancelRequested、固定 error/progress、waitReason
|
|
113
|
+
execution_bus_state 工具仅接受 {},从宿主 Agent 取 sessionId,拒绝匿名或自报身份,关闭后拒绝查询。它读取 core inspect 的不可变快照,不创建 Job、不改变消息额度;查询会做正常终态 TTL 清理。不包含输入/输出,不访问其他会话。最多 50 条、活跃优先,其后按受理次序倒序列出终态;总数未截断,truncated 提示列表截断。元数据含 requestId、state、delivery、cancelRequested、固定 error/progress、waitReason(concurrency/dispatch/resource)、longRunning(运行超过 30 秒)、queueMs/runMs/remainingQueueMs。phase terminal 只表示执行终态且尚未标记返回,不代表可推断宿主 Job 清理进度。
|
|
114
114
|
|
|
115
115
|
core inspect(trustedOwner) 要求非空 sessionId,但不提供鉴权,可信身份和访问控制属于嵌入宿主。勿把 core 的任意 owner 参数直接暴露给远端。状态不是持久审计/模型消费回执,不应循环调用。
|
|
116
116
|
|
|
@@ -128,4 +128,6 @@ core 实例的 `diagnostics()` 返回只读用途的独立快照:阶段计数
|
|
|
128
128
|
|
|
129
129
|
0.11.0 起,模型可传简化输入:请求 `{requestId, handler, input, messageId?}`、控制 `{requestId, type, messageId?}`;插件在进入总线前补齐完整 v1 消息,完整 v1 消息仍被接受。返回快照在附着已有执行时追加可选 `reused:true`,只表示复用了逻辑执行,不代表外部副作用对账。
|
|
130
130
|
|
|
131
|
+
0.12.0 扩展 Handler 可选声明:`queue`(调度类别,内置 fs/diagnostic/command)、`maxConcurrency`(同一 Handler 舱壁)、`resources(input)`(资源键串行)、`dedupe:"payload"`(幂等 Handler 的唯一任务复用)。调度和资源锁只在单个总线实例内生效;资源键是调用方提供的字符串,不是 OS 文件锁或跨进程保证。唯一任务只复用 succeeded 终态,失败/取消/unknown 不缓存;命中时返回快照带 `reused:true`,且不会再次执行 Handler。
|
|
132
|
+
|
|
131
133
|
内置 test、host.read、host.glob 与 host.grep 没有副作用;host.edit、host.write 与 host.pwsh 是非幂等写/命令 Handler,通过 `Handler.idempotent=false` 显式声明,其终态结果复用被 `MUTATING_REUSE_DENIED` 拒绝,运行中的重复附着仍只等待同一执行。0.8.0 新增宿主工具输入错误码 INVALID_GLOB_INPUT、INVALID_GREP_INPUT 与 INVALID_PWSH_INPUT;协议既有错误码不改名。host.pwsh 的后台结果只是宿主 Job ID,不是完成回执。当前没有 ctx.executionBus 服务、远端传输、跨插件动态注册或外部副作用对账契约。扩展有副作用 Handler 前必须显式声明幂等并另行设计授权与崩溃对账,不能仅依赖本版请求键。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 萧何 / Xiao He 0.12.0
|
|
2
|
+
|
|
3
|
+
## 调度隔离、资源互斥与唯一任务(开源机制整合)
|
|
4
|
+
|
|
5
|
+
本版把一组调度/可靠性机制收敛进总线。参考来源、实际阅读范围与许可见 [整合说明](<../INTEGRATION.md>);本轮未复制第三方源码、未新增运行依赖。
|
|
6
|
+
|
|
7
|
+
- **Handler 调度声明**:新增可选 `queue`(类别)、`maxConcurrency`(舱壁)、`resources(input)`(资源键)、`dedupe:"payload"`(唯一任务);注册时校验(`INVALID_HANDLER_OPTIONS`/`INVALID_RESOURCES`)。
|
|
8
|
+
- **调度**:同会话内按类别优先级(fs < diagnostic < command < default)+ 等待老化(每 30 秒削平一级,最多两级)选择;跨会话仍按会话轮转;`maxConcurrency` 限制同一 Handler 并发。排队期限、取消、关闭与结果语义不变。
|
|
9
|
+
- **资源互斥**:`host.edit`/`host.write` 以 `file:<file_path>` 为资源键,在同一总线实例内串行执行;失败/取消/关闭释放资源;排队期限继续生效。**不是 OS 文件锁或跨进程保证。**
|
|
10
|
+
- **唯一任务**:`host.read`/`host.glob`/`host.grep` 声明 payload 去重;同会话同 handler+input 已有 `succeeded` 终态时,新 requestId 直接复用快照并返回 `reused:true`,不重复执行 Handler;失败/取消/unknown 不缓存。
|
|
11
|
+
- **诊断**:`execution_bus_state` 任务增加 `longRunning`(运行超过 30 秒);`waitReason` 增加 `resource`。
|
|
12
|
+
- **开发验证**:新增 8 项调度/唯一/资源回归、2 项 fast-check 属性回归与 2 项插件级 Handler 回归;全量 **122** 项测试通过。fast-check 仅 devDependency(MIT),不进入 tgz。
|
|
13
|
+
|
|
14
|
+
## 升级说明
|
|
15
|
+
|
|
16
|
+
需要 DSH 0.2.1-rc.2。wire 协议与工具调用形状不变;新增能力只在 core/Handler 层,宿主预设、工具名与既有错误码不变。默认调度对内置 Handler 生效(类别优先级与文件互斥),如需完全旧行为应自行评估 effect。不自动安装、重写日常配置或重启 DSH。
|
|
17
|
+
|
|
18
|
+
## 验证与限制
|
|
19
|
+
|
|
20
|
+
本地全量 122 项测试通过;属性测试固定种子,不是穷尽证明。调度与资源锁只在单个总线实例内;跨会话/跨进程/OS 级互斥、外部副作用对账、持久化与自动重试均未实现。真实模型、桌面 UI 与实际沙箱仍未完整验收。
|
package/docs/validation.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 验证范围与使用限制
|
|
2
2
|
|
|
3
|
-
本文说明当前版本的证据边界,不是生产环境认证。当前能力基线为 **v0.
|
|
3
|
+
本文说明当前版本的证据边界,不是生产环境认证。当前能力基线为 **v0.12.0**,在 0.5.0 的会话准入/排队、0.7.0 的宿主工具转发、0.8.0 的搜索/命令 Handler、0.9.0 的预算分离等待与 0.10.0 的采用面改造上,收紧为收益导向触发并加入简化调用、复用标记与本地采用率统计;0.11.1 补充跨步骤取回姿势,0.11.3 补记指引级 A/B 证据,0.12.0 加入调度类别/舱壁/资源互斥/唯一任务与属性测试;预设沿用 v0.3.0 的宿主 PTC 声明派生方式。验证环境为 Windows、Node.js 24.21.0,宿主组件锁定 DSH 0.2.1-rc.2、Cordis 4.0.4。
|
|
4
4
|
|
|
5
5
|
## 已验证什么?
|
|
6
6
|
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
| PTC 预算分离等待 | 真实 Node PTC `run_code` 以 2 秒预算等待 5 秒总线执行:预算到期后执行继续、宿主 Job 保留、同 requestId 取回结果;外部中断仍取消执行 | 在 DSH 0.2.1-rc.2 实际中断原因下成立;固定字符串不是跨版本契约 |
|
|
13
13
|
| 简化调用与复用标记 | 简化请求/控制、完整 v1 消息兼容、终态与运行中复用返回 `reused:true`、非法形状拒绝 | 组件级测试确认;不改变 wire 语义与副作用边界 |
|
|
14
14
|
| 采用策略 A/B(指引级) | 同一版本 0.11.2、同一模型、5 类任务各 5 个全新子会话:可选触发 vs 总线优先指引,统计总线调用/复用/耗时 | 指出可选触发更优(总线优先把一次性长命令也包装、并有约 19s 排队延迟);非预设级、并发单机小样本,非统计显著 |
|
|
15
|
+
| 调度隔离与资源互斥 | Handler queue/maxConcurrency/resources、类别优先级 + 等待老化、资源等待期限、waitReason/longRunning、唯一任务只复用 succeeded | 组件级回归确认;仅在单个总线实例内成立,不是 OS 锁或跨进程保证 |
|
|
16
|
+
| 属性测试 | fast-check 随机提交/重放与容量压力不变量(固定种子) | 开发依赖(MIT);随机覆盖不是穷尽验证,种子固定保证可复现 |
|
|
15
17
|
| 预设隔离 | AgentPresetRegistry 与 Loader | 派生预设可激活;源 ptc 声明不变、普通 Agent 不可见总线;原 write/edit/Job 工具保留,宿主前置策略拒绝仍有效 |
|
|
16
18
|
| 故障注入 | 克隆失败、观察者拒绝、Job 等待/清理异常、先行移除与卸载交错 | 指定故障下的回归约束;不证明所有故障组合都已覆盖 |
|
|
17
19
|
| 候选包 | 严格文件白名单、临时目录生产依赖安装、入口注册 | 包可安装和加载;安装验证使用简化宿主夹具,不是桌面插件管理器验收 |
|
|
@@ -51,6 +53,7 @@ eb95b4e51675f5f1821275e0812ef4dd4984b1aba45a52fbc6bab3518bbf6f38
|
|
|
51
53
|
| 从真实 tgz 经宿主插件管理器加载预设、卸载与回滚 | 未完成完整桌面流程;源码工作区的 Loader 测试不能替代 |
|
|
52
54
|
| 真实 glob/grep/pwsh 工具实现 | 未在测试环境启用:真实搜索工具依赖打包 ripgrep 与 subprocess 服务,真实 pwsh 依赖 shell/sandbox/审批组合;本版只验证按 handler 名转发、输入校验、幂等分流与失败固定码 |
|
|
53
55
|
| 跨版本 PTC 中断原因 | 分离等待只识别 DSH 0.2.1-rc.2 实际使用的固定中断原因字符串;宿主升级后未知原因保守按取消处理,需复核 |
|
|
56
|
+
| 跨会话/跨进程资源互斥 | 未实现:资源键互斥只在单个总线实例内;不提供 OS 级文件锁,宿主插件总线作用域需按实际部署核对 |
|
|
54
57
|
| 模式采用率与效果 | 已完成指引级 A/B(可选触发 vs 总线优先),但非预设级、并发单机小样本,不具统计显著性;轮次/耗时/成功率的大样本对照仍未完成,不能由 schema 或单元测试推断 |
|
|
55
58
|
| 磁盘会话结果提交及重启恢复原调用 | 未验收磁盘提交;本插件不支持跨重启恢复 |
|
|
56
59
|
| 减少真实模型轮次、状态查询或总耗时的对照 | 未验证,不给提升比例 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feng0531/dsh-execution-bus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"@deepseek-ai/dsh-tool-jobs": "0.2.1-rc.2",
|
|
75
75
|
"@types/js-yaml": "4.0.9",
|
|
76
76
|
"@types/node": "^22",
|
|
77
|
+
"fast-check": "^4.10.2",
|
|
77
78
|
"prettier": "3.6.2",
|
|
78
79
|
"typescript": "~5.9.3",
|
|
79
80
|
"yaml": "2.9.1"
|