@feng0531/dsh-execution-bus 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.3.0**
7
+ DSH 独立插件:`@feng0531/dsh-execution-bus` · 当前版本 **0.4.1**
8
8
 
9
9
  > 萧何模式从宿主已声明的 PTC 组合派生,保留日常工具,再叠加可选执行总线。已验证隔离的真实 Node PTC → 原 read + 总线调用,以及脚本模型内存会话;真实模型、桌面界面与实际沙箱仍未验收。**不支持重启后恢复任务。**
10
10
 
@@ -115,7 +115,7 @@ flowchart TD
115
115
  2. 在你选定的隔离 DSH 配置依赖目录安装固定版本,不要在 DSH 程序安装目录执行:
116
116
 
117
117
  ```powershell
118
- npm install --ignore-scripts @feng0531/dsh-execution-bus@0.3.0
118
+ npm install --ignore-scripts @feng0531/dsh-execution-bus@0.4.1
119
119
  ```
120
120
 
121
121
  这是 npm 目录示例。若目标配置使用 pnpm,应沿用其包管理器,不混用锁文件。遇到宿主 peer 版本冲突就停止,不用强制安装绕过。
@@ -210,6 +210,12 @@ stateDiagram-v2
210
210
 
211
211
  执行终态不会再改成另一个终态。取消与成功竞争时,若处理器最终正常返回,仍可能是成功。`delivery` 是另一个字段:`pending` 表示尚未走到返回标记,`returned` 只表示插件已走到工具返回路径,**不证明会话结果已经写入磁盘或模型已经消费**。
212
212
 
213
+ ## 调度与诊断
214
+
215
+ 0.4.0 起对已受理的排队请求按会话轮转,同一会话保持 FIFO;不抢占运行任务,也不保证队列满时为其他会话预留名额。执行结果仍由 core 唯一提交,宿主 Job 只负责任务展示、控制与生命周期关联。
216
+
217
+ core 开发者可调用实例 `diagnostics()` 取得队列、运行数、保留结果字节及最近 128 条数值阶段轨迹。不含请求/会话 ID、输入、输出或错误文本,不写日志;诊断不增加模型调用工具,也不是跨重启审计记录。详见[优化范围与验证](<docs/OPTIMIZATION.md>)。
218
+
213
219
  ## 容量与重要限制
214
220
 
215
221
  默认并发 **2**,排队上限 **32**,最多保存 **256** 条任务记录,单结果最多 **64 KiB**,完成结果保留期 **1 小时**,在新请求和查询时惰性清理;不是一小时一到即定时删除。队列或记录满时拒绝新任务;宿主 Job 自身也有容量限制。
@@ -244,7 +250,7 @@ npm run test:install # 临时目录安装,不操作日常 DSH
244
250
 
245
251
  - [协议和投递语义](<docs/protocol.md>):字段、状态、重复处理与取消规则。
246
252
  - [宿主兼容性与源码调用链](<docs/host-compatibility.md>):为什么可以等待,为什么不能跨重启恢复。
247
- - [验证记录](<docs/validation.md>)、[0.3.0 发布说明](<docs/releases/0.3.0.md>):已验证与未验证范围。
253
+ - [验证记录](<docs/validation.md>)、[0.4.1 发布说明](<docs/releases/0.4.1.md>):已验证与未验证范围。
248
254
  - [维护者发布流程](<docs/RELEASING.md>):标签 CI、安装包校验与正式发布门禁。
249
255
 
250
256
  ## 许可
