@feng0531/dsh-execution-bus 0.2.12 → 0.4.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 CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  “萧何”是产品与模式显示名,执行总线是其技术功能;不新增人物 Agent,不进行角色扮演。仓库 dsh-execution-bus、npm 包名、execution_bus 工具名及 execution-bus-test 预设 ID 保持不变。历史版本发布说明保留当时名称。
6
6
 
7
- DSH 独立插件:`@feng0531/dsh-execution-bus` · 当前版本 **0.2.12**
7
+ DSH 独立插件:`@feng0531/dsh-execution-bus` · 当前版本 **0.4.0**
8
8
 
9
- > 当前是早期可运行版本,不是完整生产验收版。已验证真实 DSH 组件和脚本模型驱动的内存会话;真实模型、桌面界面和 PTC 完整链路尚未验收。**不支持重启后恢复任务。**
9
+ > 萧何模式从宿主已声明的 PTC 组合派生,保留日常工具,再叠加可选执行总线。已验证隔离的真实 Node PTC → 原 read + 总线调用,以及脚本模型内存会话;真实模型、桌面界面与实际沙箱仍未验收。**不支持重启后恢复任务。**
10
10
 
11
11
  ## 它解决什么问题?
12
12
 
@@ -92,7 +92,13 @@ flowchart TD
92
92
 
93
93
  包内提供标准 DSH bundle patch(预设组合补丁)。宿主正常加载后会收到“萧何 / Xiao He”预设定义,由既有设置 → Agent 预设 → **自定义**列表展示。建议选择它新建专门会话,不改默认选择或正在使用的普通会话。已有会话的切换受宿主空白会话检查约束。**实际桌面卡片展示尚未验收**,不能把注册表测试当作界面已通过。
94
94
 
95
- 该预设只在自己的作用域注册 execution_bus,使用原生工具调用,不改其他会话的 PTC 模式。它没有 shell 行;宿主文件工具套件会声明 read/write/edit 等工具,但该模式的执行门禁(guard)只允许总线、read 和 Job 控制,写入/编辑即使可见也会拒绝。它不是进程级沙箱,读取边界仍由宿主权限决定;同一预设的会话共享有界队列容量,但请求结果按会话隔离。
95
+ **0.3.0 起不再是只读验证模式。** 加载时读取宿主 `ptc` 预设的组合声明快照,保留原工具、平台表达式、禁用项和配置,只追加萧何总线与说明。不再强制 native 呈现或添加只读白名单;终端、写入、搜索、技能等能力是否可用,取决于原 PTC 配置和宿主服务。其他预设和默认模式不被修改,原 PTC 会话看不到新增总线。
96
+
97
+ 普通任务仍通过 `run_code` 调用原工具;**总线目前只支持 test 和 host.read,不接管任意命令、安装或全部工具**。例如更新插件应使用原终端能力并遵守宿主授权;专用插件管理工具若在源 PTC 中禁用,萧何不会擅自启用。命令受理、返回 Job ID 不等于执行完成。
98
+
99
+ 升级提示:预设 ID 保持兼容,但从旧版只读模式升级后能力范围扩大,使用者须审阅后加载;本次不自动升级日常配置。它不是进程沙箱,同一预设会话共享总线容量,结果按会话隔离。派生是加载时快照,不自动跟随源预设热编辑;源 PTC 更新后应按宿主正常流程重新加载派生行,旧会话遵循宿主版本保留规则。
100
+
101
+ 源组合中的相对模块名、include 子文件缺少公开基准地址信息,当前明确拒绝派生,不能称为兼容所有自定义 PTC。遇到该诊断应先将源组合整理成可解析的显式模块行,不能绕过或回退成只读工具集。缺失 ptc 声明时注册器最多等候约 5 秒后明确失败。
96
102
 
97
103
  **不要同时把包根入口作为全局工具插件加载**:那是手工集成入口,会使 execution_bus 全局可见,失去此预设的可见性隔离。仅安装 npm 包也不保证卡片立即出现,需要加载包声明的 bundle patch。项目发布不会自动安装到任何读者的宿主,卡片是否可用需要在目标实例实际检查。
98
104
 
