@cannbot-plugin/cannbot-harness 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -8,8 +8,12 @@ import z from "@deepseek-ai/schemastery";
8
8
  import { spawn } from "node:child_process";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
11
- import { cp, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
11
+ import { access, cp, lstat, mkdir, mkdtemp, open, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
+ import { DatabaseSync } from "node:sqlite";
14
+ import { defineTool } from "@deepseek-ai/dsh-tools";
15
+ import { createInterface } from "node:readline";
16
+ import { parse } from "yaml";
13
17
  //#region src/workflows.ts
14
18
  const WORKFLOWS = [
15
19
  {
@@ -26,36 +30,6 @@ const WORKFLOWS = [
26
30
  id: "ops-registry-invoke",
27
31
  label: "registry",
28
32
  description: "registryDescription"
29
- },
30
- {
31
- id: "model-infer-optimize",
32
- label: "model",
33
- description: "modelDescription"
34
- },
35
- {
36
- id: "hccl-op-generator",
37
- label: "hccl",
38
- description: "hcclDescription"
39
- },
40
- {
41
- id: "triton-op-generator",
42
- label: "triton",
43
- description: "tritonDescription"
44
- },
45
- {
46
- id: "catlass-op-generator",
47
- label: "catlass",
48
- description: "catlassDescription"
49
- },
50
- {
51
- id: "ops-perf-optimize",
52
- label: "performance",
53
- description: "performanceDescription"
54
- },
55
- {
56
- id: "pypto-op-orchestrator",
57
- label: "pypto",
58
- description: "pyptoDescription"
59
33
  }
60
34
  ];
61
35
  //#endregion
@@ -123,7 +97,7 @@ function installWorkflowContext(ctx, config, content) {
123
97
  source,
124
98
  content: [{
125
99
  type: "text",
126
- text: renderSkillContent(skill)
100
+ text: renderSkillContent(skill) + "\n\nDSH 会话任务跟踪:在执行多步骤工作前,用 todo_write 建立具体的 PM 任务计划;需求确认、环境准备、工作流组装等启动前工作也要列入。开始、完成或受阻时及时更新计划,不要只在聊天中报告进展。命令返回或文件生成不等于任务验收通过。插件自动同步 [工作流] 节点状态;todo_write 只维护你自己的 PM 计划,勿复制或修改插件节点。在此插件内启动或续跑编排器使用 cannbot_workflow_run,查询使用 cannbot_workflow_status,节点由 DSH agent loop 执行;不要经 shell 改用其他 provider。若已有外部编排器在运行,不要重复启动;先核实并报告其状态。"
127
101
  }]
128
102
  })]
129
103
  };
@@ -132,12 +106,7 @@ function installWorkflowContext(ctx, config, content) {
132
106
  }
133
107
  //#endregion
134
108
  //#region src/client/plugin-update.ts
135
- const UPDATE_PACKAGES = [
136
- "cannbot-harness",
137
- "cannbot-dsh",
138
- "dsh-cannbot",
139
- "@cannbot-plugin/cannbot-harness"
140
- ];
109
+ const UPDATE_PACKAGES = ["cannbot-harness", "@cannbot-plugin/cannbot-harness"];
141
110
  /** Only stable npm releases of our two installation entries may be selected. */
142
111
  function newerVersion(candidate, current) {
143
112
  const next = /^(\d+)\.(\d+)\.(\d+)$/.exec(candidate);
@@ -147,7 +116,7 @@ function newerVersion(candidate, current) {
147
116
  const difference = Number(next[index]) - Number(old[index]);
148
117
  if (difference) return difference > 0;
149
118
  }
150
- return current.includes("-");
119
+ return /^\d+\.\d+\.\d+-/.test(current);
151
120
  }
152
121
  //#endregion
153
122
  //#region src/update-host.ts
@@ -243,39 +212,93 @@ function registerHostUpdater(ctx) {
243
212
  });
244
213
  }
245
214
  //#endregion
246
- //#region src/content-lock.ts
247
- async function withContentLock(path, action) {
248
- await mkdir(dirname(path), { recursive: true });
249
- const owner = JSON.stringify({
250
- pid: process.pid,
251
- id: randomUUID()
215
+ //#region src/process-runner.mjs
216
+ /** Wait for termination before callers release locks or remove staging files. */
217
+ function runProcess(command, args, { signal, timeoutMs, graceMs = 1e3, onData, ...options } = {}) {
218
+ signal?.throwIfAborted();
219
+ return new Promise((resolve, reject) => {
220
+ const child = spawn(command, args, {
221
+ ...options,
222
+ detached: true
223
+ });
224
+ let stopped, escalation, timer;
225
+ const kill = (force) => {
226
+ if (!child.pid) return;
227
+ try {
228
+ process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
229
+ } catch {}
230
+ };
231
+ const stop = (reason) => {
232
+ if (stopped) return;
233
+ stopped = reason;
234
+ kill(false);
235
+ escalation = setTimeout(() => kill(true), graceMs);
236
+ };
237
+ const abort = () => stop(signal.reason ?? /* @__PURE__ */ new Error("Operation cancelled"));
238
+ const cleanup = () => {
239
+ clearTimeout(timer);
240
+ clearTimeout(escalation);
241
+ signal?.removeEventListener("abort", abort);
242
+ };
243
+ if (onData) {
244
+ child.stdout?.on("data", onData);
245
+ child.stderr?.on("data", onData);
246
+ }
247
+ signal?.addEventListener("abort", abort, { once: true });
248
+ if (signal?.aborted) abort();
249
+ if (timeoutMs !== void 0) timer = setTimeout(() => stop(/* @__PURE__ */ new Error("Initialization timed out")), timeoutMs);
250
+ child.on("error", (error) => {
251
+ cleanup();
252
+ reject(error);
253
+ });
254
+ child.on("close", (code, exitSignal) => {
255
+ if (stopped) kill(true);
256
+ cleanup();
257
+ if (stopped) reject(stopped);
258
+ else if (code !== 0) reject(/* @__PURE__ */ new Error(`Command failed: ${command} (exit ${code ?? exitSignal})`));
259
+ else resolve();
260
+ });
252
261
  });
253
- const deadline = Date.now() + 20 * 6e4;
254
- for (;;) try {
255
- const handle = await open(path, "wx", 384);
256
- await handle.writeFile(owner);
257
- await handle.close();
258
- break;
262
+ }
263
+ //#endregion
264
+ //#region packages/install/lib/file-lock.mjs
265
+ /** Never unlink this inode. The kernel releases the SQLite transaction on exit.
266
+ * The suffix separates legacy JSON locks from the database format. */
267
+ async function acquireFileLock(path, timeout = 0) {
268
+ await mkdir(dirname(path), { recursive: true });
269
+ const file = path + ".sqlite3";
270
+ await (await open(file, "a", 384)).close();
271
+ const database = new DatabaseSync(file);
272
+ const deadline = Date.now() + timeout;
273
+ try {
274
+ for (;;) try {
275
+ database.exec("BEGIN EXCLUSIVE");
276
+ break;
277
+ } catch (error) {
278
+ if (error.errcode !== 5) throw error;
279
+ if (Date.now() >= deadline) throw new Error("Another installation or content operation is still running");
280
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now()))));
281
+ }
259
282
  } catch (error) {
260
- if (error.code !== "EEXIST") throw error;
261
- try {
262
- const previous = await readFile(path, "utf8"), record = JSON.parse(previous);
263
- if (Number.isSafeInteger(record.pid) && typeof record.id === "string") try {
264
- process.kill(record.pid, 0);
265
- } catch (dead) {
266
- if (dead.code === "ESRCH" && await readFile(path, "utf8") === previous) {
267
- await unlink(path);
268
- continue;
269
- }
270
- }
271
- } catch {}
272
- if (Date.now() > deadline) throw new Error("Another content operation is still running");
273
- await new Promise((done) => setTimeout(done, 250));
283
+ database.close();
284
+ throw error;
274
285
  }
286
+ let released = false;
287
+ return () => {
288
+ if (!released) {
289
+ released = true;
290
+ database.close();
291
+ }
292
+ };
293
+ }
294
+ //#endregion
295
+ //#region src/content-lock.ts
296
+ async function withContentLock(path, action) {
297
+ const release = await acquireFileLock(path, 20 * 6e4);
275
298
  try {
276
299
  return await action();
277
300
  } finally {
278
- if (await readFile(path, "utf8").catch(() => "") === owner) await unlink(path);
301
+ release();
279
302
  }
280
303
  }