package/dist/bus.d.ts CHANGED
@@ -53,8 +53,36 @@ export declare class ExecutionBus {
53
53
  private entries;
54
54
  private handlers;
55
55
  private queue;
56
+ private readyOwners;
56
57
  private running;
58
+ private retainedResultBytes;
57
59
  private closed;
60
+ private telemetry;
61
+ diagnostics(): {
62
+ phase: "open" | "cancelling" | "draining" | "closed";
63
+ queued: number;
64
+ running: number;
65
+ records: number;
66
+ retainedResultBytes: number;
67
+ counts: {
68
+ accepted?: number | undefined;
69
+ started?: number | undefined;
70
+ cancel_requested?: number | undefined;
71
+ terminal?: number | undefined;
72
+ returned?: number | undefined;
73
+ job_created?: number | undefined;
74
+ job_released?: number | undefined;
75
+ job_error?: number | undefined;
76
+ reused?: number | undefined;
77
+ };
78
+ traces: {
79
+ operation: number;
80
+ stage: import("./diagnostics.js").TraceStage;
81
+ elapsedMs: number;
82
+ }[];
83
+ };
84
+ private closing?;
85
+ private shutdownPhase;
58
86
  constructor(options?: {
59
87
  concurrency: number;
60
88
  queueLimit: number;
package/dist/bus.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID, createHash } from "node:crypto";
2
2
  import { assertJson } from "./json.js";
3
+ import { Diagnostics } from "./diagnostics.js";
3
4
  export function object(v) {
4
5
  if (!v || typeof v !== "object" || Array.isArray(v))
5
6
  throw Error("INVALID_OBJECT");
@@ -58,8 +59,23 @@ export class ExecutionBus {
58
59
  entries = new Map();
59
60
  handlers = new Map();
60
61
  queue = [];
62
+ readyOwners = [];
61
63
  running = 0;
64
+ retainedResultBytes = 0;
62
65
  closed = false;
66
+ telemetry = new Diagnostics();
67
+ diagnostics() {
68
+ return {
69
+ ...this.telemetry.snapshot(),
70
+ phase: this.shutdownPhase,
71
+ queued: this.queue.length,
72
+ running: this.running,
73
+ records: this.entries.size,
74
+ retainedResultBytes: this.retainedResultBytes,
75
+ };
76
+ }
77
+ closing;
78
+ shutdownPhase = "open";
63
79
  constructor(options = {
64
80
  concurrency: 2,
65
81
  queueLimit: 32,
@@ -119,16 +135,30 @@ export class ExecutionBus {
119
135
  type,
120
136
  payload,
121
137
  });
122
- for (const f of e.listeners)
123
- try {
124
- // Observe promise rejection without awaiting or blocking task settlement.
125
- void Promise.resolve(f(event)).catch(() => {
126
- /* Async observers are best-effort; never leak their error text. */
127
- });
128
- }
129
- catch {
130
- /* Observers cannot alter execution. */
138
+ // Snapshot and enqueue before invoking user code: nested cancellation cannot
139
+ // overtake the current event for later observers. Removed listeners are skipped.
140
+ e.notifications.push({ event, listeners: [...e.listeners] });
141
+ if (e.dispatching)
142
+ return;
143
+ e.dispatching = true;
144
+ try {
145
+ while (e.notifications.length) {
146
+ const batch = e.notifications.shift();
147
+ for (const listener of batch.listeners) {
148
+ if (!e.listeners.has(listener))
149
+ continue;
150
+ try {
151
+ void Promise.resolve(listener(batch.event)).catch(() => { });
152
+ }
153
+ catch { }
154
+ }
131
155
  }
156
+ }
157
+ finally {
158
+ e.dispatching = false;
159
+ if (terminal(e.view.state))
160
+ e.listeners.clear();
161
+ }
132
162
  }
133
163
  view(e) {
134
164
  return structuredClone(e.view);
@@ -141,8 +171,11 @@ export class ExecutionBus {
141
171
  }
142
172
  sweep(now = Date.now()) {
143
173
  for (const [k, e] of this.entries)
144
- if (terminal(e.view.state) && now - e.created > this.options.retentionMs)
174
+ if (terminal(e.view.state) &&
175
+ now - e.created > this.options.retentionMs) {
176
+ this.retainedResultBytes -= e.resultBytes;
145
177
  this.entries.delete(k);
178
+ }
146
179
  }
147
180
  submit(owner, raw, onEvent, invoke) {
148
181
  if (this.closed)
@@ -170,6 +203,7 @@ export class ExecutionBus {
170
203
  const attached = this.attachment(e, onEvent, m.messageId);
171
204
  if (!previous)
172
205
  e.messages.set(m.messageId, canonical(m));
206
+ this.telemetry.record(e.trace, "reused");
173
207
  return attached;
174
208
  }
175
209
  const handler = this.handlers.get(p.handler)?.handler;
@@ -182,6 +216,8 @@ export class ExecutionBus {
182
216
  let resolve;
183
217
  const done = new Promise((r) => (resolve = r));
184
218
  e = {
219
+ trace: this.telemetry.begin(),
220
+ resultBytes: 0,
185
221
  invoke,
186
222
  correlationId: m.messageId,
187
223
  owner: owner.sessionId,
@@ -199,13 +235,18 @@ export class ExecutionBus {
199
235
  done,
200
236
  resolve,
201
237
  listeners: new Set(),
238
+ notifications: [],
239
+ dispatching: false,
202
240
  sourceSequence: 0,
203
241
  created: Date.now(),
204
242
  messages: new Map([[m.messageId, canonical(m)]]),
205
243
  };
206
244
  this.entries.set(key, e);
245
+ this.telemetry.record(e.trace, "accepted");
207
246
  const attached = this.attachment(e, onEvent, m.messageId);
208
247
  this.queue.push(e);
248
+ if (!this.readyOwners.includes(e.owner))
249
+ this.readyOwners.push(e.owner);
209
250
  queueMicrotask(() => this.pump());
210
251
  return attached;
211
252
  }
@@ -235,6 +276,8 @@ export class ExecutionBus {
235
276
  e.listeners.delete(listener);
236
277
  },
237
278
  returned: () => {
279
+ if (e.view.delivery !== "returned")
280
+ this.telemetry.record(e.trace, "returned");
238
281
  e.view.delivery = "returned";
239
282
  },
240
283
  };
@@ -268,14 +311,17 @@ export class ExecutionBus {
268
311
  if (terminal(e.view.state) || e.view.cancelRequested)
269
312
  return;
270
313
  e.view.cancelRequested = true;
314
+ this.telemetry.record(e.trace, "cancel_requested");
271
315
  e.controller.abort();
272
316
  this.emit(e, "cancel_requested", {});
273
317
  if (e.view.state === "queued") {
274
318
  this.queue = this.queue.filter((x) => x !== e);
319
+ if (!this.queue.some((entry) => entry.owner === e.owner))
320
+ this.readyOwners = this.readyOwners.filter((owner) => owner !== e.owner);
275
321
  this.finish(e, "cancelled");
276
322
  }
277
323
  }
278
- finish(e, state, result, error) {
324
+ finish(e, state, result, error, resultBytes = 0) {
279
325
  if (terminal(e.view.state))
280
326
  return;
281
327
  // Prepare the delivery snapshot BEFORE committing an irreversible terminal state.
@@ -290,6 +336,7 @@ export class ExecutionBus {
290
336
  }
291
337
  catch {
292
338
  state = "unknown";
339
+ resultBytes = 0;
293
340
  next = {
294
341
  requestId: e.view.requestId,
295
342
  state,
@@ -300,21 +347,37 @@ export class ExecutionBus {
300
347
  };
301
348
  snapshot = freezeJson({ ...next });
302
349
  }
350
+ e.resultBytes = resultBytes;
351
+ this.retainedResultBytes += resultBytes;
303
352
  e.created = Date.now();
304
353
  e.view = { ...next, sequence: next.sequence - 1 };
305
- this.emit(e, state, { state });
354
+ this.telemetry.record(e.trace, "terminal");
306
355
  e.resolve(snapshot);
307
- e.listeners.clear();
356
+ this.emit(e, state, { state });
357
+ // A reentrant terminal notification may still be queued behind this batch.
358
+ if (!e.dispatching)
359
+ e.listeners.clear();
308
360
  }
309
361
  pump() {
310
362
  while (!this.closed &&
311
363
  this.running < this.options.concurrency &&
312
364
  this.queue.length) {
313
- const e = this.queue.shift();
365
+ let index = -1;
366
+ while (this.readyOwners.length && index < 0) {
367
+ const owner = this.readyOwners.shift();
368
+ index = this.queue.findIndex((entry) => entry.owner === owner);
369
+ if (index >= 0 &&
370
+ this.queue.some((entry, i) => i !== index && entry.owner === owner))
371
+ this.readyOwners.push(owner);
372
+ }
373
+ if (index < 0)
374
+ index = 0;
375
+ const e = this.queue.splice(index, 1)[0];
314
376
  if (e.view.state !== "queued")
315
377
  continue;
316
378
  this.running++;
317
379
  e.view.state = "running";
380
+ this.telemetry.record(e.trace, "started");
318
381
  this.emit(e, "started", {});
319
382
  void this.run(e).finally(() => {
320
383
  this.running--;
@@ -355,7 +418,7 @@ export class ExecutionBus {
355
418
  this.finish(e, "unknown", undefined, "RESULT_NOT_RETAINED");
356
419
  return;
357
420
  }
358
- this.finish(e, "succeeded", JSON.parse(json));
421
+ this.finish(e, "succeeded", JSON.parse(json), undefined, Buffer.byteLength(json));
359
422
  }
360
423
  catch (err) {
361
424
  const cancelled = e.controller.signal.aborted &&
@@ -372,12 +435,29 @@ export class ExecutionBus {
372
435
  : "HANDLER_FAILED");
373
436
  }
374
437
  }
375
- async close() {
438
+ close() {
439
+ if (this.closing)
440
+ return this.closing;
376
441
  this.closed = true;
377
- for (const e of this.entries.values())
378
- this.cancel(e);
379
- await Promise.all([...this.entries.values()].map((e) => e.done));
380
- this.handlers.clear();
381
- this.entries.clear();
442
+ this.shutdownPhase = "cancelling";
443
+ let resolve, reject;
444
+ this.closing = new Promise((r, j) => {
445
+ resolve = r;
446
+ reject = j;
447
+ });
448
+ void (async () => {
449
+ const entries = [...this.entries.values()];
450
+ for (const e of entries)
451
+ this.cancel(e);
452
+ this.shutdownPhase = "draining";
453
+ await Promise.all(entries.map((e) => e.done));
454
+ this.handlers.clear();
455
+ this.entries.clear();
456
+ this.retainedResultBytes = 0;
457
+ this.queue = [];
458
+ this.readyOwners = [];
459
+ this.shutdownPhase = "closed";
460
+ })().then(resolve, reject);
461
+ return this.closing;
382
462
  }
383
463
  }
@@ -0,0 +1,40 @@
1
+ export type TraceStage = "accepted" | "started" | "cancel_requested" | "terminal" | "returned" | "job_created" | "job_released" | "job_error" | "reused";
2
+ export interface Trace {
3
+ operation: number;
4
+ stage: TraceStage;
5
+ elapsedMs: number;
6
+ }
7
+ /** In-memory numeric metadata only: never accepts request/session strings, input, output or error text. */
8
+ export declare class Diagnostics {
9
+ private next;
10
+ private events;
11
+ private cursor;
12
+ private size;
13
+ private counts;
14
+ begin(): {
15
+ operation: number;
16
+ at: number;
17
+ };
18
+ record(context: {
19
+ operation: number;
20
+ at: number;
21
+ }, stage: TraceStage): void;
22
+ snapshot(): {
23
+ counts: {
24
+ accepted?: number | undefined;
25
+ started?: number | undefined;
26
+ cancel_requested?: number | undefined;
27
+ terminal?: number | undefined;
28
+ returned?: number | undefined;
29
+ job_created?: number | undefined;
30
+ job_released?: number | undefined;
31
+ job_error?: number | undefined;
32
+ reused?: number | undefined;
33
+ };
34
+ traces: {
35
+ operation: number;
36
+ stage: TraceStage;
37
+ elapsedMs: number;
38
+ }[];
39
+ };
40
+ }
@@ -0,0 +1,30 @@
1
+ import { performance } from "node:perf_hooks";
2
+ /** In-memory numeric metadata only: never accepts request/session strings, input, output or error text. */
3
+ export class Diagnostics {
4
+ next = 0;
5
+ events = new Array(128);
6
+ cursor = 0;
7
+ size = 0;
8
+ counts = {};
9
+ begin() {
10
+ return { operation: ++this.next, at: performance.now() };
11
+ }
12
+ record(context, stage) {
13
+ this.counts[stage] = (this.counts[stage] ?? 0) + 1;
14
+ this.events[this.cursor] = {
15
+ operation: context.operation,
16
+ stage,
17
+ elapsedMs: Math.max(0, performance.now() - context.at),
18
+ };
19
+ this.cursor = (this.cursor + 1) % 128;
20
+ this.size = Math.min(this.size + 1, 128);
21
+ }
22
+ snapshot() {
23
+ return {
24
+ counts: { ...this.counts },
25
+ traces: Array.from({ length: this.size }, (_, index) => ({
26
+ ...this.events[(this.cursor - this.size + 128 + index) % 128],
27
+ })),
28
+ };
29
+ }
30
+ }
@@ -0,0 +1,61 @@
1
+ import type { JobRegistry, JobId } from "@deepseek-ai/dsh-jobs";
2
+ import type { SessionId } from "@deepseek-ai/dsh-session";
3
+ import type { RecordView, Event } from "./bus.js";
4
+ import { Diagnostics } from "./diagnostics.js";
5
+ export interface ExecutionAttachment {
6
+ done: Promise<RecordView>;
7
+ cancel(): void;
8
+ }
9
+ /** One host projection of a logical execution. The core completion remains authoritative. */
10
+ export declare class ExecutionHandle {
11
+ private jobs;
12
+ private owner;
13
+ private execution;
14
+ private telemetry;
15
+ readonly completion: Promise<RecordView>;
16
+ readonly settled: Promise<void>;
17
+ readonly job: JobId;
18
+ private progressHandle?;
19
+ private trace;
20
+ constructor(jobs: JobRegistry, owner: SessionId, execution: ExecutionAttachment, telemetry?: Diagnostics);
21
+ requestCancel(): void;
22
+ progress(event: Event): void;
23
+ private absent;
24
+ private waitAndClean;
25
+ }
26
+ export type ShutdownPhase = "open" | "stopping" | "draining" | "releasing" | "closed";
27
+ /** Owns host projections and active deliveries, not a second execution state machine. */
28
+ export declare class ExecutionOwner {
29
+ private handles;
30
+ private deliveries;
31
+ private closing?;
32
+ private telemetry;
33
+ create(key: string, jobs: JobRegistry, owner: SessionId, execution: ExecutionAttachment): ExecutionHandle;
34
+ diagnostics(): {
35
+ phase: ShutdownPhase;
36
+ activeProjections: number;
37
+ deliveries: number;
38
+ counts: {
39
+ accepted?: number | undefined;
40
+ started?: number | undefined;
41
+ cancel_requested?: number | undefined;
42
+ terminal?: number | undefined;
43
+ returned?: number | undefined;
44
+ job_created?: number | undefined;
45
+ job_released?: number | undefined;
46
+ job_error?: number | undefined;
47
+ reused?: number | undefined;
48
+ };
49
+ traces: {
50
+ operation: number;
51
+ stage: import("./diagnostics.js").TraceStage;
52
+ elapsedMs: number;
53
+ }[];
54
+ };
55
+ private currentPhase;
56
+ get phase(): ShutdownPhase;
57
+ get(key: string): ExecutionHandle | undefined;
58
+ add(key: string, handle: ExecutionHandle): void;
59
+ track<T>(delivery: Promise<T>): Promise<T>;
60
+ close(drainCore: () => Promise<void>): Promise<void>;
61
+ }
@@ -0,0 +1,174 @@
1
+ import { setTimeout as pause } from "node:timers/promises";
2
+ import { Diagnostics } from "./diagnostics.js";
3
+ /** One host projection of a logical execution. The core completion remains authoritative. */
4
+ export class ExecutionHandle {
5
+ jobs;
6
+ owner;
7
+ execution;
8
+ telemetry;
9
+ completion;
10
+ settled;
11
+ job;
12
+ progressHandle;
13
+ trace;
14
+ constructor(jobs, owner, execution, telemetry = new Diagnostics()) {
15
+ this.jobs = jobs;
16
+ this.owner = owner;
17
+ this.execution = execution;
18
+ this.telemetry = telemetry;
19
+ this.trace = telemetry.begin();
20
+ this.completion = execution.done;
21
+ const outcome = this.completion.then((v) => ({
22
+ status: v.state === "succeeded"
23
+ ? "completed"
24
+ : v.state === "cancelled"
25
+ ? "killed"
26
+ : "failed",
27
+ detail: v.state,
28
+ }));
29
+ this.job = jobs.start({
30
+ kind: "execution-bus",
31
+ label: "萧何 / Xiao He",
32
+ owner,
33
+ run: (handle) => {
34
+ this.progressHandle = handle;
35
+ return { done: outcome, cancel: () => this.requestCancel() };
36
+ },
37
+ });
38
+ telemetry.record(this.trace, "job_created");
39
+ this.settled = this.waitAndClean();
40
+ }
41
+ requestCancel() {
42
+ this.execution.cancel();
43
+ }
44
+ progress(event) {
45
+ this.progressHandle?.updateProgress(event.type === "progress"
46
+ ? String(event.payload.percent) + "%"
47
+ : event.type);
48
+ }
49
+ absent(error) {
50
+ try {
51
+ return (error instanceof Error &&
52
+ error.message === "unknown job " + this.job &&
53
+ !this.jobs.list(this.owner).some((v) => v.id === this.job));
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ async waitAndClean() {
60
+ try {
61
+ for (;;) {
62
+ const view = await this.jobs.wait(this.job, 60000, this.owner);
63
+ if (view.status !== "running" && view.status !== "stopping")
64
+ break;
65
+ }
66
+ this.jobs.remove(this.job, this.owner);
67
+ this.telemetry.record(this.trace, "job_released");
68
+ }
69
+ catch (error) {
70
+ this.telemetry.record(this.trace, "job_error");
71
+ const removed = this.absent(error);
72
+ // Retain the original execution, including while its caller/owner is being disposed.
73
+ await this.completion;
74
+ if (removed)
75
+ this.telemetry.record(this.trace, "job_released");
76
+ if (!removed) {
77
+ for (let attempt = 0; attempt < 20; attempt++) {
78
+ try {
79
+ const view = this.jobs
80
+ .list(this.owner)
81
+ .find((v) => v.id === this.job && v.owner === this.owner);
82
+ if (!view) {
83
+ this.telemetry.record(this.trace, "job_released");
84
+ break;
85
+ }
86
+ if (view.status !== "running" && view.status !== "stopping") {
87
+ this.jobs.remove(this.job, this.owner);
88
+ this.telemetry.record(this.trace, "job_released");
89
+ break;
90
+ }
91
+ }
92
+ catch {
93
+ break;
94
+ }
95
+ await pause(10);
96
+ }
97
+ throw error;
98
+ }
99
+ }
100
+ finally {
101
+ this.progressHandle = undefined;
102
+ }
103
+ }
104
+ }
105
+ /** Owns host projections and active deliveries, not a second execution state machine. */
106
+ export class ExecutionOwner {
107
+ handles = new Map();
108
+ deliveries = new Set();
109
+ closing;
110
+ telemetry = new Diagnostics();
111
+ create(key, jobs, owner, execution) {
112
+ if (this.phase !== "open")
113
+ throw Error("CLOSED");
114
+ if (this.handles.has(key))
115
+ throw Error("DUPLICATE_EXECUTION_HANDLE");
116
+ const handle = new ExecutionHandle(jobs, owner, execution, this.telemetry);
117
+ this.add(key, handle);
118
+ return handle;
119
+ }
120
+ diagnostics() {
121
+ return {
122
+ ...this.telemetry.snapshot(),
123
+ phase: this.phase,
124
+ activeProjections: this.handles.size,
125
+ deliveries: this.deliveries.size,
126
+ };
127
+ }
128
+ currentPhase = "open";
129
+ get phase() {
130
+ return this.currentPhase;
131
+ }
132
+ get(key) {
133
+ return this.handles.get(key);
134
+ }
135
+ add(key, handle) {
136
+ if (this.phase !== "open")
137
+ throw Error("CLOSED");
138
+ if (this.handles.has(key))
139
+ throw Error("DUPLICATE_EXECUTION_HANDLE");
140
+ this.handles.set(key, handle);
141
+ const release = () => {
142
+ if (this.handles.get(key) === handle)
143
+ this.handles.delete(key);
144
+ };
145
+ void handle.settled.then(release, release);
146
+ }
147
+ track(delivery) {
148
+ this.deliveries.add(delivery);
149
+ const release = () => this.deliveries.delete(delivery);
150
+ void delivery.then(release, release);
151
+ return delivery;
152
+ }
153
+ close(drainCore) {
154
+ if (this.closing)
155
+ return this.closing;
156
+ this.currentPhase = "stopping";
157
+ let resolve, reject;
158
+ this.closing = new Promise((r, j) => {
159
+ resolve = r;
160
+ reject = j;
161
+ });
162
+ void (async () => {
163
+ const core = drainCore();
164
+ this.currentPhase = "draining";
165
+ await core;
166
+ await Promise.allSettled([...this.deliveries]);
167
+ this.currentPhase = "releasing";
168
+ await Promise.allSettled([...this.handles.values()].map((h) => h.settled));
169
+ this.handles.clear();
170
+ this.currentPhase = "closed";
171
+ })().then(resolve, reject);
172
+ return this.closing;
173
+ }
174
+ }
package/dist/plugin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { setTimeout as pause } from "node:timers/promises";
2
+ import { ExecutionOwner } from "./execution.js";
3
3
  import { ExecutionBus, parse } from "./bus.js";
4
4
  import { testHandler, readHandler } from "./handlers.js";
5
5
  export const name = "execution-bus";
@@ -8,12 +8,8 @@ export function apply(ctx) {
8
8
  const bus = new ExecutionBus();
9
9
  bus.register("test", testHandler);
10
10
  bus.register("host.read", readHandler);
11
- const sharedJobs = new Map();
12
- ctx.effect(() => async () => {
13
- await bus.close();
14
- await Promise.allSettled(sharedJobs.values());
15
- sharedJobs.clear();
16
- });
11
+ const executions = new ExecutionOwner();
12
+ ctx.effect(() => () => executions.close(() => bus.close()));
17
13
  ctx.tools.register({
18
14
  name: "execution_bus",
19
15
  description: "萧何 / Xiao He. Submit a version 1 request and WAIT for its terminal result in this tool call. Reuse requestId only for the same logical operation. Handlers: test {delayMs:0..30000,fail:boolean}, host.read {file_path,offset?,limit?<=100}. Control types status/result/cancel use empty payload. Acceptance is volatile, not execution success. No polling needed; no restart resume.",
@@ -42,147 +38,79 @@ export function apply(ctx) {
42
38
  schema: {},
43
39
  render: (_a, v) => [{ type: "text", text: JSON.stringify(v) }],
44
40
  },
45
- async execute(raw, exec) {
46
- if (!exec.agent)
47
- throw Error("OWNING_SESSION_REQUIRED");
48
- const owner = {
49
- sessionId: String(exec.agent.id),
50
- callId: String(exec.callId),
51
- }, m = parse(raw);
52
- if (m.type !== "request")
53
- return bus.command(owner, m);
54
- let handle;
55
- const attached = bus.submit(owner, m, (event) => handle?.updateProgress(event.type === "progress"
56
- ? String(event.payload.percent) + "%"
57
- : event.type), async (input, signal) => {
58
- const result = await ctx.tools.execute({
59
- name: "read",
60
- callId: (String(exec.callId) +
61
- ":bus:" +
62
- randomUUID()),
63
- rootCallId: exec.rootCallId,
64
- parent: exec.token,
65
- agent: exec.agent,
66
- arguments: input,
67
- signal,
68
- });
69
- if (result.isError)
70
- throw Error("HOST_READ_FAILED");
71
- return result.value;
41
+ execute(raw, exec) {
42
+ if (executions.phase !== "open")
43
+ return Promise.reject(Error("CLOSED"));
44
+ return executions.track(dispatch(raw, exec));
45
+ },
46
+ });
47
+ async function dispatch(raw, exec) {
48
+ if (!exec.agent)
49
+ throw Error("OWNING_SESSION_REQUIRED");
50
+ const owner = {
51
+ sessionId: String(exec.agent.id),
52
+ callId: String(exec.callId),
53
+ }, m = parse(raw);
54
+ if (m.type !== "request")
55
+ return bus.command(owner, m);
56
+ let projection;
57
+ const attached = bus.submit(owner, m, (event) => projection?.progress(event), async (input, signal) => {
58
+ const result = await ctx.tools.execute({
59
+ name: "read",
60
+ callId: (String(exec.callId) +
61
+ ":bus:" +
62
+ randomUUID()),
63
+ rootCallId: exec.rootCallId,
64
+ parent: exec.token,
65
+ agent: exec.agent,
66
+ arguments: input,
67
+ signal,
72
68
  });
73
- // Terminal reuse is a read of an existing result, not admission of new work.
74
- if (attached.accepted.payload.state !== "queued" &&
75
- attached.accepted.payload.state !== "running") {
76
- try {
77
- const value = await attached.done;
78
- attached.returned();
79
- return value;
80
- }
81
- finally {
82
- attached.detach();
83
- }
84
- }
85
- const abort = () => attached.cancel();
86
- const jobKey = JSON.stringify([owner.sessionId, m.requestId]);
87
- let settled = sharedJobs.get(jobKey);
88
- if (!settled) {
89
- try {
90
- const outcome = attached.done.then((v) => ({
91
- status: v.state === "succeeded"
92
- ? "completed"
93
- : v.state === "cancelled"
94
- ? "killed"
95
- : "failed",
96
- detail: v.state,
97
- }));
98
- const job = ctx.jobs.start({
99
- kind: "execution-bus",
100
- label: "萧何 / Xiao He",
101
- owner: exec.agent.id,
102
- run(h) {
103
- handle = h;
104
- return {
105
- done: outcome,
106
- cancel: abort,
107
- };
108
- },
109
- });
110
- const ownerId = exec.agent.id;
111
- // One registered host waiter suppresses extra completion messages for every attachment.
112
- settled = (async () => {
113
- try {
114
- for (;;) {
115
- const view = await ctx.jobs.wait(job, 60000, ownerId);
116
- if (view.status !== "running" && view.status !== "stopping")
117
- break;
118
- }
119
- ctx.jobs.remove(job, ownerId);
120
- }
121
- catch (error) {
122
- // rc.2 has no typed not-found code. Match only this minted Job and
123
- // confirm absence in the original owner scope; never swallow access/service errors.
124
- let alreadyRemoved = false;
125
- try {
126
- alreadyRemoved =
127
- error instanceof Error &&
128
- error.message === `unknown job ${job}` &&
129
- !ctx.jobs.list(ownerId).some((view) => view.id === job);
130
- }
131
- catch {
132
- /* Preserve the original wait/cleanup error. */
133
- }
134
- // A broken wait channel does not terminate execution. Keep caller cancellation
135
- // attached, drain both producer and host outcome, then attempt cleanup once.
136
- await attached.done;
137
- if (!alreadyRemoved) {
138
- // No scope-owned observer: teardown can remove it before settlement.
139
- // After producer drain, allow a bounded host commit window. This is
140
- // program-side cleanup only, never execution retry or model polling.
141
- for (let attempt = 0; attempt < 20; attempt++) {
142
- try {
143
- const view = ctx.jobs
144
- .list(ownerId)
145
- .find((view) => view.id === job && view.owner === ownerId);
146
- if (!view)
147
- break;
148
- if (view.status !== "running" &&
149
- view.status !== "stopping") {
150
- ctx.jobs.remove(job, ownerId);
151
- break;
152
- }
153
- }
154
- catch {
155
- break;
156
- }
157
- await pause(10);
158
- }
159
- throw error;
160
- }
161
- }
162
- })();
163
- sharedJobs.set(jobKey, settled);
164
- void settled.then(() => sharedJobs.delete(jobKey), () => sharedJobs.delete(jobKey));
165
- }
166
- catch {
167
- abort();
168
- await attached.done;
169
- attached.detach();
170
- throw Error("HOST_JOB_UNAVAILABLE");
171
- }
172
- }
173
- exec.signal.addEventListener("abort", abort, { once: true });
174
- if (exec.signal.aborted)
175
- abort();
69
+ if (result.isError)
70
+ throw Error("HOST_READ_FAILED");
71
+ return result.value;
72
+ });
73
+ // Terminal reuse is a read of an existing result, not admission of new work.
74
+ if (attached.accepted.payload.state !== "queued" &&
75
+ attached.accepted.payload.state !== "running") {
176
76
  try {
177
- await settled;
178
77
  const value = await attached.done;
179
78
  attached.returned();
180
79
  return value;
181
80
  }
182
81
  finally {
183
- exec.signal.removeEventListener("abort", abort);
184
82
  attached.detach();
185
83
  }
186
- },
187
- });
84
+ }
85
+ const abort = () => attached.cancel();
86
+ const jobKey = JSON.stringify([owner.sessionId, m.requestId]);
87
+ let execution = executions.get(jobKey);
88
+ if (execution)
89
+ attached.detach(); // only the first attachment projects progress into the shared Job
90
+ if (!execution) {
91
+ try {
92
+ execution = executions.create(jobKey, ctx.jobs, exec.agent.id, attached);
93
+ projection = execution;
94
+ }
95
+ catch {
96
+ abort();
97
+ await attached.done;
98
+ attached.detach();
99
+ throw Error("HOST_JOB_UNAVAILABLE");
100
+ }
101
+ }
102
+ exec.signal.addEventListener("abort", abort, { once: true });
103
+ if (exec.signal.aborted)
104
+ abort();
105
+ try {
106
+ await execution.settled;
107
+ const value = await attached.done;
108
+ attached.returned();
109
+ return value;
110
+ }
111
+ finally {
112
+ exec.signal.removeEventListener("abort", abort);
113
+ attached.detach();
114
+ }
115
+ }
188
116
  }
@@ -0,0 +1,38 @@
1
+ # GitHub 源码对比与诊断优化
2
+
3
+ ## 调研范围
4
+
5
+ 本轮对比萧何 v0.4.0(55e1d66)的队列生命周期、事件通知和诊断成本。通过 GitHub 仓库检索及 raw 源码读取,使用以下固定标签,不把 star 数作为质量证明,也未构建这些项目。
6
+
7
+ | 项目 | 实际查看内容 | 对萧何的结论 |
8
+ |---|---|---|
9
+ | [p-queue v8.1.0 实现](https://github.com/sindresorhus/p-queue/blob/v8.1.0/source/index.ts)与[测试](https://github.com/sindresorhus/p-queue/blob/v8.1.0/test/test.ts) | pending 计数、onEmpty/onIdle、abort 与 finally | 对照检查排队为空不等于运行结束;保留萧何合作取消后排空语义,不引入其超时/取消竞速直接释放槽位的行为 |
10
+ | [EventEmitter3 5.0.1 实现](https://github.com/primus/eventemitter3/blob/5.0.1/index.js)与[测试](https://github.com/primus/eventemitter3/blob/5.0.1/test/test.js) | emit 前保存监听数组长度,移除重建监听列表 | 支持为派发明确边界;萧何 0.4.0 已有批次快照、解除后跳过和嵌套事件顺序,保持自己的公开契约,不换库 |
11
+ | [Denque v2.1.0 实现](https://github.com/invertase/denque/blob/v2.1.0/index.js)与[测试](https://github.com/invertase/denque/blob/v2.1.0/test/denque.js) | head/tail 索引、容量和环绕行为 | 用同类索引思想自行实现固定 128 槽诊断环,不搬可增长 deque 或新增运行依赖 |
12
+
13
+ 一次额外 deque 候选链接返回 404,未作为实现依据。参考代码不是本项目实际运行依赖;只借鉴行为和测试思路,没有复制上游源码。
14
+
15
+ ## 本轮实施
16
+
17
+ 1. **固定诊断环**:0.4.0 达到上限后每条事件 push 再 shift,搬移旧记录;0.4.1 使用固定槽位覆盖和游标,读取时导出最近 128 条的时间顺序副本。
18
+ 2. **保留结果字节缓存**:0.4.0 每次 diagnostics 遍历记录并重新 JSON.stringify;0.4.1 复用保存时已算出的 UTF-8 字节,在终态快照保存成功后提交缓存。过期逐项扣除、关闭归零,unknown 或快照失败不计入。
19
+ 3. **行为保持**:请求协议、容量、会话轮转、合作取消、关闭阶段和通知重入语义不变;不增加任务队列依赖,也不改变宿主授权。
20
+
21
+ ## 可重复证据
22
+
23
+ 回归 test/github-comparison.test.mjs 先在旧产物运行:
24
+
25
+ | 对比工作负载 | 0.4.0 实际计数 | 0.4.1 预期/回归结果 |
26
+ |---|---|---|
27
+ | 写入 1000 条诊断 | Array.shift 调用 872 次 | 0 次;仍只保存编号 873–1000 的 128 条 |
28
+ | 两条保存结果,查询诊断 50 次 | 额外 JSON.stringify 100 次 | 0 次,字节数完全相同 |
29
+ | 注入结果快照失败,再关闭 | unknown,不保存结果 | 字节计数保持 0;关闭 phase closed、queued/running 为 0 |
30
+
31
+ 前两项优化前断言失败,第三项在前后均通过。以上是明确工作负载下的操作次数,不是耗时加速比、模型轮次或生产吞吐结论。若结果本身仅很小或诊断很少调用,实际收益也可能很小。新增字节缓存有维护成本,因此专门覆盖 Unicode、过期和失败归零。
32
+
33
+ ## 暂不做
34
+
35
+ - 不用开源队列直接替换当前调度器:宿主 Job 与原工具结果排空约束不同。
36
+ - 不把 normal timeout 当作任务取消成功,不放弃仍运行的 Handler。
37
+ - 不做无锁/线程池、消息持久化或自动副作用重跑。
38
+ - 不因库测试通过就声称当前桌面、真实模型或沙箱验收通过。
@@ -0,0 +1,47 @@
1
+ # 引擎经验优化整合与验收
2
+
3
+ 本轮统一在 0.4.0 交付,不将普通 BUG 修复冒充整体架构优化,不声称模型性能已提高。运行逻辑仅作用于萧何总线及其 Job 关联;保留 0.3.0 的 PTC 派生方式、权限和原工具能力。
4
+
5
+ ## 优先级与交付
6
+
7
+ | 优先级 | 交付 | 范围与限制 |
8
+ |---|---|---|
9
+ | P1 | 执行所有权集中 | 内部 ExecutionHandle 关联 core completion、请求级取消、宿主 Job 和进度;ExecutionOwner 集中跟踪共享投影及工具交付。不是第二个执行状态机 |
10
+ | P1 | 阶段化关闭 | 停止受理 → 请求取消及排空 core → 等待活跃工具交付 → 等待并释放 Job 关联。重复关闭返回同一 Promise,先发布 closing 再触发取消,允许回调重入 |
11
+ | P1 | 通知批次边界 | 每次固定名单;新增监听从后续事件接收;解除的监听跳过;嵌套事件按请求内序号排在当前批之后。同步与异步错误继续隔离 |
12
+ | P2 | 有界脱敏诊断 | core diagnostics 快照及内部 Job 聚合轨迹。每个环最近 128 条;只存数值编号、阶段、耗时、计数,不存业务内容 |
13
+ | P2 | 会话轮转 | 两会话积压测试确认 FIFO 的队头延迟后,实现受理后轮转;保持会话内 FIFO 和并发上限,无入队公平保证或时间 SLA |
14
+ | 暂缓 | 按资源串行 | 没有真实写 Handler,不引入资源锁;PTC 原工具不被全局接管 |
15
+ | 不做 | 工作流/线程系统 | 不加入 DAG、线程池、无锁队列、对象池或按引用数自动取消 |
16
+
17
+ ## 实测证据及不能推导的结论
18
+
19
+ 确定性压力用例中,A0 已运行,A1–A12 排队后 B0 入队:
20
+
21
+ ```text
22
+ 原 FIFO: A0,A1,A2,A3,A4,A5,A6,A7,A8,A9,A10,A11,A12,B0
23
+ 会话轮转:A0,A1,B0,A2,A3,A4,A5,A6,A7,A8,A9,A10,A11,A12
24
+ ```
25
+
26
+ 这是已受理任务的开始顺序对比,不是生产延迟、吞吐或模型轮次提升比例。运行任务不可抢占,新会话可能仍因队列满被拒绝。同一会话多个长任务也可能占用并发槽,轮转不等于资源配额。
27
+
28
+ 另外验证三会话、并发为二、取消一个排队任务:只执行其余 23 个不同请求,未超过并发上限,同会话开始顺序保持 FIFO。已有真实宿主等待/清理故障与卸载回归继续执行,不以纯 core 测试替代。
29
+
30
+ ## 如何查看诊断
31
+
32
+ 从包的 ./core 使用 ExecutionBus,调用 `bus.diagnostics()`。这不是新的 Agent 工具,也不提供跨会话读取入口;生产插件内部所有者的诊断尚未公开为服务或 UI。
33
+
34
+ 阶段 elapsedMs 相对于本次受理的单调时钟时间:started 减 accepted 为排队时间、terminal 减 started 为执行时间、returned 减 terminal 为首次返回标记延迟。cancel_requested 与 terminal 间隔只能解释为取消请求到执行终态,未必为成功取消。
35
+
36
+ 从 0.4.1 起,结果字节在保存时缓存、记录过期和关闭时扣除/归零;查询不再遍历记录或序列化结果。诊断写入使用 128 槽固定环,读取时才按时间顺序复制最多 128 条。环形轨迹不会记录结果内容,但仍是内部运行信息,不作为可信审计/授权依据。进程关闭后不恢复。
37
+
38
+ ## 引擎经验来源
39
+
40
+ - [Bevy Task v0.16.1](https://github.com/bevyengine/bevy/blob/v0.16.1/crates/bevy_tasks/src/task.rs):区分任务完成、取消和 detach;没有照搬 Rust Drop 语义。
41
+ - [Bevy CommandQueue v0.16.1](https://github.com/bevyengine/bevy/blob/v0.16.1/crates/bevy_ecs/src/world/command_queue.rs):先确定消费边界再调用外部代码;未移植 unsafe/字节布局。
42
+ - [Godot 4.4 MessageQueue](https://github.com/godotengine/godot/blob/4.4/core/object/message_queue.cpp):派发重入与消费位置的明确约束。
43
+ - [Godot 4.4 WorkerThreadPool](https://github.com/godotengine/godot/blob/4.4/core/object/worker_thread_pool.h):分阶段关闭;没有增加线程池。
44
+ - [Unity Addressables](https://docs.unity3d.com/Packages/com.unity.addressables@1.21/manual/AddressableAssetsAsyncOperationHandle.html):区分异步操作所有权、释放和取消,不增加模拟指针引用计数。
45
+ - [Stride Scheduler](https://github.com/stride3d/stride/blob/master/sources/core/Stride.Core.MicroThreading/Scheduler.cs)、[O3DE JobCancelGroup](https://github.com/o3de/o3de/blob/development/Code/Framework/AzCore/AzCore/Jobs/JobCancelGroup.h):作为调研参考,分支链接可变;不宣称固定 SHA 或已构建这些引擎。
46
+
47
+ 本项目是依据原则自行实现,没有复制这些引擎的源码或引入其运行依赖。
package/docs/protocol.md CHANGED
@@ -44,7 +44,7 @@ core 的 `submit()` 返回 `accepted`、`done` Promise 和取消/解除通知/
44
44
 
45
45
  通知包含版本、消息 ID、请求 ID、关联 ID(correlationId)、请求内序号、类型与 payload。accepted 的 correlationId 对应本次提交消息,序号是当前快照;后续事件关联首次请求 messageId,新增序号。类型包括 started、progress、cancel_requested 和 succeeded、failed、cancelled、unknown 四种终态事件。
46
46
 
47
- Handler 报告的进度序号必须递增;重复、乱序及终态后进度忽略,百分比须在 0–100 内。事件及 payload 在通知前冻结。每个附着独立解除通知,detach 不取消执行;取消是另一个操作。观察者抛错或异步拒绝被隔离,不记录异常原文,也不等待观察者资源完成。
47
+ Handler 报告的进度序号必须递增;重复、乱序及终态后进度忽略,百分比须在 0–100 内。事件及 payload 在通知前冻结。每个附着独立解除通知,detach 不取消执行;取消是另一个操作。派发开始时固定本批名单,派发期间新增监听从下个事件开始接收;已解除且尚未轮到的回调跳过。回调中触发的嵌套事件按序排在当前批之后,不跨越当前通知。观察者抛错或异步拒绝被隔离,不记录异常原文,也不等待观察者资源完成。
48
48
 
49
49
  ## 3. 执行状态和交付标记
50
50
 
@@ -80,13 +80,13 @@ queued → running → succeeded / failed / cancelled / unknown
80
80
  | 单结果 JSON | 64 KiB |
81
81
  | 终态保留期 | 自完成起 1 小时 |
82
82
 
83
- 新执行请求受队列和记录容量限制;运行中重复附着受消息/监听名额限制;未过期终态复用不申请新 Job。宿主新 Job 准入还有独立容量要求。没有公平性 SLA,单会话可能占满总线。
83
+ 新执行请求受队列和记录容量限制;运行中重复附着受消息/监听名额限制;未过期终态复用不申请新 Job。宿主新 Job 准入还有独立容量要求。已受理的排队任务按会话轮转,各会话内部 FIFO;不抢占运行任务,没有完成时间 SLA,单会话仍可占满受理容量,不保证其他会话一定获得入队名额。
84
84
 
85
85
  core 不传配置时用默认值;显式配置必须完整包含 concurrency、queueLimit、recordLimit、resultBytes、retentionMs 五项正安全整数。拒绝缺项、额外字段与访问器;构造时复制冻结,无动态更新接口。
86
86
 
87
87
  保留期通过新请求或控制消息触发惰性清理,也可调用实例 `sweep()`;不是精确的一小时定时删除,闲置时记录可能继续占内存,但后续正常查询会先清理过期记录。无磁盘任务库。结果大小检查在 Handler 返回后进行,Handler 自身也必须限制内存分配。敏感返回内容仍可能进入宿主会话日志,本插件不是秘密识别器。
88
88
 
89
- 关闭停止受理,取消队列和运行任务,等待合作 Handler。正常路径复用 Job 窗口等待;服务异常后仍先排空执行,再有界检查记录并保留原服务错误。持续故障可能留记录到宿主生命周期清理,不合作 Handler 可能阻碍关闭。
89
+ 关闭幂等且可重入,多次 close 返回同一 Promise。core 先停止受理,取消队列和运行任务,排空后清理内存;宿主执行所有者继续等原工具交付路径及 Job 关联结束,再释放。等待某个 Handler 的通知回调里不应反向 await 整个关闭完成。正常路径复用 Job 窗口等待;服务异常后仍先排空执行,再有界检查记录并保留原服务错误。持续故障可能留记录到宿主生命周期清理,不合作 Handler 可能阻碍关闭。
90
90
 
91
91
  ## 5. 重试与恢复语义
92
92
 
@@ -94,7 +94,13 @@ core 不传配置时用默认值;显式配置必须完整包含 concurrency、
94
94
 
95
95
  同存活实例、保存期内的请求复用不是端到端 exactly-once;每个实际工具调用仍有自己的宿主结果记录。没有外部终态注入接口,也没有跨重启恢复:任一阶段进程崩溃都会丢失插件内存状态。宿主日志不能复活旧 Promise,不追加聊天消息冒充原调用恢复。
96
96
 
97
- ## 6. 扩展 Handler
97
+ ## 6. 诊断
98
+
99
+ core 实例的 `diagnostics()` 返回只读用途的独立快照:阶段计数、最近 128 条 `{operation,stage,elapsedMs}` 轨迹、关闭阶段、队列/运行/记录数及保留结果字节数。operation 是实例内递增数值,不是消息或会话 ID;elapsedMs 以单调时钟计算,阶段时间均相对受理时间,可相减得到排队/执行/返回延迟。复用计数仅在附着受理成功时增加;返回阶段按逻辑结果首次标记,不代表每个工具调用都持久化。
100
+
101
+ 宿主执行所有者另有内部 Job 创建/释放/异常聚合轨迹,不作为模型工具或公开服务导出;其 operation 是独立编号,不能与 core 轨迹按编号关联。数据只在内存,不保存参数、结果、Handler 名称、请求/会话 ID、异常文本或聊天记录。总计数会持续累积,128 槽环形轨迹有界且按时间顺序导出;结果字节只在保存/过期/关闭时更新,查询不读取结果内容或重复序列化。异常计数不等于每次清理最终失败。
102
+
103
+ ## 7. 扩展 Handler
98
104
 
99
105
  包的 `./core` 导出 ExecutionBus、Handler、Capability 类型。Handler 只接收输入、requestId、signal、progress 和可选最小 capability,不接收聊天历史。register 返回绑定唯一注册令牌的幂等注销函数,不会误删后来同名注册;已受理请求继续持有原 Handler。
100
106
 
@@ -0,0 +1,17 @@
1
+ # 萧何 / Xiao He 0.4.0
2
+
3
+ ## 本版变化
4
+
5
+ 统一交付引擎调研中优先级最高的生命周期优化,不改变请求级取消、内存保存或宿主权限规则。
6
+
7
+ - 内部执行句柄集中持有完成结果、请求取消及宿主 Job 投影,core 仍是执行状态唯一事实源。共享执行只由首个附着投影进度,重复调用不重复创建 Job。
8
+ - 关闭分阶段、可重入并幂等,多次 close 返回同一完成 Promise,等待已启动执行和活跃工具交付后释放关联。
9
+ - 固定事件通知批次:回调中新增监听不接收当前事件,解除监听跳过尚未调用部分;嵌套取消/终态按序派发。事件仍不可变,观察者异常继续隔离。
10
+ - core 增加 diagnostics():最近 128 条数值阶段轨迹及容量快照,内部执行所有者另计 Job 事件。无业务参数、结果、请求/会话 ID 或错误文本,无新增模型工具。
11
+ - 压力验证后将已受理调度改为按会话轮转,保持会话内 FIFO;不抢占、不保证入队公平或完成时间。
12
+
13
+ ## 验证和范围
14
+
15
+ 新增通知重入/关闭、诊断脱敏及多会话调度回归。确定性用例 B0 从 A 的 12 个积压任务之后提前至 A1 后执行,不代表真实模型性能提升;具体顺序与边界见优化说明。
16
+
17
+ 沿用 PTC 声明派生,原工具不强制经过总线;Handler 仍只含 test/host.read。资源锁、DAG、线程池、对象池、跨重启恢复不在本版。真实模型、桌面 UI 和实际沙箱仍未验收。兼容 DSH 0.2.0-rc.2,非商业源码公开许可;不安装或重启日常 DSH。
@@ -0,0 +1,15 @@
1
+ # 萧何 / Xiao He 0.4.1
2
+
3
+ ## 优化
4
+
5
+ 结合固定版本的 p-queue、EventEmitter3 和 Denque 实现/测试对照,收敛诊断热路径开销,保持 0.4.0 执行语义。
6
+
7
+ - 诊断最近 128 条轨迹改为固定槽位环,写入不再 shift 搬移旧项;快照仍按时间顺序复制。
8
+ - 保存结果时缓存字节数,过期时扣除、关闭归零;diagnostics 查询不再遍历所有结果和重复序列化。结果快照失败或 unknown 不占缓存。
9
+ - 增加三项对比回归及可追溯 GitHub 对比说明。1000 条事件的 shift 从 872 次降为 0;两个结果查询诊断 50 次的额外序列化从 100 次降为 0。不是速度提升比例。
10
+
11
+ ## 兼容与验证
12
+
13
+ 不改变消息协议、工具或预设标识、会话轮转/通知重入/关闭契约;不新增运行依赖。全量测试、标签 Windows/Ubuntu × Node 22/24、包白名单与生产隔离安装按既有门禁执行。
14
+
15
+ DSH 0.2.0-rc.2,非商业源码公开许可。真实模型、桌面 UI 和实际沙箱未验收,不支持跨重启恢复,不安装或重启日常 DSH。
@@ -1,6 +1,6 @@
1
1
  # 验证范围与使用限制
2
2
 
3
- 本文说明当前版本的证据边界,不是生产环境认证。当前预设能力基线为 **v0.3.0**;总线执行核心沿用此前验证,新增的是宿主 PTC 声明派生与叠加模式。验证环境为 Windows、Node.js 24.21.0,宿主组件锁定 DSH 0.2.0-rc.2、Cordis 4.0.4。
3
+ 本文说明当前版本的证据边界,不是生产环境认证。当前优化基线为 **v0.4.0**,新增执行所有权/关闭、通知重入边界、数值诊断及会话轮转;预设沿用 v0.3.0 的宿主 PTC 声明派生方式。验证环境为 Windows、Node.js 24.21.0,宿主组件锁定 DSH 0.2.0-rc.2、Cordis 4.0.4。
4
4
 
5
5
  ## 已验证什么?
6
6
 
@@ -13,7 +13,7 @@
13
13
  | 故障注入 | 克隆失败、观察者拒绝、Job 等待/清理异常、先行移除与卸载交错 | 指定故障下的回归约束;不证明所有故障组合都已覆盖 |
14
14
  | 候选包 | 严格文件白名单、临时目录生产依赖安装、入口注册 | 包可安装和加载;安装验证使用简化宿主夹具,不是桌面插件管理器验收 |
15
15
 
16
- v0.2.11 本地回归基线为 **48/48 通过**;本版新增声明保真、真实注册生命周期及真实 Node PTC 桥接测试,最新数量以命令输出为准。测试项数量是用例数量,不是全部产品验收条数。当前结果请重新运行下述命令;文档测试数不应代替实际输出。
16
+ 此前 v0.3.0 本地回归为 **51/51 通过**;本轮新增生命周期与多会话压力回归,最新数量以命令输出为准。测试项数量是用例数量,不是全部产品验收条数。当前结果请重新运行下述命令;文档测试数不应代替实际输出。
17
17
 
18
18
  ```powershell
19
19
  npm ci --include=dev --ignore-scripts
@@ -49,7 +49,7 @@ eb95b4e51675f5f1821275e0812ef4dd4984b1aba45a52fbc6bab3518bbf6f38
49
49
  ## 需要知道的运行约束
50
50
 
51
51
  1. 受理、队列、结果及交付标记均在内存。进程关闭会丢失,宿主会话日志不等于插件恢复执行权。
52
- 2. 同一预设的会话共享总线容量,结果访问按会话隔离;不是独立进程或资源安全隔离。
52
+ 2. 同一预设的会话共享总线容量,已受理任务会话轮转、会话内 FIFO;结果访问按会话隔离,不是独立进程或资源安全隔离,也不保证入队名额公平。
53
53
  3. 取消任一正在等待的重复调用会请求取消整个逻辑执行,不是仅停止该等待者。
54
54
  4. 已终态且未过期的请求可直接复用结果,不申请新 Job;仍须通过身份、冲突及消息额度检查。
55
55
  5. 等待服务异常后先排空原执行,再有界检查宿主记录;服务持续不可用时记录可能留到所属生命周期清理。不合作 Handler 仍可能阻碍关闭。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-execution-bus",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "license": "SEE LICENSE IN LICENSE",