@@ -109,7 +115,7 @@ flowchart TD
109
115
  2. 在你选定的隔离 DSH 配置依赖目录安装固定版本,不要在 DSH 程序安装目录执行:
110
116
 
111
117
  ```powershell
112
- npm install --ignore-scripts @feng0531/dsh-execution-bus@0.2.12
118
+ npm install --ignore-scripts @feng0531/dsh-execution-bus@0.4.0
113
119
  ```
114
120
 
115
121
  这是 npm 目录示例。若目标配置使用 pnpm,应沿用其包管理器,不混用锁文件。遇到宿主 peer 版本冲突就停止,不用强制安装绕过。
@@ -178,7 +184,7 @@ flowchart TD
178
184
 
179
185
  ### 如果宿主使用 PTC 模式
180
186
 
181
- PTC 是 DSH 的程序化工具调用入口。在 `run_code` 内使用 `await tools.execution_bus(请求对象)`,而不是直接调用折叠后的工具名。执行总线不会解除 `run_code` 自身的时间预算;PTC 完整进程链路尚未验收。
187
+ PTC 是 DSH 的程序化工具调用入口。在 `run_code` 内使用 `await tools.execution_bus(请求对象)`,而不是直接调用折叠后的工具名。执行总线不会解除 `run_code` 自身的时间预算。已有真实 Node PTC 隔离进程读文件和调用总线的验证,但尚未进行实际模型/桌面沙箱全链路验收。
182
188
 
183
189
  ## 如何理解任务状态?
184
190
 
@@ -204,6 +210,12 @@ stateDiagram-v2
204
210
 
205
211
  执行终态不会再改成另一个终态。取消与成功竞争时,若处理器最终正常返回,仍可能是成功。`delivery` 是另一个字段:`pending` 表示尚未走到返回标记,`returned` 只表示插件已走到工具返回路径,**不证明会话结果已经写入磁盘或模型已经消费**。
206
212
 
213
+ ## 调度与诊断
214
+
215
+ 0.4.0 起对已受理的排队请求按会话轮转,同一会话保持 FIFO;不抢占运行任务,也不保证队列满时为其他会话预留名额。执行结果仍由 core 唯一提交,宿主 Job 只负责任务展示、控制与生命周期关联。
216
+
217
+ core 开发者可调用实例 `diagnostics()` 取得队列、运行数、保留结果字节及最近 128 条数值阶段轨迹。不含请求/会话 ID、输入、输出或错误文本,不写日志;诊断不增加模型调用工具,也不是跨重启审计记录。详见[优化范围与验证](<docs/OPTIMIZATION.md>)。
218
+
207
219
  ## 容量与重要限制
208
220
 
209
221
  默认并发 **2**,排队上限 **32**,最多保存 **256** 条任务记录,单结果最多 **64 KiB**,完成结果保留期 **1 小时**,在新请求和查询时惰性清理;不是一小时一到即定时删除。队列或记录满时拒绝新任务;宿主 Job 自身也有容量限制。
@@ -238,7 +250,7 @@ npm run test:install # 临时目录安装,不操作日常 DSH
238
250
 
239
251
  - [协议和投递语义](<docs/protocol.md>):字段、状态、重复处理与取消规则。
240
252
  - [宿主兼容性与源码调用链](<docs/host-compatibility.md>):为什么可以等待,为什么不能跨重启恢复。
241
- - [验证记录](<docs/validation.md>)、[0.2.12 发布说明](<docs/releases/0.2.12.md>):已验证与未验证范围。
253
+ - [验证记录](<docs/validation.md>)、[0.4.0 发布说明](<docs/releases/0.4.0.md>):已验证与未验证范围。
242
254
  - [维护者发布流程](<docs/RELEASING.md>):标签 CI、安装包校验与正式发布门禁。
243
255
 
244
256
  ## 许可
@@ -1,5 +1,5 @@
1
1
  # 第三方说明
2
2
 