281
304
  //#endregion
@@ -407,6 +430,12 @@ async function initializeTransaction(workspace, recoveryRoot, descriptor, resour
407
430
  } catch (recovery) {
408
431
  throw new Error(`${error.message}\n自动恢复未完成,备份保留在 ${directory}:${recovery.message}`);
409
432
  }
433
+ await rm(directory, {
434
+ recursive: true,
435
+ force: true,
436
+ maxRetries: 3,
437
+ retryDelay: 100
438
+ }).catch(() => {});
410
439
  throw new Error(`${error.message}\n已恢复本次初始化前的 Skills 和工作区配置。`);
411
440
  }
412
441
  await rm(journalPath);
@@ -458,6 +487,8 @@ var ContentManager = class {
458
487
  stamp = "";
459
488
  fetcher;
460
489
  registry = REGISTRY;
490
+ /** Workflows declared by the shipped defaults; registry catalogs may not reintroduce others. */
491
+ shipped;
461
492
  state = {
462
493
  phase: "idle",
463
494
  message: "",
@@ -467,6 +498,7 @@ var ContentManager = class {
467
498
  this.fetcher = fetcher;
468
499
  this.root = resolve(root);
469
500
  const entries = JSON.parse(readFileSync(defaults, "utf8")).workflows.map(checkedEntry);
501
+ this.shipped = new Set(entries.map((item) => item.id));
470
502
  this.index = {
471
503
  schemaVersion: 1,
472
504
  entries,
@@ -527,6 +559,10 @@ var ContentManager = class {
527
559
  await this.fetcher.extract(manifest.dist.tarball, directory, options);
528
560
  return directory;
529
561
  } catch (error) {
562
+ await rm(directory, {
563
+ recursive: true,
564
+ force: true
565
+ }).catch(() => {});
530
566
  if (String(error.message).includes("EINTEGRITY") || error.code === "EINTEGRITY") throw error;
531
567
  const registry = new URL(manifest.dist.tarball).hostname === "registry.npmjs.org" ? MIRROR : REGISTRY;
532
568
  const alternative = await this.fetcher.manifest(`${manifest.name}@${manifest.version}`, {
@@ -536,8 +572,16 @@ var ContentManager = class {
536
572
  validateTarball(alternative);
537
573
  if (alternative.name !== manifest.name || alternative.version !== manifest.version || alternative.dist.integrity !== manifest.dist.integrity) throw new Error("Registry mirror disagrees with the selected package integrity");
538
574
  const fresh = await mkdtemp(join(dirname(directory), `${manifest.version}-mirror-`));
539
- await this.fetcher.extract(alternative.dist.tarball, fresh, options);
540
- return fresh;
575
+ try {
576
+ await this.fetcher.extract(alternative.dist.tarball, fresh, options);
577
+ return fresh;
578
+ } catch (error) {
579
+ await rm(fresh, {
580
+ recursive: true,
581
+ force: true
582
+ }).catch(() => {});
583
+ throw error;
584
+ }
541
585
  }
542
586
  }
543
587
  descriptor(record) {
@@ -706,26 +750,35 @@ var ContentManager = class {
706
750
  });
707
751
  return this.pending;
708
752
  }
709
- installDependencies(catalogFile, resolvePackage) {
753
+ installDependencies(catalogFile, resolvePackage, replaceFromSource = false) {
710
754
  return this.operation("dependencies", async () => {
711
755
  const catalog = JSON.parse(await readFile(catalogFile, "utf8"));
712
756
  if (catalog.name !== "@cannbot-plugin/workflow-catalog" || !validVersion(catalog.version) || catalog.cannbotWorkflowCatalog?.schemaVersion !== 1 || !Array.isArray(catalog.cannbotWorkflowCatalog.workflows)) throw new Error("Invalid installed workflow catalog");
713
- const baseline = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry);
757
+ const baseline = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry).filter((item) => this.shipped.has(item.id));
714
758
  if (!baseline.length || new Set(baseline.map((item) => item.id)).size !== baseline.length) throw new Error("Invalid installed workflow entries");
715
- const newerCatalog = this.index.catalogVersion && newerVersion(this.index.catalogVersion, catalog.version);
716
- const entries = newerCatalog ? [...this.index.entries] : [...baseline];
717
- for (const old of this.index.entries) if (!entries.some((item) => item.id === old.id)) entries.push(old);
718
- await this.save({
759
+ const newerCatalog = !replaceFromSource && this.index.catalogVersion && newerVersion(this.index.catalogVersion, catalog.version);
760
+ const entries = (newerCatalog ? [...this.index.entries] : [...baseline]).filter((item) => this.shipped.has(item.id));
761
+ for (const old of this.index.entries) if (this.shipped.has(old.id) && !entries.some((item) => item.id === old.id)) entries.push(old);
762
+ const next = {
719
763
  ...this.index,
720
764
  entries,
721
- catalogVersion: newerCatalog ? this.index.catalogVersion : catalog.version
722
- });
765
+ catalogVersion: newerCatalog ? this.index.catalogVersion : catalog.version,
766
+ records: { ...this.index.records },
767
+ history: { ...this.index.history },
768
+ pinned: [...this.index.pinned]
769
+ };
770
+ const dropped = Object.keys(next.records).filter((id) => !this.shipped.has(id));
771
+ for (const id of dropped) delete next.records[id];
772
+ for (const id of Object.keys(next.history)) if (!this.shipped.has(id)) delete next.history[id];
773
+ next.pinned = next.pinned.filter((id) => this.shipped.has(id));
723
774
  for (const entry of baseline.filter((item) => item.default)) {
724
- if (this.index.records[entry.id]) continue;
775
+ if (!replaceFromSource && this.index.records[entry.id]) continue;
725
776
  if (!catalog.dependencies?.[entry.package]) throw new Error(`Workflow dependency missing: ${entry.package}`);
726
777
  const packageFile = resolvePackage(entry.package);
727
778
  const manifest = JSON.parse(await readFile(packageFile, "utf8"));
728
779
  if (manifest.name !== entry.package || !validVersion(manifest.version)) throw new Error("Incorrect installed workflow dependency");
780
+ const identity = (replaceFromSource ? "source:" : "dependency:") + createHash("sha512").update(await readFile(join(dirname(packageFile), "workflow.json"))).digest("base64");
781
+ if (replaceFromSource && this.index.records[entry.id]?.integrity === identity) continue;
729
782
  const parent = join(this.root, "packages", entry.id);
730
783
  await mkdir(parent, { recursive: true });
731
784
  const directory = await mkdtemp(join(parent, `${manifest.version}-dependency-`));
@@ -733,7 +786,7 @@ var ContentManager = class {
733
786
  recursive: true,
734
787
  dereference: true
735
788
  });
736
- const integrity = "dependency:" + createHash("sha512").update(await readFile(join(directory, "workflow.json"))).digest("base64");
789
+ const integrity = identity;
737
790
  const record = {
738
791
  id: entry.id,
739
792
  package: entry.package,
@@ -743,15 +796,21 @@ var ContentManager = class {
743
796
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
744
797
  dependency: true
745
798
  };
746
- this.descriptor(record);
747
- await this.save({
748
- ...this.index,
749
- records: {
750
- ...this.index.records,
751
- [entry.id]: record
752
- }
753
- });
799
+ const previous = this.index;
800
+ try {
801
+ this.index = next;
802
+ this.descriptor(record);
803
+ } finally {
804
+ this.index = previous;
805
+ }
806
+ next.records[entry.id] = record;
807
+ if (replaceFromSource) next.pinned = next.pinned.filter((id) => id !== entry.id);
754
808
  }
809
+ await this.save(next);
810
+ for (const id of dropped) await rm(join(this.root, "packages", id), {
811
+ recursive: true,
812
+ force: true
813
+ }).catch(() => {});
755
814
  this.publish({
756
815
  phase: "ready",
757
816
  message: "Installed workflow dependencies are ready."
@@ -767,9 +826,9 @@ var ContentManager = class {
767
826
  });
768
827
  const catalog = await this.manifest(`${CATALOG_PACKAGE}@latest`);
769
828
  if (catalog.name !== "@cannbot-plugin/workflow-catalog" || !validVersion(catalog.version) || catalog.cannbotWorkflowCatalog?.schemaVersion !== 1 || !Array.isArray(catalog.cannbotWorkflowCatalog.workflows)) throw new Error("Unsupported workflow catalog");
770
- const entries = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry);
829
+ const entries = catalog.cannbotWorkflowCatalog.workflows.map(checkedEntry).filter((item) => this.shipped.has(item.id));
771
830
  if (!entries.length || new Set(entries.map((item) => item.id)).size !== entries.length) throw new Error("Invalid workflow catalog");
772
- for (const old of this.index.entries) if (this.index.records[old.id] && !entries.some((item) => item.id === old.id)) entries.push(old);
831
+ for (const old of this.index.entries) if (this.shipped.has(old.id) && this.index.records[old.id] && !entries.some((item) => item.id === old.id)) entries.push(old);
773
832
  await this.save({
774
833
  ...this.index,
775
834
  entries,
@@ -821,27 +880,45 @@ var ContentManager = class {
821
880
  const parent = join(this.root, "packages", entry.id);
822
881
  await mkdir(parent, { recursive: true });
823
882
  let directory = await mkdtemp(join(parent, `${manifest.version}-`));
824
- directory = await this.extract(manifest, directory);
825
- const record = {
826
- id: entry.id,
827
- package: entry.package,
828
- version: manifest.version,
829
- integrity: manifest.dist.integrity,
830
- directory: relative(this.root, directory).split(sep).join("/"),
831
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
832
- };
833
- this.descriptor(record);
834
- const history = { ...this.index.history };
835
- if (previous) history[entry.id] = [previous, ...(history[entry.id] ?? []).filter((item) => item.version !== previous.version)];
836
- await this.save({
837
- ...this.index,
838
- records: {
839
- ...this.index.records,
840
- [entry.id]: record
841
- },
842
- history,
843
- pinned: this.index.pinned.filter((item) => item !== entry.id)
844
- });
883
+ let committed = false;
884
+ try {
885
+ directory = await this.extract(manifest, directory);
886
+ const record = {
887
+ id: entry.id,
888
+ package: entry.package,
889
+ version: manifest.version,
890
+ integrity: manifest.dist.integrity,
891
+ directory: relative(this.root, directory).split(sep).join("/"),
892
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
893
+ };
894
+ this.descriptor(record);
895
+ const history = { ...this.index.history };
896
+ if (previous) history[entry.id] = [previous, ...(history[entry.id] ?? []).filter((item) => item.version !== previous.version)];
897
+ await this.save({
898
+ ...this.index,
899
+ records: {
900
+ ...this.index.records,
901
+ [entry.id]: record
902
+ },
903
+ history,
904
+ pinned: this.index.pinned.filter((item) => item !== entry.id)
905
+ });
906
+ committed = true;
907
+ } finally {
908
+ if (!committed) {
909
+ let retained = true;
910
+ try {
911
+ const active = JSON.parse(await readFile(join(this.root, "active.json"), "utf8"));
912
+ retained = Object.values(active.records).some((record) => inside(this.root, record.directory) === directory);
913
+ } catch (error) {
914
+ if (error.code === "ENOENT") retained = false;
915
+ }
916
+ if (!retained) await rm(directory, {
917
+ recursive: true,
918
+ force: true
919
+ }).catch(() => {});
920
+ }
921
+ }
845
922
  this.errors.delete(entry.id);
846
923
  } catch (error) {
847
924
  const message = String(error.message).slice(-500);
@@ -875,61 +952,30 @@ var ContentManager = class {
875
952
  });
876
953
  }
877
954
  async runInitializer(entry, args, cwd) {
878
- await new Promise((accept, reject) => {
879
- let output = "", stopped = false;
880
- const compatibility = join(import.meta.dirname, "../runtime/initializer-compat.cjs");
881
- const child = spawn(process.execPath, [
882
- "--require",
883
- compatibility,
884
- entry,
885
- ...args
886
- ], {
955
+ let output = "";
956
+ try {
957
+ await runProcess(process.execPath, [entry, ...args], {
887
958
  cwd,
888
- windowsHide: true,
889
- detached: process.platform !== "win32",
890
959
  stdio: [
891
960
  "ignore",
892
961
  "pipe",
893
962
  "pipe"
894
963
  ],
964
+ timeoutMs: 20 * 6e4,
895
965
  env: {
896
966
  ...process.env,
897
967
  ...process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {},
898
- CANNBOT_INITIALIZER_WORKSPACE: cwd,
899
968
  GIT_TERMINAL_PROMPT: "0",
900
969
  GCM_INTERACTIVE: "never"
970
+ },
971
+ onData: (data) => {
972
+ output = (output + data).slice(-16e3);
973
+ this.publish({ log: output.slice(-6e3) });
901
974
  }
902
975
  });
903
- const log = (data) => {
904
- output = (output + data).slice(-16e3);
905
- this.publish({ log: output.slice(-6e3) });
906
- };
907
- child.stdout.on("data", log);
908
- child.stderr.on("data", log);
909
- const timer = setTimeout(() => {
910
- stopped = true;
911
- if (child.pid) if (process.platform === "win32") spawn("taskkill", [
912
- "/PID",
913
- String(child.pid),
914
- "/T",
915
- "/F"
916
- ], {
917
- windowsHide: true,
918
- stdio: "ignore"
919
- });
920
- else try {
921
- process.kill(-child.pid, "SIGTERM");
922
- } catch {}
923
- }, 20 * 6e4);
924
- child.on("error", (error) => {
925
- clearTimeout(timer);
926
- reject(error);
927
- });
928
- child.on("close", (code) => {
929
- clearTimeout(timer);
930
- code === 0 && !stopped ? accept() : reject(new Error(stopped ? "Initialization timed out" : output.slice(-1500) || "Initialization failed"));
931
- });
932
- });
976
+ } catch (error) {
977
+ throw new Error(`${error.message}\n${output.slice(-1500)}`);
978
+ }
933
979
  }
934
980
  async prepare(cwd, id) {
935
981
  if (!validId(id)) throw new Error("Invalid workflow");
@@ -1108,6 +1154,887 @@ function registerContentRoutes(ctx, content) {
1108
1154
  });
1109
1155
  }
1110
1156
  //#endregion
1157
+ //#region src/native-workflow-bootstrap.mjs
1158
+ /** Plugin-owned Python bootstrap. Paths and prompts travel as argv, never interpolated code. */
1159
+ const pythonBootstrap = String.raw`
1160
+ import concurrent.futures
1161
+ import json
1162
+ import os
1163
+ import sys
1164
+ import threading
1165
+ import uuid
1166
+
1167
+ # Import the installed workflow's shared scheduler, including its sibling modules.
1168
+ script = os.path.abspath(sys.argv.pop(1))
1169
+ sys.path.insert(0, os.path.dirname(script))
1170
+ sys.argv[0] = script
1171
+ import orchestrator
1172
+
1173
+ # Use the unchanged CLI parser for ordinary YAML/workspace/prompt arguments.
1174
+ args = orchestrator.parse_args()
1175
+ if args.provider is not None:
1176
+ raise SystemExit("The plugin supplies its execution callback; do not pass a CLI provider")
1177
+
1178
+ class PipeExecutor:
1179
+ def __init__(self, output):
1180
+ self.output = output
1181
+ self.lock = threading.Lock()
1182
+ self.pending = {}
1183
+ threading.Thread(target=self._receive, daemon=True).start()
1184
+
1185
+ def _receive(self):
1186
+ try:
1187
+ for line in sys.stdin:
1188
+ response = json.loads(line)
1189
+ with self.lock:
1190
+ future = self.pending.pop(response['id'], None)
1191
+ if future is not None:
1192
+ future.set_result(response)
1193
+ finally:
1194
+ # Host loss releases the Python scheduler even while awaiting approval.
1195
+ os._exit(2)
1196
+
1197
+ def execute(self, entry, work_dir, path):
1198
+ request_id = str(uuid.uuid4())
1199
+ future = concurrent.futures.Future()
1200
+ with self.lock:
1201
+ self.pending[request_id] = future
1202
+ self.output.write(json.dumps({'id': request_id, 'taskId': entry['task_id'],
1203
+ 'role': entry['agent'], 'phase': entry['_phase'],
1204
+ 'prompt': entry['prompt'], 'workDir': work_dir}) + '\n')
1205
+ self.output.flush()
1206
+ response = future.result()
1207
+ with open(path, 'w', encoding='utf-8') as output:
1208
+ json.dump(response, output, ensure_ascii=False)
1209
+ return ('executed' if entry['_phase'] == 'execute' else ''), not response.get('ok', False)
1210
+
1211
+ # Preserve one protocol descriptor; scheduler/subprocess logs all go to stderr.
1212
+ protocol = os.fdopen(os.dup(sys.stdout.fileno()), 'w', encoding='utf-8')
1213
+ os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
1214
+ sys.stdout = sys.stderr
1215
+ executor = PipeExecutor(protocol)
1216
+ sys.exit(orchestrator.main(task_executor=executor.execute, args=args))
1217
+ `;
1218
+ //#endregion
1219
+ //#region src/workflow-todos.mjs
1220
+ const PREFIX$1 = "[工作流] ";
1221
+ const active = new Set([
1222
+ "running",
1223
+ "executed",
1224
+ "verifying",
1225
+ "awaiting_approval"
1226
+ ]);
1227
+ const labels = {
1228
+ pending: "待执行",
1229
+ running: "执行中",
1230
+ executed: "待验证",
1231
+ verifying: "验证中",
1232
+ awaiting_approval: "待审批",
1233
+ pass: "已完成",
1234
+ fail: "失败",
1235
+ skipped: "已跳过"
1236
+ };
1237
+ function externalWorkflowLaunch(event) {
1238
+ if (event.type !== "tool/call" || event.data.name !== "bash") return;
1239
+ try {
1240
+ const { command } = JSON.parse(event.data.arguments);
1241
+ if (!/(?:run_workflow|orchestrator)\.py(?:\s|["'])/.test(command)) return;
1242
+ const argument = (name) => {
1243
+ const match = command.match(new RegExp(`--${name}\\s+(?:"([^"$\\\\]+)"|'([^']+)'|([^\\s;|&<>"'$\\\\]+))`));
1244
+ const value = match?.[1] ?? match?.[2] ?? match?.[3];
1245
+ return value && isAbsolute(value) ? value : void 0;
1246
+ };
1247
+ const cwd = argument("work-dir"), yaml = argument("yaml");
1248
+ if (cwd && yaml) return {
1249
+ cwd,
1250
+ yaml,
1251
+ external: true
1252
+ };
1253
+ } catch {}
1254
+ }
1255
+ /** The native TodoItem has three states. Preserve richer scheduler states in its text. */
1256
+ async function readTaskNames(yaml) {
1257
+ const names = /* @__PURE__ */ new Map();
1258
+ try {
1259
+ const config = parse(await readFile(yaml, "utf8"));
1260
+ for (const node of config?.nodes ?? []) if (node?.id != null && typeof node.title === "string" && node.title.trim()) names.set(String(node.id), node.title.trim());
1261
+ } catch {}
1262
+ return names;
1263
+ }
1264
+ async function readCompletionOrder(cwd) {
1265
+ const order = [];
1266
+ try {
1267
+ for (const line of (await readFile(join(cwd, ".workflow/log.jsonl"), "utf8")).split("\n")) try {
1268
+ const event = JSON.parse(line);
1269
+ if (event.task_id != null && (["pass", "skipped"].includes(event.to) || event.verdict === "pass")) {
1270
+ const id = String(event.task_id), index = order.indexOf(id);
1271
+ if (index >= 0) order.splice(index, 1);
1272
+ order.push(id);
1273
+ }
1274
+ } catch {}
1275
+ } catch {}
1276
+ return order;
1277
+ }
1278
+ function workflowTodos(workflow, state, finished = false, taskNames = /* @__PURE__ */ new Map(), completionOrder = []) {
1279
+ const entries = Object.entries(state?.tasks ?? {});
1280
+ const completed = entries.filter(([, task]) => ["pass", "skipped"].includes(task?.status)).sort(([a], [b]) => completionOrder.indexOf(a) - completionOrder.indexOf(b));
1281
+ return [...entries.filter(([, task]) => !["pass", "skipped"].includes(task?.status)), ...completed].flatMap(([id, task]) => {
1282
+ if (!task || typeof task !== "object") return [];
1283
+ const detail = finished && active.has(task.status) ? finished === "paused" ? "已暂停" : "已中断" : labels[task.status] ?? "未知状态";
1284
+ const retry = Number.isSafeInteger(task.retries) && task.retries > 0 ? `,重试 ${task.retries}` : "";
1285
+ return [{
1286
+ content: `${PREFIX$1}${workflow || "当前工作流"} / ${taskNames.get(id) ?? id} · ${detail}${retry}`,
1287
+ status: ["pass", "skipped"].includes(task.status) ? "completed" : !finished && active.has(task.status) ? "in_progress" : "pending"
1288
+ }];
1289
+ });
1290
+ }
1291
+ /** Publish through DSH's existing durable todo projection, preserving non-workflow todos. */
1292
+ var WorkflowTodos = class {
1293
+ constructor(ctx) {
1294
+ this.ctx = ctx;
1295
+ this.sessions = /* @__PURE__ */ new Map();
1296
+ this.closed = false;
1297
+ this.restoring = /* @__PURE__ */ new Set();
1298
+ this.externalSessions = /* @__PURE__ */ new Map();
1299
+ this.externalRuns = /* @__PURE__ */ new Map();
1300
+ this.observer = setInterval(() => {
1301
+ for (const session of this.externalSessions.values()) this.restore(session, true);
1302
+ }, 1e3);
1303
+ this.observer.unref?.();
1304
+ this.releaseCreated = ctx.on?.("session/created", (session) => {
1305
+ this.restore(session);
1306
+ });
1307
+ for (const session of ctx.sessions?.list() ?? []) this.restore(session);
1308
+ this.release = ctx.on?.("session/event", (session, event) => {
1309
+ if (externalWorkflowLaunch(event)) {
1310
+ this.externalSessions.set(session.id, session);
1311
+ this.restore(session);
1312
+ }
1313
+ const entry = this.sessions.get(session.id);
1314
+ if (!entry || !["todo/write", "turn/start"].includes(event.type)) return;
1315
+ if (entry.queued) return;
1316
+ entry.queued = true;
1317
+ queueMicrotask(() => {
1318
+ entry.queued = false;
1319
+ this.sync(entry);
1320
+ });
1321
+ });
1322
+ }
1323
+ async restore(session, refresh = false) {
1324
+ if (this.closed || !this.ctx.sessionQuery || !session.header?.cwd || this.restoring.has(session.id)) return;
1325
+ this.restoring.add(session.id);
1326
+ try {
1327
+ const history = refresh ? { events: [] } : await this.ctx.sessionQuery.readSession(session.id);
1328
+ const calls = /* @__PURE__ */ new Map(), runs = refresh ? new Map(this.externalRuns.get(session.id) ?? []) : /* @__PURE__ */ new Map();
1329
+ for (const event of history.events) {
1330
+ if (event.seq < (session.inheritedEventCount ?? 0)) continue;
1331
+ const external = externalWorkflowLaunch(event);
1332
+ if (external) {
1333
+ runs.set(external.cwd, external);
1334
+ this.externalSessions.set(session.id, session);
1335
+ }
1336
+ if (event.type === "tool/call" && event.data.name === "cannbot_workflow_run") try {
1337
+ calls.set(event.data.callId, JSON.parse(event.data.arguments));
1338
+ } catch {}
1339
+ if (event.type !== "tool/result" || event.data.isError) continue;
1340
+ const message = event.data.message, args = calls.get(message?.toolCallId);
1341
+ if (!args) continue;
1342
+ for (const block of message.content ?? []) try {
1343
+ const result = JSON.parse(block.text);
1344
+ if (result.runId && typeof result.workDir === "string") {
1345
+ for (const [cwd, old] of runs) if (old.workflow === args.workflow) runs.delete(cwd);
1346
+ runs.set(result.workDir, {
1347
+ cwd: result.workDir,
1348
+ workflow: args.workflow
1349
+ });
1350
+ }
1351
+ } catch {}
1352
+ }
1353
+ const latest = [...runs.values()].at(-1);
1354
+ if (latest?.external) {
1355
+ runs.clear();
1356
+ runs.set(latest.cwd, latest);
1357
+ this.externalRuns.set(session.id, new Map(runs));
1358
+ } else {
1359
+ for (const [cwd, run] of runs) if (run.external) runs.delete(cwd);
1360
+ this.externalRuns.delete(session.id);
1361
+ this.externalSessions.delete(session.id);
1362
+ }
1363
+ const workspace = await realpath(session.header.cwd);
1364
+ for (const run of runs.values()) try {
1365
+ const path = await realpath(join(run.cwd, ".workflow/status.json"));
1366
+ const diff = relative(workspace, path);
1367
+ if (diff === ".." || diff.startsWith("../") || isAbsolute(diff)) continue;
1368
+ const state = JSON.parse(await readFile(path, "utf8"));
1369
+ if (run.external) {
1370
+ const yaml = await realpath(run.yaml);
1371
+ if (!withinWorkspace(workspace, yaml) || await realpath(state.workflow) !== yaml) continue;
1372
+ run.workflow = parse(await readFile(yaml, "utf8")).workflow || "外部工作流";
1373
+ }
1374
+ run.taskNames = await readTaskNames(state.workflow);
1375
+ run.completionOrder = await readCompletionOrder(run.cwd);
1376
+ const existing = this.sessions.get(session.id)?.runs.get(run.cwd);
1377
+ if (this.closed || existing && !existing.external) continue;
1378
+ if (run.external) {
1379
+ const entry = this.sessions.get(session.id);
1380
+ for (const [cwd, old] of entry?.runs ?? []) if (cwd !== run.cwd && old.workflow === run.workflow && (old.external || old.finished)) entry.runs.delete(cwd);
1381
+ }
1382
+ this.update({
1383
+ id: session.id,
1384
+ session
1385
+ }, run, state, !run.external);
1386
+ } catch {}
1387
+ } catch (error) {
1388
+ this.ctx.logger?.warn(`Cannot restore workflow todos for ${session.id}: ${error.message}`);
1389
+ } finally {
1390
+ this.restoring.delete(session.id);
1391
+ }
1392
+ }
1393
+ update(parent, run, state, finished = false) {
1394
+ if (this.closed || !parent.session || !this.ctx.sessionProjections?.stateOf) return;
1395
+ let entry = this.sessions.get(parent.id);
1396
+ if (!entry) {
1397
+ entry = {
1398
+ session: parent.session,
1399
+ runs: /* @__PURE__ */ new Map(),
1400
+ queued: false
1401
+ };
1402
+ this.sessions.set(parent.id, entry);
1403
+ }
1404
+ for (const [cwd, old] of entry.runs) if (cwd !== run.cwd && old.workflow === run.workflow && old.finished) entry.runs.delete(cwd);
1405
+ const previous = entry.runs.get(run.cwd);
1406
+ const completed = Object.keys(state?.tasks ?? {}).filter((id) => ["pass", "skipped"].includes(state.tasks[id]?.status));
1407
+ const completionOrder = (previous?.completionOrder ?? run.completionOrder ?? []).filter((id) => completed.includes(id));
1408
+ for (const id of completed) if (!completionOrder.includes(id)) completionOrder.push(id);
1409
+ entry.runs.set(run.cwd, {
1410
+ workflow: run.workflow,
1411
+ cwd: run.cwd,
1412
+ external: run.external,
1413
+ taskNames: run.taskNames,
1414
+ completionOrder,
1415
+ state,
1416
+ finished
1417
+ });
1418
+ this.sync(entry);
1419
+ }
1420
+ sync(entry) {
1421
+ if (this.closed || this.sessions.get(entry.session.id) !== entry) return;
1422
+ try {
1423
+ const current = this.ctx.sessionProjections.stateOf(entry.session, "todos");
1424
+ if (current === void 0) return;
1425
+ const existing = current ?? [];
1426
+ const names = [...entry.runs.values()].map((run) => run.workflow);
1427
+ const rows = [...entry.runs.values()].flatMap((run) => workflowTodos(names.filter((name) => name === run.workflow).length > 1 ? `${run.workflow} (${run.cwd})` : run.workflow, run.state, run.finished, run.taskNames, run.completionOrder));
1428
+ const todos = [...existing.filter((item) => !item.content.startsWith(PREFIX$1)), ...rows];
1429
+ if (JSON.stringify(todos) !== JSON.stringify(existing)) entry.session.append("todo/write", { todos });
1430
+ } catch {}
1431
+ }
1432
+ dispose() {
1433
+ this.closed = true;
1434
+ clearInterval(this.observer);
1435
+ this.externalSessions.clear();
1436
+ this.externalRuns.clear();
1437
+ this.release?.();
1438
+ this.releaseCreated?.();
1439
+ this.sessions.clear();
1440
+ }
1441
+ };
1442
+ function withinWorkspace(root, path) {
1443
+ const diff = relative(root, path);
1444
+ return diff !== ".." && !diff.startsWith("../") && !isAbsolute(diff);
1445
+ }
1446
+ //#endregion
1447
+ //#region src/native-workflow.mjs
1448
+ /** Python owns scheduling and verdicts; each dispatched node runs in DSH's native child loop. */
1449
+ function within(root, path) {
1450
+ const diff = relative(root, path);
1451
+ return diff !== ".." && !diff.startsWith("../") && !diff.startsWith("..\\") && !isAbsolute(diff);
1452
+ }
1453
+ var NativeWorkflow = class {
1454
+ constructor(ctx) {
1455
+ this.ctx = ctx;
1456
+ this.todos = new WorkflowTodos(ctx);
1457
+ this.runs = /* @__PURE__ */ new Map();
1458
+ this.closed = false;
1459
+ }
1460
+ async start(parent, { runner, yaml, cwd, resources, prompt, workflow = "", goalId }) {
1461
+ if (this.closed) throw new Error("DSH workflow runner is stopping");
1462
+ await mkdir(cwd, { recursive: true });
1463
+ cwd = await realpath(cwd);
1464
+ for (const run of this.runs.values()) if (run.cwd === cwd && run.code === null) throw new Error("A workflow is already running in this directory");
1465
+ const id = randomUUID(), controller = new AbortController();
1466
+ const run = {
1467
+ id,
1468
+ owner: parent.id,
1469
+ goalId,
1470
+ workflow,
1471
+ cwd,
1472
+ code: null,
1473
+ error: "",
1474
+ controller,
1475
+ process: null,
1476
+ done: null,
1477
+ log: "",
1478
+ finalState: void 0
1479
+ };
1480
+ this.runs.set(id, run);
1481
+ let file;
1482
+ try {
1483
+ run.taskNames = await readTaskNames(yaml);
1484
+ run.completionOrder = await readCompletionOrder(cwd);
1485
+ await mkdir(join(cwd, ".workflow"), { recursive: true });
1486
+ file = await open(join(cwd, ".workflow/dsh-runner.log"), "a");
1487
+ controller.signal.throwIfAborted();
1488
+ if (this.closed) throw new Error("DSH workflow runner is stopping");
1489
+ const argv = [
1490
+ "-c",
1491
+ pythonBootstrap,
1492
+ runner,
1493
+ "--yaml",
1494
+ yaml,
1495
+ "--work-dir",
1496
+ cwd
1497
+ ];
1498
+ if (prompt) argv.push("--prompt", prompt);
1499
+ const child = spawn("python3", argv, {
1500
+ cwd,
1501
+ stdio: [
1502
+ "pipe",
1503
+ "pipe",
1504
+ "pipe"
1505
+ ]
1506
+ });
1507
+ run.process = child;
1508
+ const pending = /* @__PURE__ */ new Set();
1509
+ const lines = createInterface({ input: child.stdout });
1510
+ let logging = Promise.resolve();
1511
+ const log = (text) => {
1512
+ run.log = (run.log + String(text)).slice(-8192);
1513
+ logging = logging.then(() => file.appendFile(text)).catch((error) => {
1514
+ run.error = String(error);
1515
+ });
1516
+ };
1517
+ child.stderr.on("data", log);
1518
+ child.stdin.on("error", (error) => {
1519
+ if (!controller.signal.aborted) run.error = String(error);
1520
+ });
1521
+ child.on("error", (error) => {
1522
+ run.error = String(error);
1523
+ });
1524
+ lines.on("line", (line) => {
1525
+ let request;
1526
+ try {
1527
+ request = JSON.parse(line);
1528
+ if (typeof request.id !== "string" || typeof request.prompt !== "string" || typeof request.taskId !== "string" || ![
1529
+ "execute",
1530
+ "verify",
1531
+ "advice"
1532
+ ].includes(request.phase) || resolve(request.workDir) !== cwd) throw new Error("Invalid Python dispatch");
1533
+ } catch (error) {
1534
+ run.error = String(error);
1535
+ controller.abort();
1536
+ child.kill();
1537
+ return;
1538
+ }
1539
+ const job = this.executeNode(parent, request, resources, controller.signal).catch((error) => ({
1540
+ ok: false,
1541
+ error: String(error)
1542
+ })).then((result) => {
1543
+ if (!child.stdin.destroyed && !controller.signal.aborted) child.stdin.write(JSON.stringify({
1544
+ id: request.id,
1545
+ ...result
1546
+ }) + "\n");
1547
+ });
1548
+ pending.add(job);
1549
+ job.finally(() => pending.delete(job));
1550
+ });
1551
+ run.done = new Promise((accept) => child.on("close", async (code, signal) => {
1552
+ run.todoStopped = true;
1553
+ clearTimeout(run.todoTimer);
1554
+ await run.todoPoll;
1555
+ controller.abort();
1556
+ lines.close();
1557
+ await Promise.allSettled([...pending]);
1558
+ await logging;
1559
+ await file.close().catch((error) => {
1560
+ run.error = String(error);
1561
+ });
1562
+ try {
1563
+ run.finalState = JSON.parse(await readFile(join(cwd, ".workflow/status.json"), "utf8"));
1564
+ } catch {}
1565
+ run.code = code ?? 2;
1566
+ if (signal) run.error ||= `Scheduler terminated by ${signal}`;
1567
+ this.todos.update(parent, run, run.finalState, run.paused ? "paused" : true);
1568
+ accept();
1569
+ }));
1570
+ this.trackTodos(parent, run);
1571
+ return {
1572
+ runId: id,
1573
+ workDir: cwd,
1574
+ status: "running"
1575
+ };
1576
+ } catch (error) {
1577
+ this.runs.delete(id);
1578
+ await file?.close();
1579
+ throw error;
1580
+ }
1581
+ }
1582
+ trackTodos(parent, run) {
1583
+ if (!parent.session || !this.ctx.sessionProjections?.stateOf) return;
1584
+ const poll = () => {
1585
+ run.todoPoll = (async () => {
1586
+ try {
1587
+ const state = JSON.parse(await readFile(join(run.cwd, ".workflow/status.json"), "utf8"));
1588
+ if (!run.todoStopped) this.todos.update(parent, run, state);
1589
+ } catch {}
1590
+ if (!run.todoStopped) run.todoTimer = setTimeout(poll, 1e3);
1591
+ })();
1592
+ };
1593
+ poll();
1594
+ }
1595
+ async executeNode(parent, request, resources, signal) {
1596
+ if (typeof request.role !== "string" || !/^[\w-]+$/.test(request.role)) throw new Error("Invalid workflow role");
1597
+ const role = await readFile(join(resources, "agents", request.role + ".md"), "utf8");
1598
+ signal.throwIfAborted();
1599
+ const activation = await this.ctx.subagents.startActivation({
1600
+ provider: "spawn",
1601
+ delivery: "caller",
1602
+ signal,
1603
+ label: `CANNBot ${request.taskId} · ${request.phase}`,
1604
+ request: {
1605
+ parent,
1606
+ cwd: request.workDir,
1607
+ prompt: [{
1608
+ type: "text",
1609
+ text: `Execute one Python-scheduled workflow node using the DSH agent loop.\nWorkflow resources: ${resources}\nSkills: ${join(resources, "skills")}\nRole instructions (frontmatter is metadata):\n${role}\n\nFollow the task's verdict callback instructions. Python owns dependencies, retries, approvals and status updates.\n` + request.prompt
1610
+ }]
1611
+ }
1612
+ });
1613
+ const cancel = () => {
1614
+ activation.dispose().catch(() => {});
1615
+ };
1616
+ signal.addEventListener("abort", cancel, { once: true });
1617
+ if (signal.aborted) cancel();
1618
+ try {
1619
+ const result = await activation.result;
1620
+ return {
1621
+ ok: !signal.aborted && result.stopReason === "completed",
1622
+ childSessionId: activation.childId,
1623
+ stopReason: result.stopReason,
1624
+ output: result.output,
1625
+ diagnostic: result.diagnostic
1626
+ };
1627
+ } finally {
1628
+ signal.removeEventListener("abort", cancel);
1629
+ await activation.dispose();
1630
+ }
1631
+ }
1632
+ owned(parent, id) {
1633
+ const run = this.runs.get(id);
1634
+ if (!run || run.owner !== parent.id) throw new Error("Workflow run is unavailable in this session");
1635
+ return run;
1636
+ }
1637
+ async status(parent, id, waitMs = 0) {
1638
+ const run = this.owned(parent, id);
1639
+ if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 3e4) throw new Error("waitMs must be between 0 and 30000");
1640
+ let timer;
1641
+ if (run.done && run.code === null && waitMs) try {
1642
+ await Promise.race([run.done, new Promise((accept) => {
1643
+ timer = setTimeout(accept, waitMs);
1644
+ })]);
1645
+ } finally {
1646
+ clearTimeout(timer);
1647
+ }
1648
+ let state = run.finalState;
1649
+ if (run.code === null) try {
1650
+ state = JSON.parse(await readFile(join(run.cwd, ".workflow/status.json"), "utf8"));
1651
+ } catch (error) {
1652
+ if (error.code !== "ENOENT") throw error;
1653
+ }
1654
+ if (state) this.todos.update(parent, run, state, run.code !== null && (run.paused ? "paused" : true));
1655
+ return {
1656
+ runId: id,
1657
+ workDir: run.cwd,
1658
+ status: run.code === null ? "running" : run.paused ? "paused" : run.code === 0 ? "completed" : "failed",
1659
+ exitCode: run.code,
1660
+ error: run.error || void 0,
1661
+ state,
1662
+ log: run.log
1663
+ };
1664
+ }
1665
+ async stop(run) {
1666
+ run.controller.abort();
1667
+ run.process?.stdin.end();
1668
+ run.process?.kill("SIGTERM");
1669
+ const timer = setTimeout(() => run.process?.kill("SIGKILL"), 3e3);
1670
+ try {
1671
+ await run.done;
1672
+ } finally {
1673
+ clearTimeout(timer);
1674
+ }
1675
+ }
1676
+ async cancel(parent, id) {
1677
+ const run = this.owned(parent, id);
1678
+ if (run.code === null) await this.stop(run);
1679
+ return this.status(parent, id);
1680
+ }
1681
+ async dispose() {
1682
+ this.closed = true;
1683
+ await Promise.all([...this.runs.values()].filter((run) => run.code === null).map((run) => this.stop(run)));
1684
+ this.todos.dispose();
1685
+ }
1686
+ };
1687
+ //#endregion
1688
+ //#region src/goal-workflow-resume.mjs
1689
+ /** Recover successful starts belonging to this goal, excluding fork-inherited work. */
1690
+ function goalWorkflowRuns(events, goalId, inherited = 0) {
1691
+ const calls = /* @__PURE__ */ new Map(), runs = /* @__PURE__ */ new Map();
1692
+ let currentGoal, turn, hadGoal = false;
1693
+ for (const event of events) {
1694
+ if (event.seq < inherited) continue;
1695
+ if (event.type === "tool/call") turn = event.data.turn;
1696
+ if (event.type === "goal/change") {
1697
+ if (!hadGoal && event.data.operation === "create") {
1698
+ for (const call of calls.values()) if (call.turn === turn) call.goal = event.data.goal?.id;
1699
+ }
1700
+ currentGoal = event.data.goal?.id;
1701
+ hadGoal = true;
1702
+ }
1703
+ if (event.type === "tool/call" && event.data.name === "cannbot_workflow_run") try {
1704
+ calls.set(event.data.callId, {
1705
+ args: JSON.parse(event.data.arguments),
1706
+ goal: currentGoal,
1707
+ turn
1708
+ });
1709
+ } catch {}
1710
+ if (event.type !== "tool/result" || event.data.isError) continue;
1711
+ const message = event.data.message, call = calls.get(message?.toolCallId);
1712
+ if (!call) continue;
1713
+ for (const block of message.content ?? []) try {
1714
+ const result = JSON.parse(block.text);
1715
+ if (result.runId && typeof result.workDir === "string") runs.set(result.workDir, {
1716
+ call,
1717
+ workDir: result.workDir
1718
+ });
1719
+ } catch {}
1720
+ }
1721
+ return [...runs.values()].filter((run) => run.call.goal === goalId).map(({ call, workDir }) => ({
1722
+ workflow: call.args.workflow,
1723
+ yaml: call.args.yaml,
1724
+ workDir
1725
+ }));
1726
+ }
1727
+ function installGoalWorkflowResume(ctx, execution, start) {
1728
+ const pending = /* @__PURE__ */ new Map();
1729
+ const stopping = /* @__PURE__ */ new Map();
1730
+ ctx.on("goal/changed", ({ agent, change }) => {
1731
+ if (change.operation === "resume") pending.set(agent, change.ref);
1732
+ else pending.delete(agent);
1733
+ if (change.operation === "pause") {
1734
+ const previous = stopping.get(agent);
1735
+ const job = (async () => {
1736
+ await previous;
1737
+ const history = await ctx.sessionQuery.readSession(agent.id);
1738
+ const directories = new Set(goalWorkflowRuns(history.events, change.ref.id, agent.session.inheritedEventCount).map((run) => run.workDir));
1739
+ await Promise.all([...execution.runs.values()].filter((run) => run.owner === agent.id && run.code === null && (run.goalId === change.ref.id || directories.has(run.cwd))).map((run) => {
1740
+ run.paused = true;
1741
+ return execution.stop(run);
1742
+ }));
1743
+ })();
1744
+ stopping.set(agent, job);
1745
+ job.catch((error) => ctx.logger?.warn(`Cannot pause workflow: ${error}`));
1746
+ return job;
1747
+ }
1748
+ });
1749
+ ctx.on("agent/disposed", ({ agent }) => {
1750
+ pending.delete(agent);
1751
+ stopping.delete(agent);
1752
+ });
1753
+ ctx.on("dispose", () => {
1754
+ pending.clear();
1755
+ stopping.clear();
1756
+ });
1757
+ ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
1758
+ const decision = await next();
1759
+ const ref = pending.get(agent);
1760
+ if (decision.kind !== "enter" || !ref) return decision;
1761
+ const valid = () => {
1762
+ const goal = ctx.goals.get(agent);
1763
+ return pending.get(agent) === ref && goal?.id === ref.id && goal.revision === ref.revision && goal.phase === "active";
1764
+ };
1765
+ if (!valid()) return decision;
1766
+ const notices = [];
1767
+ try {
1768
+ await stopping.get(agent);
1769
+ signal.throwIfAborted();
1770
+ if (!valid()) return decision;
1771
+ const runs = goalWorkflowRuns((await ctx.sessionQuery.readSession(agent.id)).events, ref.id, agent.session.inheritedEventCount);
1772
+ const workspace = await realpath(await ctx.workingDirectory.ensure(agent, signal));
1773
+ for (const args of runs) {
1774
+ signal.throwIfAborted();
1775
+ if (!valid()) break;
1776
+ const cwd = await realpath(args.workDir);
1777
+ if (!within(workspace, cwd)) throw new Error("Workflow directory escapes the session workspace");
1778
+ const live = [...execution.runs.values()].find((run) => run.cwd === cwd && run.code === null);
1779
+ if (live) {
1780
+ if (live.owner !== agent.id) throw new Error("Workflow directory is running in another session");
1781
+ notices.push({
1782
+ workflow: args.workflow,
1783
+ runId: live.id,
1784
+ status: "running",
1785
+ reused: true
1786
+ });
1787
+ continue;
1788
+ }
1789
+ const path = await realpath(join(cwd, ".workflow/status.json"));
1790
+ if (!within(workspace, path)) throw new Error("Workflow state escapes the session workspace");
1791
+ const state = JSON.parse(await readFile(path, "utf8"));
1792
+ const tasks = Object.values(state.tasks ?? {});
1793
+ if (!tasks.length || tasks.every((task) => ["pass", "skipped"].includes(task.status))) continue;
1794
+ signal.throwIfAborted();
1795
+ if (!valid()) break;
1796
+ const result = await start(args, agent, signal);
1797
+ const run = execution.runs.get(result.runId);
1798
+ if (run) run.goalId = ref.id;
1799
+ if (!valid()) {
1800
+ if (run && run.code === null) {
1801
+ run.paused = true;
1802
+ await execution.stop(run);
1803
+ }
1804
+ break;
1805
+ }
1806
+ notices.push({
1807
+ workflow: args.workflow,
1808
+ ...result
1809
+ });
1810
+ }
1811
+ } catch (error) {
1812
+ if (signal.aborted) throw error;
1813
+ notices.push({
1814
+ error: String(error),
1815
+ instruction: "Workflow recovery failed. Diagnose this error; do not assume a stale running state means the scheduler is alive."
1816
+ });
1817
+ }
1818
+ if (pending.get(agent) === ref) pending.delete(agent);
1819
+ if (!notices.length) return decision;
1820
+ return {
1821
+ ...decision,
1822
+ messages: decision.messages.map((message, index) => index === 0 ? {
1823
+ ...message,
1824
+ content: [...message.content, {
1825
+ type: "text",
1826
+ text: `CANNBot workflow recovery after goal resume: ${JSON.stringify(notices)}\nUse cannbot_workflow_status with the reported runId to track scheduling; do not replace it with shell sleep/poll loops.`
1827
+ }]
1828
+ } : message)
1829
+ };
1830
+ });
1831
+ }
1832
+ //#endregion
1833
+ //#region src/pm-progress.mjs
1834
+ const PREFIX = "[PM 当前操作] ";
1835
+ const isWorkflow = (item) => item.content.startsWith("[工作流] ");
1836
+ const isProgress = (item) => item.content.startsWith(PREFIX);
1837
+ /** Keep native PM plans across turns; show factual activity until PM supplies a plan. */
1838
+ var PmProgress = class {
1839
+ constructor(ctx) {
1840
+ this.ctx = ctx;
1841
+ this.entries = /* @__PURE__ */ new Map();
1842
+ this.closed = false;
1843
+ this.releases = [ctx.on("session/created", (session) => {
1844
+ this.restore(session);
1845
+ }), ctx.on("session/event", (session, event) => this.event(session, event))];
1846
+ for (const session of ctx.sessions?.list() ?? []) this.restore(session);
1847
+ }
1848
+ event(session, event, replay = false) {
1849
+ if (this.closed) return;
1850
+ if (![
1851
+ "user/message",
1852
+ "tool/call",
1853
+ "tool/result",
1854
+ "todo/write",
1855
+ "turn/start",
1856
+ "turn/end"
1857
+ ].includes(event.type)) return;
1858
+ let entry = this.entries.get(session.id);
1859
+ if (event.type === "user/message" && event.data.source?.kind === "cannbot-harness-workflow") {
1860
+ if (!entry) {
1861
+ entry = {
1862
+ session,
1863
+ plan: [],
1864
+ activity: "准备工作流",
1865
+ status: "in_progress",
1866
+ queued: false
1867
+ };
1868
+ this.entries.set(session.id, entry);
1869
+ }
1870
+ }
1871
+ if (!entry) return;
1872
+ if (event.type === "tool/call" && event.data.name !== "todo_write") try {
1873
+ const args = JSON.parse(event.data.arguments);
1874
+ if (typeof args.description === "string" && args.description.trim()) {
1875
+ entry.activity = args.description.trim().slice(0, 240);
1876
+ entry.status = "in_progress";
1877
+ entry.callId = event.data.callId;
1878
+ }
1879
+ } catch {}
1880
+ if (event.type === "tool/result" && event.data.message?.toolCallId === entry.callId && event.data.isError) {
1881
+ entry.status = "pending";
1882
+ entry.activity = `操作失败,待处理:${entry.activity}`;
1883
+ }
1884
+ if (event.type === "todo/write") {
1885
+ const rows = event.data.todos ?? [];
1886
+ const plan = rows.filter((item) => !isWorkflow(item) && !isProgress(item));
1887
+ if (!rows.some(isProgress) && (plan.length || !rows.length)) entry.plan = plan;
1888
+ }
1889
+ if (event.type === "turn/end") entry.status = "pending";
1890
+ if (event.type === "turn/start") entry.status = "in_progress";
1891
+ if (!replay) this.queue(entry);
1892
+ }
1893
+ queue(entry) {
1894
+ if (entry.queued || this.closed) return;
1895
+ entry.queued = true;
1896
+ queueMicrotask(() => {
1897
+ entry.queued = false;
1898
+ if (this.closed || this.entries.get(entry.session.id) !== entry) return;
1899
+ const current = this.ctx.sessionProjections.stateOf(entry.session, "todos");
1900
+ if (current === void 0) return;
1901
+ const workflow = (current ?? []).filter(isWorkflow);
1902
+ const rows = [...entry.plan, ...workflow];
1903
+ if (!rows.length) rows.push({
1904
+ content: PREFIX + entry.activity,
1905
+ status: entry.status
1906
+ });
1907
+ if (JSON.stringify(current) !== JSON.stringify(rows)) entry.session.append("todo/write", { todos: rows });
1908
+ });
1909
+ }
1910
+ async restore(session) {
1911
+ try {
1912
+ const history = await this.ctx.sessionQuery.readSession(session.id);
1913
+ if (this.closed || this.entries.has(session.id)) return;
1914
+ for (const event of history.events) if (event.seq >= (session.inheritedEventCount ?? 0)) this.event(session, event, true);
1915
+ const entry = this.entries.get(session.id);
1916
+ if (entry) this.queue(entry);
1917
+ } catch (error) {
1918
+ this.ctx.logger?.warn(`Cannot restore PM progress: ${error}`);
1919
+ }
1920
+ }
1921
+ dispose() {
1922
+ this.closed = true;
1923
+ for (const release of this.releases) release?.();
1924
+ this.entries.clear();
1925
+ }
1926
+ };
1927
+ //#endregion
1928
+ //#region src/native-workflow-tools.mjs
1929
+ /** Register generic Python workflow scheduling tools when DSH's native loop is available. */
1930
+ function installNativeWorkflowTools(ctx, content) {
1931
+ ctx.inject([
1932
+ "tools",
1933
+ "subagents",
1934
+ "workingDirectory",
1935
+ "sessionProjections",
1936
+ "sessions",
1937
+ "sessionQuery"
1938
+ ], (scoped) => {
1939
+ const execution = new NativeWorkflow(scoped);
1940
+ const progress = new PmProgress(scoped);
1941
+ let goals;
1942
+ scoped.on("dispose", () => execution.dispose());
1943
+ scoped.on("dispose", () => progress.dispose());
1944
+ async function start(args, agent, signal) {
1945
+ const workspace = await realpath(await scoped.workingDirectory.ensure(agent, signal));
1946
+ await content.prepare(workspace, args.workflow);
1947
+ const source = content.getSource();
1948
+ const plugin = source.catalog.plugins.find((item) => item.id === args.workflow);
1949
+ if (!plugin) throw new Error("Unknown workflow package");
1950
+ const resources = await realpath(resolve(source.assets, plugin.resourceDirectory ?? plugin.id));
1951
+ const runner = join(resources, "skills/workflow-orchestrator/scripts/orchestrator.py");
1952
+ await access(runner);
1953
+ const yaml = await realpath(isAbsolute(args.yaml) ? args.yaml : resolve(resources, args.yaml));
1954
+ if (!within(resources, yaml) && !within(workspace, yaml)) throw new Error("Workflow YAML must belong to the package or workspace");
1955
+ const cwd = resolve(workspace, args.workDir ?? ".");
1956
+ if (!within(workspace, cwd)) throw new Error("Workflow directory must be inside the session workspace");
1957
+ let ancestor = cwd;
1958
+ while (true) try {
1959
+ if (!within(workspace, await realpath(ancestor))) throw new Error("Workflow directory escapes the session workspace");
1960
+ break;
1961
+ } catch (error) {
1962
+ if (error.code !== "ENOENT") throw error;
1963
+ ancestor = resolve(ancestor, "..");
1964
+ }
1965
+ signal.throwIfAborted();
1966
+ return execution.start(agent, {
1967
+ runner,
1968
+ yaml,
1969
+ cwd,
1970
+ resources,
1971
+ prompt: args.prompt,
1972
+ workflow: args.workflow,
1973
+ goalId: goals?.get(agent)?.id
1974
+ });
1975
+ }
1976
+ scoped.inject(["goals"], (goalScope) => {
1977
+ goals = goalScope.goals;
1978
+ goalScope.on("dispose", () => {
1979
+ goals = void 0;
1980
+ });
1981
+ installGoalWorkflowResume(goalScope, execution, start);
1982
+ });
1983
+ const string = {
1984
+ type: "string",
1985
+ required: true
1986
+ };
1987
+ const specs = [
1988
+ [
1989
+ "cannbot_workflow_run",
1990
+ {
1991
+ workflow: string,
1992
+ yaml: string,
1993
+ prompt: { type: "string" },
1994
+ workDir: { type: "string" }
1995
+ },
1996
+ "Run or resume a YAML workflow from the selected CANNBot workflow package. Python owns scheduling, parallelism, retries and verdicts; nodes use the DSH native agent loop and inherit the session model. No CLI provider is needed. yaml is absolute or relative to package resources; workDir defaults to the session workspace. prompt is required for a new run."
1997
+ ],
1998
+ [
1999
+ "cannbot_workflow_status",
2000
+ {
2001
+ runId: string,
2002
+ waitMs: { type: "number" }
2003
+ },
2004
+ "Read Python workflow state and log. Optionally wait up to 30000 ms. Awaiting approvals remain governed by the Python workflow approval scripts."
2005
+ ],
2006
+ [
2007
+ "cannbot_workflow_cancel",
2008
+ { runId: string },
2009
+ "Stop this session’s scheduler and native node agents. Start the same YAML and working directory again to recover through the Python resume logic."
2010
+ ]
2011
+ ];
2012
+ for (const [name, parameters, description] of specs) scoped.tools.register(defineTool({
2013
+ name,
2014
+ parameters,
2015
+ description,
2016
+ output: {
2017
+ schema: {
2018
+ type: "object",
2019
+ properties: {},
2020
+ additionalProperties: true
2021
+ },
2022
+ render: (_args, value) => [{
2023
+ type: "text",
2024
+ text: JSON.stringify(value)
2025
+ }]
2026
+ },
2027
+ async execute(args, exec) {
2028
+ if (!exec.agent) throw new Error("A live DSH session is required");
2029
+ if (name === "cannbot_workflow_status") return execution.status(exec.agent, args.runId, args.waitMs);
2030
+ if (name === "cannbot_workflow_cancel") return execution.cancel(exec.agent, args.runId);
2031
+ if (["provider", "model"].some((key) => key in args)) throw new Error("Workflow nodes inherit the DSH session model");
2032
+ return start(args, exec.agent, exec.signal);
2033
+ }
2034
+ }));
2035
+ });
2036
+ }
2037
+ //#endregion
1111
2038
  //#region src/index.ts
1112
2039
  const inject = ["skills"];
1113
2040
  async function apply(ctx, config = {}) {
@@ -1118,6 +2045,7 @@ async function apply(ctx, config = {}) {
1118
2045
  await content.installDependencies(catalogFile, (name) => dependencies.resolve(`${name}/package.json`));
1119
2046
  registerContentRoutes(ctx, content);
1120
2047
  installWorkflowContext(ctx, config, content);
2048
+ installNativeWorkflowTools(ctx, content);
1121
2049
  ctx.skills.registerProvider((control) => {
1122
2050
  let observed = content.getSource().revision;
1123
2051
  const release = content.subscribe(() => {