3
- 本包没有捆绑 DSH、Cordis、TypeScript、测试依赖或宿主源码快照。DSH 与 Cordis 作为宿主 peer 使用,其独立包分别受原许可证约束,本项目非商业许可证不限制第三方已授予的权利。开发依赖不进入本项目 tgz。
3
+ 本包没有捆绑 DSH、Cordis、TypeScript、测试依赖或宿主源码快照。DSH 与 Cordis 作为宿主 peer 使用,其独立包分别受原许可证约束,本项目非商业许可证不限制第三方已授予的权利。开发依赖不进入本项目 tgz。js-yaml 为 MIT 许可的独立运行依赖,由包管理器按声明安装,不捆绑进 tgz;其源码和许可见 https://github.com/nodeca/js-yaml ,本项目许可不限制其原有权利。
4
4
 
5
5
  CI 与发布组织方式参考 dsh-vcs,并按本项目执行能力重新实现;本包不含 Monaco、React 或 SVN 实现。
package/cordis.patch.yml CHANGED
@@ -1,25 +1,6 @@
1
- # Registers a selectable custom preset, not a global execution_bus tool.
1
+ # Add a separate selectable preset, deriving the host's configured PTC declaration.
2
+ # Does not patch preset-ptc, selectedDefault or global tools.
2
3
  - insert:
3
4
  - id: preset-execution-bus-test
4
- name: '@deepseek-ai/dsh-agent-preset'
5
- config:
6
- id: execution-bus-test
7
- name: 萧何 / Xiao He
8
- description: 独立执行模式:请求排队、异步等待、进度和取消;当前支持受控验证与只读文件。不修改默认模式,禁止命令和写入执行;不支持重启恢复。
9
- order: 20
10
- plugins:
11
- - id: execution-bus-persona
12
- name: '@deepseek-ai/dsh-persona'
13
- config:
14
- prefix: >-
15
- 你正在萧何模式中;这是执行请求协调模式,不是人物角色扮演。只使用 execution_bus、只读文件和任务控制工具。
16
- 请求只提交一次并等待结果,不循环查询进度;同一逻辑操作重发沿用 requestId。
17
- 默认先用 test Handler 做延迟成功、失败和取消测试,读取真实文件须有用户要求。
18
- cancelRequested 不代表已取消,unknown 不代表执行失败。禁止声称跨重启恢复或完整模型性能提升。
19
- suffix: 'Your working directory is {{cwd}}.'
20
- - id: execution-bus-file-tools
21
- name: '@deepseek-ai/dsh-tool-fs'
22
- - id: execution-bus-job-tools
23
- name: '@deepseek-ai/dsh-tool-jobs'
24
- - id: execution-bus-scoped-runtime
25
- name: '@feng0531/dsh-execution-bus/test-mode'
5
+ name: '@feng0531/dsh-execution-bus/preset'
6
+ config: {}
package/dist/bus.d.ts CHANGED
@@ -53,8 +53,35 @@ export declare class ExecutionBus {
53
53
  private entries;
54
54
  private handlers;
55
55
  private queue;
56
+ private readyOwners;
56
57
  private running;
57
58
  private closed;
59
+ private telemetry;
60
+ diagnostics(): {
61
+ phase: "open" | "cancelling" | "draining" | "closed";
62
+ queued: number;
63
+ running: number;
64
+ records: number;
65
+ retainedResultBytes: number;
66
+ counts: {
67
+ accepted?: number | undefined;
68
+ started?: number | undefined;
69
+ cancel_requested?: number | undefined;
70
+ terminal?: number | undefined;
71
+ returned?: number | undefined;
72
+ job_created?: number | undefined;
73
+ job_released?: number | undefined;
74
+ job_error?: number | undefined;
75
+ reused?: number | undefined;
76
+ };
77
+ traces: {
78
+ operation: number;
79
+ stage: import("./diagnostics.js").TraceStage;
80
+ elapsedMs: number;
81
+ }[];
82
+ };
83
+ private closing?;
84
+ private shutdownPhase;
58
85
  constructor(options?: {
59
86
  concurrency: number;
60
87
  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,25 @@ export class ExecutionBus {
58
59
  entries = new Map();
59
60
  handlers = new Map();
60
61
  queue = [];
62
+ readyOwners = [];
61
63
  running = 0;
62
64
  closed = false;
65
+ telemetry = new Diagnostics();
66
+ diagnostics() {
67
+ return {
68
+ ...this.telemetry.snapshot(),
69
+ phase: this.shutdownPhase,
70
+ queued: this.queue.length,
71
+ running: this.running,
72
+ records: this.entries.size,
73
+ retainedResultBytes: [...this.entries.values()].reduce((sum, e) => sum +
74
+ (e.view.result === undefined
75
+ ? 0
76
+ : Buffer.byteLength(JSON.stringify(e.view.result))), 0),
77
+ };
78
+ }
79
+ closing;
80
+ shutdownPhase = "open";
63
81
  constructor(options = {
64
82
  concurrency: 2,
65
83
  queueLimit: 32,
@@ -119,16 +137,30 @@ export class ExecutionBus {
119
137
  type,
120
138
  payload,
121
139
  });
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. */
140
+ // Snapshot and enqueue before invoking user code: nested cancellation cannot
141
+ // overtake the current event for later observers. Removed listeners are skipped.
142
+ e.notifications.push({ event, listeners: [...e.listeners] });
143
+ if (e.dispatching)
144
+ return;
145
+ e.dispatching = true;
146
+ try {
147
+ while (e.notifications.length) {
148
+ const batch = e.notifications.shift();
149
+ for (const listener of batch.listeners) {
150
+ if (!e.listeners.has(listener))
151
+ continue;
152
+ try {
153
+ void Promise.resolve(listener(batch.event)).catch(() => { });
154
+ }
155
+ catch { }
156
+ }
131
157
  }
158
+ }
159
+ finally {
160
+ e.dispatching = false;
161
+ if (terminal(e.view.state))
162
+ e.listeners.clear();
163
+ }
132
164
  }
133
165
  view(e) {
134
166
  return structuredClone(e.view);
@@ -170,6 +202,7 @@ export class ExecutionBus {
170
202
  const attached = this.attachment(e, onEvent, m.messageId);
171
203
  if (!previous)
172
204
  e.messages.set(m.messageId, canonical(m));
205
+ this.telemetry.record(e.trace, "reused");
173
206
  return attached;
174
207
  }
175
208
  const handler = this.handlers.get(p.handler)?.handler;
@@ -182,6 +215,7 @@ export class ExecutionBus {
182
215
  let resolve;
183
216
  const done = new Promise((r) => (resolve = r));
184
217
  e = {
218
+ trace: this.telemetry.begin(),
185
219
  invoke,
186
220
  correlationId: m.messageId,
187
221
  owner: owner.sessionId,
@@ -199,13 +233,18 @@ export class ExecutionBus {
199
233
  done,
200
234
  resolve,
201
235
  listeners: new Set(),
236
+ notifications: [],
237
+ dispatching: false,
202
238
  sourceSequence: 0,
203
239
  created: Date.now(),
204
240
  messages: new Map([[m.messageId, canonical(m)]]),
205
241
  };
206
242
  this.entries.set(key, e);
243
+ this.telemetry.record(e.trace, "accepted");
207
244
  const attached = this.attachment(e, onEvent, m.messageId);
208
245
  this.queue.push(e);
246
+ if (!this.readyOwners.includes(e.owner))
247
+ this.readyOwners.push(e.owner);
209
248
  queueMicrotask(() => this.pump());
210
249
  return attached;
211
250
  }
@@ -235,6 +274,8 @@ export class ExecutionBus {
235
274
  e.listeners.delete(listener);
236
275
  },
237
276
  returned: () => {
277
+ if (e.view.delivery !== "returned")
278
+ this.telemetry.record(e.trace, "returned");
238
279
  e.view.delivery = "returned";
239
280
  },
240
281
  };
@@ -268,10 +309,13 @@ export class ExecutionBus {
268
309
  if (terminal(e.view.state) || e.view.cancelRequested)
269
310
  return;
270
311
  e.view.cancelRequested = true;
312
+ this.telemetry.record(e.trace, "cancel_requested");
271
313
  e.controller.abort();
272
314
  this.emit(e, "cancel_requested", {});
273
315
  if (e.view.state === "queued") {
274
316
  this.queue = this.queue.filter((x) => x !== e);
317
+ if (!this.queue.some((entry) => entry.owner === e.owner))
318
+ this.readyOwners = this.readyOwners.filter((owner) => owner !== e.owner);
275
319
  this.finish(e, "cancelled");
276
320
  }
277
321
  }
@@ -302,19 +346,33 @@ export class ExecutionBus {
302
346
  }
303
347
  e.created = Date.now();
304
348
  e.view = { ...next, sequence: next.sequence - 1 };
305
- this.emit(e, state, { state });
349
+ this.telemetry.record(e.trace, "terminal");
306
350
  e.resolve(snapshot);
307
- e.listeners.clear();
351
+ this.emit(e, state, { state });
352
+ // A reentrant terminal notification may still be queued behind this batch.
353
+ if (!e.dispatching)
354
+ e.listeners.clear();
308
355
  }
309
356
  pump() {
310
357
  while (!this.closed &&
311
358
  this.running < this.options.concurrency &&
312
359
  this.queue.length) {
313
- const e = this.queue.shift();
360
+ let index = -1;
361
+ while (this.readyOwners.length && index < 0) {
362
+ const owner = this.readyOwners.shift();
363
+ index = this.queue.findIndex((entry) => entry.owner === owner);
364
+ if (index >= 0 &&
365
+ this.queue.some((entry, i) => i !== index && entry.owner === owner))
366
+ this.readyOwners.push(owner);
367
+ }
368
+ if (index < 0)
369
+ index = 0;
370
+ const e = this.queue.splice(index, 1)[0];
314
371
  if (e.view.state !== "queued")
315
372
  continue;
316
373
  this.running++;
317
374
  e.view.state = "running";
375
+ this.telemetry.record(e.trace, "started");
318
376
  this.emit(e, "started", {});
319
377
  void this.run(e).finally(() => {
320
378
  this.running--;
@@ -372,12 +430,28 @@ export class ExecutionBus {
372
430
  : "HANDLER_FAILED");
373
431
  }
374
432
  }
375
- async close() {
433
+ close() {
434
+ if (this.closing)
435
+ return this.closing;
376
436
  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();
437
+ this.shutdownPhase = "cancelling";
438
+ let resolve, reject;
439
+ this.closing = new Promise((r, j) => {
440
+ resolve = r;
441
+ reject = j;
442
+ });
443
+ void (async () => {
444
+ const entries = [...this.entries.values()];
445
+ for (const e of entries)
446
+ this.cancel(e);
447
+ this.shutdownPhase = "draining";
448
+ await Promise.all(entries.map((e) => e.done));
449
+ this.handlers.clear();
450
+ this.entries.clear();
451
+ this.queue = [];
452
+ this.readyOwners = [];
453
+ this.shutdownPhase = "closed";
454
+ })().then(resolve, reject);
455
+ return this.closing;
382
456
  }
383
457
  }
@@ -0,0 +1,38 @@
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 counts;
12
+ begin(): {
13
+ operation: number;
14
+ at: number;
15
+ };
16
+ record(context: {
17
+ operation: number;
18
+ at: number;
19
+ }, stage: TraceStage): void;
20
+ snapshot(): {
21
+ counts: {
22
+ accepted?: number | undefined;
23
+ started?: number | undefined;
24
+ cancel_requested?: number | undefined;
25
+ terminal?: number | undefined;
26
+ returned?: number | undefined;
27
+ job_created?: number | undefined;
28
+ job_released?: number | undefined;
29
+ job_error?: number | undefined;
30
+ reused?: number | undefined;
31
+ };
32
+ traces: {
33
+ operation: number;
34
+ stage: TraceStage;
35
+ elapsedMs: number;
36
+ }[];
37
+ };
38
+ }
@@ -0,0 +1,26 @@
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 = [];
6
+ counts = {};
7
+ begin() {
8
+ return { operation: ++this.next, at: performance.now() };
9
+ }
10
+ record(context, stage) {
11
+ this.counts[stage] = (this.counts[stage] ?? 0) + 1;
12
+ this.events.push({
13
+ operation: context.operation,
14
+ stage,
15
+ elapsedMs: Math.max(0, performance.now() - context.at),
16
+ });
17
+ if (this.events.length > 128)
18
+ this.events.shift();
19
+ }
20
+ snapshot() {
21
+ return {
22
+ counts: { ...this.counts },
23
+ traces: this.events.map((e) => ({ ...e })),
24
+ };
25
+ }
26
+ }
@@ -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
+ }